diff --git a/docs/architecture/parental-lock.md b/docs/architecture/parental-lock.md index 165387ec2..0197cf88e 100644 --- a/docs/architecture/parental-lock.md +++ b/docs/architecture/parental-lock.md @@ -98,8 +98,21 @@ locked default. `XtreamStore.reloadCategories()` + `reloadCachedContent()` and, if the selected category vanished, clears the selection and navigates to the section root. -- **Xtream (PWA):** `PwaXtreamDataSource` drops withheld categories and - streams at read time; the same reloads apply. +- **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 + can be empty while the offline cache is complete (every category locked), + so `ElectronXtreamDataSource` confirms an empty read with the unfiltered + `hasXtreamCategories` / `hasXtreamContent` before refetching from the + provider. +- **Routes:** `parentalLockXtreamCategoryGuard(section)` on every Xtream + `:categoryId` route (live, vod, series and their detail children) and + `parentalLockStalkerCategoryGuard(section)` on the Stalker vod/series + `:categoryId` routes prompt for the PIN when a locked category is reached + by URL — bookmark, typed address, stale link — and redirect to the section + root on refusal. Electron routes carry SQLite row ids, so the Xtream guard + maps them through the unfiltered category read; Stalker routes already + carry the genre id. - **Stalker:** genres are stored unfiltered; `getCategoryResource` filters them, `getAllCategoriesForSelectedType` is the raw list for the lock dialog. `itvFullChannelList` and the content loader drop rows whose @@ -110,7 +123,10 @@ locked default. portal page made only of locked rows does not end the list), a page that is entirely withheld requests the next page by itself, and the VOD/series `totalCount` is reduced by the withheld ids seen so the grid stops asking - once every visible row is in. A selected withheld genre is cleared and the + once every visible row is in. A lock flip while the list sits past page 1 + drops the withheld rows on screen at once and restarts from page 1, so rows + accumulated under the old lock state are never appended to. A selected + withheld genre is cleared and the section root is navigated to. - **M3U:** `ChannelListContainerComponent` derives one `visibleChannelList` (all views, favorites, recents, the fullscreen panel and numeric zapping diff --git a/libs/portal/stalker/data-access/src/lib/stores/features/with-stalker-content.feature.spec.ts b/libs/portal/stalker/data-access/src/lib/stores/features/with-stalker-content.feature.spec.ts index d798b2f2e..514d69a06 100644 --- a/libs/portal/stalker/data-access/src/lib/stores/features/with-stalker-content.feature.spec.ts +++ b/libs/portal/stalker/data-access/src/lib/stores/features/with-stalker-content.feature.spec.ts @@ -657,6 +657,56 @@ describe('withStalkerContent failure states', () => { expect(store.hasMoreContent()).toBe(false); }); + it('drops accumulated rows of a newly locked genre and restarts from page 1', async () => { + dataService.sendIpcEvent.mockImplementation( + (_event: unknown, payload: { params?: { p?: number } }) => { + const page = Number(payload.params?.p ?? 1); + const data = + page === 1 + ? [ + { id: 'movie-1', name: 'One', category_id: '5' }, + { id: 'adult-1', name: 'A', category_id: '9' }, + ] + : [{ id: 'movie-2', name: 'Two', category_id: '5' }]; + return Promise.resolve({ js: { data, total_items: 3 } }); + } + ); + + store.setSelectedContentType('vod'); + store.setCategories('vod', [ + { category_id: '5', category_name: 'Action' }, + { category_id: '9', category_name: 'Adult' }, + ]); + store.setSelectedCategory('*'); + store.setCurrentPlaylist(PLAYLIST); + void store.isPaginatedContentLoading(); + + await waitForCondition(() => store.getPaginatedContent().length === 2); + store.setPage(1); + await waitForCondition(() => store.getPaginatedContent().length === 3); + + // Lock now: the genre-9 row loaded on page 1 must leave the screen + // and the list must be rebuilt from page 1 under the new lock state. + parentalLock.active.mockReturnValue(true); + parentalLock.lockedStalkerIds.mockReturnValue(['9']); + parentalLock.version.set(1); + + await waitForCondition( + () => + store.page() === 0 && + !store.isPaginatedContentLoading() && + store + .getPaginatedContent() + .every((item) => item.category_id !== '9'), + 60 + ); + + expect(store.getPaginatedContent().map((item) => item.id)).toEqual([ + 'movie-1', + ]); + expect(store.totalCount()).toBe(2); + }); + it('keeps accumulated pages when an append fails and retries the same page', async () => { let failPageTwo = true; dataService.sendIpcEvent.mockImplementation( diff --git a/libs/portal/stalker/data-access/src/lib/stores/features/with-stalker-content.feature.ts b/libs/portal/stalker/data-access/src/lib/stores/features/with-stalker-content.feature.ts index 5fc3e8d4b..5d922e77a 100644 --- a/libs/portal/stalker/data-access/src/lib/stores/features/with-stalker-content.feature.ts +++ b/libs/portal/stalker/data-access/src/lib/stores/features/with-stalker-content.feature.ts @@ -290,6 +290,7 @@ export function withStalkerContent() { // adding nothing new — withheld or not — is a stalled portal. let withheldSeenKey = ''; const withheldSeenIds = new Set(); + let lastParentalLockVersion: number | null = null; return { categoryResource: resource({ params: () => ({ @@ -496,6 +497,55 @@ export function withStalkerContent() { playlist, params.contentType ); + // A lock flip while the list is past page 1 must + // not append filtered rows onto pages that were + // accumulated under the old lock state: drop the + // withheld rows on screen now and restart from + // page 1 so the list is rebuilt under the new one. + if ( + lastParentalLockVersion !== null && + params.parentalLockVersion !== + lastParentalLockVersion && + params.pageIndex > 1 + ) { + lastParentalLockVersion = + params.parentalLockVersion; + const retained = withoutWithheldStalkerItems( + store.paginatedContent(), + params.contentType, + withheldCategoryIds + ); + patchState(store, { + paginatedContent: retained, + ...(params.contentType === 'itv' + ? { + itvChannels: + withoutWithheldStalkerItems( + store.itvChannels(), + 'itv', + withheldCategoryIds + ), + } + : params.contentType === 'radio' + ? { + radioChannels: + withoutWithheldStalkerItems( + store.radioChannels(), + 'radio', + withheldCategoryIds + ), + } + : {}), + }); + ( + store as unknown as { + setPage?: (page: number) => void; + } + ).setPage?.(0); + return retained; + } + lastParentalLockVersion = + params.parentalLockVersion; if (params.contentType === 'itv') { const cachedChannels = diff --git a/libs/portal/stalker/feature/src/lib/parental-lock-category.guard.spec.ts b/libs/portal/stalker/feature/src/lib/parental-lock-category.guard.spec.ts new file mode 100644 index 000000000..9fafbab33 --- /dev/null +++ b/libs/portal/stalker/feature/src/lib/parental-lock-category.guard.spec.ts @@ -0,0 +1,87 @@ +import { TestBed } from '@angular/core/testing'; +import { + ActivatedRouteSnapshot, + convertToParamMap, + Router, + RouterStateSnapshot, + UrlTree, +} from '@angular/router'; +import { ParentalLockService } from '@iptvnator/services'; +import { parentalLockStalkerCategoryGuard } from './parental-lock-category.guard'; + +function createRoute(playlistId: string, categoryId: string) { + const parent = { + paramMap: convertToParamMap({ id: playlistId }), + } as ActivatedRouteSnapshot; + const route = { + paramMap: convertToParamMap({ categoryId }), + } as ActivatedRouteSnapshot; + Object.defineProperty(route, 'pathFromRoot', { + value: [parent, route], + }); + return route; +} + +describe('parentalLockStalkerCategoryGuard', () => { + let parentalLock: { + initialize: jest.Mock; + active: jest.Mock; + isStalkerCategoryLocked: jest.Mock; + requestUnlock: jest.Mock; + }; + let router: { createUrlTree: jest.Mock }; + + beforeEach(() => { + parentalLock = { + initialize: jest.fn().mockResolvedValue(undefined), + active: jest.fn(() => true), + isStalkerCategoryLocked: jest.fn(() => false), + requestUnlock: jest.fn().mockResolvedValue(false), + }; + router = { createUrlTree: jest.fn(() => ({}) as UrlTree) }; + TestBed.configureTestingModule({ + providers: [ + { provide: ParentalLockService, useValue: parentalLock }, + { provide: Router, useValue: router }, + ], + }); + }); + + function run(section: 'vod' | 'series', categoryId: string) { + return TestBed.runInInjectionContext(() => + parentalLockStalkerCategoryGuard(section)( + createRoute('portal-1', categoryId), + {} as RouterStateSnapshot + ) + ); + } + + it('passes an unlocked genre and an inactive lock', async () => { + await expect(run('vod', '9')).resolves.toBe(true); + parentalLock.active.mockReturnValue(false); + parentalLock.isStalkerCategoryLocked.mockReturnValue(true); + await expect(run('vod', '9')).resolves.toBe(true); + }); + + it('prompts for a locked genre and redirects to the section root on refusal', async () => { + parentalLock.isStalkerCategoryLocked.mockReturnValue(true); + + const result = await run('series', '9'); + + expect(parentalLock.isStalkerCategoryLocked).toHaveBeenCalledWith( + 'portal-1', + 'series', + '9' + ); + expect(router.createUrlTree).toHaveBeenCalledWith([ + '/workspace', + 'stalker', + 'portal-1', + 'series', + ]); + expect(result).not.toBe(true); + + parentalLock.requestUnlock.mockResolvedValue(true); + await expect(run('series', '9')).resolves.toBe(true); + }); +}); diff --git a/libs/portal/stalker/feature/src/lib/parental-lock-category.guard.ts b/libs/portal/stalker/feature/src/lib/parental-lock-category.guard.ts new file mode 100644 index 000000000..09c8f1531 --- /dev/null +++ b/libs/portal/stalker/feature/src/lib/parental-lock-category.guard.ts @@ -0,0 +1,62 @@ +import { inject } from '@angular/core'; +import { + ActivatedRouteSnapshot, + CanActivateFn, + Router, + UrlTree, +} from '@angular/router'; +import { ParentalLockService } from '@iptvnator/services'; +import { ParentalLockStalkerCategoryType } from '@iptvnator/shared/interfaces'; + +function findPlaylistId(route: ActivatedRouteSnapshot): string | null { + for (const snapshot of route.pathFromRoot) { + const id = snapshot.paramMap.get('id'); + if (id) { + return id; + } + } + return null; +} + +/** + * Keeps a parental-locked Stalker genre off the screen when reached by URL. + * Stalker routes carry the portal's own genre id, which is what the lock + * store keys on, so no lookup is needed. A locked genre prompts for the PIN; + * a refusal redirects to the section root. + */ +export function parentalLockStalkerCategoryGuard( + section: ParentalLockStalkerCategoryType +): CanActivateFn { + return async (route): Promise => { + const parentalLock = inject(ParentalLockService); + const router = inject(Router); + + await parentalLock.initialize(); + if (!parentalLock.active()) { + return true; + } + + const playlistId = findPlaylistId(route); + const categoryId = route.paramMap.get('categoryId'); + if ( + !playlistId || + !categoryId || + !parentalLock.isStalkerCategoryLocked( + playlistId, + section, + categoryId + ) + ) { + return true; + } + if (await parentalLock.requestUnlock()) { + return true; + } + return router.createUrlTree([ + '/workspace', + 'stalker', + playlistId, + section, + ]); + }; +} diff --git a/libs/portal/stalker/feature/src/lib/stalker-feature.routes.ts b/libs/portal/stalker/feature/src/lib/stalker-feature.routes.ts index d500a0784..73f270280 100644 --- a/libs/portal/stalker/feature/src/lib/stalker-feature.routes.ts +++ b/libs/portal/stalker/feature/src/lib/stalker-feature.routes.ts @@ -1,4 +1,5 @@ import { Route } from '@angular/router'; +import { parentalLockStalkerCategoryGuard } from './parental-lock-category.guard'; import { PORTAL_CATALOG_DETAIL_COMPONENT } from '@iptvnator/portal/shared/util'; import { StalkerCatalogDetailComponent } from './stalker-catalog-detail/stalker-catalog-detail.component'; import { provideStalkerCatalogFacade } from './stalker-catalog-facade.service'; @@ -77,6 +78,9 @@ export function createStalkerRoutes(): Route[] { }, { path: ':categoryId', + canActivate: [ + parentalLockStalkerCategoryGuard('vod'), + ], data: { api: 'stalker', contentType: 'vod', @@ -113,6 +117,9 @@ export function createStalkerRoutes(): Route[] { }, { path: ':categoryId', + canActivate: [ + parentalLockStalkerCategoryGuard('series'), + ], data: { api: 'stalker', contentType: 'series', diff --git a/libs/portal/xtream/data-access/src/lib/data-sources/electron-xtream-data-source.spec.ts b/libs/portal/xtream/data-access/src/lib/data-sources/electron-xtream-data-source.spec.ts index b42825b44..cb2a4d4c0 100644 --- a/libs/portal/xtream/data-access/src/lib/data-sources/electron-xtream-data-source.spec.ts +++ b/libs/portal/xtream/data-access/src/lib/data-sources/electron-xtream-data-source.spec.ts @@ -56,6 +56,26 @@ describe('ElectronXtreamDataSource (DB-first strategy)', () => { ); }); + it('keeps a complete cache whose every category the parental lock withholds', async () => { + // The filtered read returns nothing, but rows exist: refetching + // from the provider would be wasted work and, for content, a + // full re-import. + harness.dbService.getXtreamImportStatus.mockResolvedValue( + 'completed' + ); + harness.dbService.getXtreamCategories.mockResolvedValue([]); + harness.dbService.hasXtreamCategories.mockResolvedValue(true); + + const result = await harness.dataSource.getCategories( + playlistId, + credentials, + 'live' + ); + + expect(result).toEqual([]); + expect(harness.apiService.getCategories).not.toHaveBeenCalled(); + }); + it('fetches from the API and caches to DB when the cache is cold', async () => { const remoteCategories = [ { category_id: '10', category_name: 'News' }, 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 8da56809a..f6bc110ef 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 @@ -189,7 +189,14 @@ export class ElectronXtreamDataSource implements IXtreamDataSource { playlistId, dbType ); - if (importStatus === 'completed' && cached.length > 0) { + // The read is filtered by the parental lock, so an empty result is + // not proof of a cold cache: a type whose every category is locked + // is complete and must not be refetched from the provider. + if ( + importStatus === 'completed' && + (cached.length > 0 || + (await this.dbService.hasXtreamCategories(playlistId, dbType))) + ) { return cached; } @@ -334,7 +341,13 @@ export class ElectronXtreamDataSource implements IXtreamDataSource { // Fetch from DB directly — avoids a separate 'has' round-trip. // An empty result means the cache is cold; proceed to fetch from API. const cached = await this.dbService.getXtreamContent(playlistId, type); - if (importStatus === 'completed' && cached.length > 0) { + // Filtered by the parental lock like the category read: empty is not + // cold while unfiltered rows exist. + if ( + importStatus === 'completed' && + (cached.length > 0 || + (await this.dbService.hasXtreamContent(playlistId, type))) + ) { return cached; } diff --git a/libs/portal/xtream/data-access/src/lib/data-sources/pwa-xtream-data-source.spec.ts b/libs/portal/xtream/data-access/src/lib/data-sources/pwa-xtream-data-source.spec.ts index 3b56bd804..71bef9d59 100644 --- a/libs/portal/xtream/data-access/src/lib/data-sources/pwa-xtream-data-source.spec.ts +++ b/libs/portal/xtream/data-access/src/lib/data-sources/pwa-xtream-data-source.spec.ts @@ -5,7 +5,7 @@ import { XtreamApiService, XtreamCredentials, } from '../services/xtream-api.service'; -import { PlaylistsService } from '@iptvnator/services'; +import { ParentalLockService, PlaylistsService } from '@iptvnator/services'; import { of } from 'rxjs'; describe('PwaXtreamDataSource', () => { @@ -17,6 +17,10 @@ describe('PwaXtreamDataSource', () => { getPlaylistById: jest.Mock; transformPlaylistMeta: jest.Mock; }; + let parentalLock: { + active: jest.Mock; + lockedXtreamIds: jest.Mock; + }; const credentials: XtreamCredentials = { serverUrl: 'http://localhost:3211', @@ -34,10 +38,15 @@ describe('PwaXtreamDataSource', () => { getPlaylistById: jest.fn(() => of(undefined)), transformPlaylistMeta: jest.fn(() => of(null)), }; + parentalLock = { + active: jest.fn(() => false), + lockedXtreamIds: jest.fn(() => []), + }; TestBed.configureTestingModule({ providers: [ PwaXtreamDataSource, + { provide: ParentalLockService, useValue: parentalLock }, { provide: XtreamApiService, useValue: apiService, @@ -56,6 +65,37 @@ describe('PwaXtreamDataSource', () => { localStorage.clear(); }); + it('withholds locked categories from catalog reads and search while the lock is active', async () => { + apiService.getStreams.mockResolvedValue([ + { stream_id: 1, name: 'Family film', category_id: '5' }, + { stream_id: 2, name: 'Family after dark', category_id: '9' }, + ]); + parentalLock.active.mockReturnValue(true); + parentalLock.lockedXtreamIds.mockImplementation((_id, type) => + type === 'movies' ? [9] : [] + ); + + const content = await dataSource.getContent( + 'playlist-1', + credentials, + 'movie' + ); + const found = await dataSource.searchContent('playlist-1', 'family', [ + 'movie', + ]); + + expect(content.map((item) => item.name)).toEqual(['Family film']); + expect(found.map((item) => item.name)).toEqual(['Family film']); + + parentalLock.active.mockReturnValue(false); + const unlocked = await dataSource.searchContent( + 'playlist-1', + 'family', + ['movie'] + ); + expect(unlocked).toHaveLength(2); + }); + it('reports remote loading phases for API fetches but stays silent on cache hits', async () => { apiService.getStreams.mockResolvedValue([ { stream_id: 1, name: 'News' }, diff --git a/libs/portal/xtream/data-access/src/lib/data-sources/pwa-xtream-data-source.ts b/libs/portal/xtream/data-access/src/lib/data-sources/pwa-xtream-data-source.ts index ed668eee4..3a94887f5 100644 --- a/libs/portal/xtream/data-access/src/lib/data-sources/pwa-xtream-data-source.ts +++ b/libs/portal/xtream/data-access/src/lib/data-sources/pwa-xtream-data-source.ts @@ -612,7 +612,13 @@ export class PwaXtreamDataSource implements IXtreamDataSource { for (const type of types) { const cacheKey = `${playlistId}-${type}-content`; - const content = this.contentCache.get(cacheKey) || []; + // Same lock boundary as the catalog read: a search must not + // surface rows the category list withholds. + const content = this.withoutLockedContent( + playlistId, + type as StreamType, + this.contentCache.get(cacheKey) || [] + ); const filtered = content.filter((item) => { const title = diff --git a/libs/portal/xtream/feature/src/lib/parental-lock-category.guard.spec.ts b/libs/portal/xtream/feature/src/lib/parental-lock-category.guard.spec.ts new file mode 100644 index 000000000..372cb8c74 --- /dev/null +++ b/libs/portal/xtream/feature/src/lib/parental-lock-category.guard.spec.ts @@ -0,0 +1,122 @@ +import { TestBed } from '@angular/core/testing'; +import { + ActivatedRouteSnapshot, + convertToParamMap, + Router, + RouterStateSnapshot, + UrlTree, +} from '@angular/router'; +import { + DatabaseService, + ParentalLockService, + RuntimeCapabilitiesService, +} from '@iptvnator/services'; +import { parentalLockXtreamCategoryGuard } from './parental-lock-category.guard'; + +function createRoute(playlistId: string, categoryId: string) { + const parent = { + paramMap: convertToParamMap({ id: playlistId }), + } as ActivatedRouteSnapshot; + const route = { + paramMap: convertToParamMap({ categoryId }), + } as ActivatedRouteSnapshot; + Object.defineProperty(route, 'pathFromRoot', { + value: [parent, route], + }); + return route; +} + +describe('parentalLockXtreamCategoryGuard', () => { + let parentalLock: { + initialize: jest.Mock; + active: jest.Mock; + isXtreamCategoryLocked: jest.Mock; + requestUnlock: jest.Mock; + }; + let databaseService: { getAllXtreamCategories: jest.Mock }; + let runtime: { supportsXtreamSqliteDataSource: boolean }; + let router: { createUrlTree: jest.Mock }; + + beforeEach(() => { + parentalLock = { + initialize: jest.fn().mockResolvedValue(undefined), + active: jest.fn(() => true), + isXtreamCategoryLocked: jest.fn(() => false), + requestUnlock: jest.fn().mockResolvedValue(false), + }; + databaseService = { + getAllXtreamCategories: jest + .fn() + .mockResolvedValue([ + { id: 12, xtream_id: 900, type: 'movies' }, + ]), + }; + runtime = { supportsXtreamSqliteDataSource: true }; + router = { createUrlTree: jest.fn(() => ({}) as UrlTree) }; + TestBed.configureTestingModule({ + providers: [ + { provide: ParentalLockService, useValue: parentalLock }, + { provide: DatabaseService, useValue: databaseService }, + { provide: RuntimeCapabilitiesService, useValue: runtime }, + { provide: Router, useValue: router }, + ], + }); + }); + + function run(section: 'live' | 'vod' | 'series', categoryId: string) { + return TestBed.runInInjectionContext(() => + parentalLockXtreamCategoryGuard(section)( + createRoute('playlist-1', categoryId), + {} as RouterStateSnapshot + ) + ); + } + + it('passes through while the lock is inactive without touching the database', async () => { + parentalLock.active.mockReturnValue(false); + + await expect(run('vod', '12')).resolves.toBe(true); + expect(databaseService.getAllXtreamCategories).not.toHaveBeenCalled(); + }); + + it('maps the Electron row id to the provider id and redirects a refused locked category', async () => { + parentalLock.isXtreamCategoryLocked.mockReturnValue(true); + + const result = await run('vod', '12'); + + expect(parentalLock.isXtreamCategoryLocked).toHaveBeenCalledWith( + 'playlist-1', + 'movies', + 900 + ); + expect(parentalLock.requestUnlock).toHaveBeenCalled(); + expect(router.createUrlTree).toHaveBeenCalledWith([ + '/workspace', + 'xtreams', + 'playlist-1', + 'vod', + ]); + expect(result).not.toBe(true); + }); + + it('lets an entered PIN through', async () => { + parentalLock.isXtreamCategoryLocked.mockReturnValue(true); + parentalLock.requestUnlock.mockResolvedValue(true); + + await expect(run('series', '12')).resolves.toBe(true); + expect(router.createUrlTree).not.toHaveBeenCalled(); + }); + + it('uses the route id as the provider id in the PWA', async () => { + runtime.supportsXtreamSqliteDataSource = false; + + await run('live', '77'); + + expect(databaseService.getAllXtreamCategories).not.toHaveBeenCalled(); + expect(parentalLock.isXtreamCategoryLocked).toHaveBeenCalledWith( + 'playlist-1', + 'live', + 77 + ); + }); +}); diff --git a/libs/portal/xtream/feature/src/lib/parental-lock-category.guard.ts b/libs/portal/xtream/feature/src/lib/parental-lock-category.guard.ts new file mode 100644 index 000000000..045d46c79 --- /dev/null +++ b/libs/portal/xtream/feature/src/lib/parental-lock-category.guard.ts @@ -0,0 +1,100 @@ +import { inject } from '@angular/core'; +import { + ActivatedRouteSnapshot, + CanActivateFn, + Router, + UrlTree, +} from '@angular/router'; +import { + DatabaseService, + ParentalLockService, + RuntimeCapabilitiesService, +} from '@iptvnator/services'; +import { ParentalLockXtreamCategoryType } from '@iptvnator/shared/interfaces'; + +type XtreamCategorySection = 'live' | 'vod' | 'series'; + +const CATEGORY_TYPE_BY_SECTION: Record< + XtreamCategorySection, + ParentalLockXtreamCategoryType +> = { + live: 'live', + vod: 'movies', + series: 'series', +}; + +function findPlaylistId(route: ActivatedRouteSnapshot): string | null { + for (const snapshot of route.pathFromRoot) { + const id = snapshot.paramMap.get('id'); + if (id) { + return id; + } + } + return null; +} + +/** + * Keeps a parental-locked Xtream category off the screen even when it is + * reached by URL — a bookmark, a typed address or a stale in-app link — and + * not through the (already filtered) category rail. The lock is keyed by the + * provider category id; in Electron the route carries the SQLite row id, so + * the guard maps it through the unfiltered category read (which only the + * guard sees). A locked category prompts for the PIN; a refusal redirects + * to the section root instead of rendering the category or its detail. + */ +export function parentalLockXtreamCategoryGuard( + section: XtreamCategorySection +): CanActivateFn { + return async (route): Promise => { + const parentalLock = inject(ParentalLockService); + const router = inject(Router); + const databaseService = inject(DatabaseService); + const runtime = inject(RuntimeCapabilitiesService); + + await parentalLock.initialize(); + if (!parentalLock.active()) { + return true; + } + + const playlistId = findPlaylistId(route); + const rawCategoryId = Number(route.paramMap.get('categoryId')); + if (!playlistId || !Number.isFinite(rawCategoryId)) { + return true; + } + + const categoryType = CATEGORY_TYPE_BY_SECTION[section]; + let xtreamId = rawCategoryId; + if (runtime.supportsXtreamSqliteDataSource) { + const rows = await databaseService.getAllXtreamCategories( + playlistId, + categoryType + ); + const row = rows.find( + (candidate) => candidate.id === rawCategoryId + ); + if (!row) { + return true; + } + xtreamId = row.xtream_id; + } + + if ( + !parentalLock.isXtreamCategoryLocked( + playlistId, + categoryType, + xtreamId + ) + ) { + return true; + } + if (await parentalLock.requestUnlock()) { + return true; + } + return router.createUrlTree([ + '/workspace', + 'xtreams', + playlistId, + section, + ]); + }; +} diff --git a/libs/portal/xtream/feature/src/lib/xtream-feature.routes.ts b/libs/portal/xtream/feature/src/lib/xtream-feature.routes.ts index 1ac729eb7..b7e25db23 100644 --- a/libs/portal/xtream/feature/src/lib/xtream-feature.routes.ts +++ b/libs/portal/xtream/feature/src/lib/xtream-feature.routes.ts @@ -1,4 +1,5 @@ import { Route } from '@angular/router'; +import { parentalLockXtreamCategoryGuard } from './parental-lock-category.guard'; import { provideXtreamCatalogFacade } from './xtream-catalog-facade.service'; import { provideXtreamWorkspaceRouteSession } from './xtream-workspace-route-session.service'; @@ -85,6 +86,9 @@ export function createXtreamRoutes(): Route[] { }, { path: 'live/:categoryId', + canActivate: [ + parentalLockXtreamCategoryGuard('live'), + ], loadComponent: loadLiveStreamLayoutComponent, }, { @@ -98,11 +102,17 @@ export function createXtreamRoutes(): Route[] { }, { path: ':categoryId', + canActivate: [ + parentalLockXtreamCategoryGuard('vod'), + ], loadComponent: loadCategoryContentViewComponent, }, { path: ':categoryId/:vodId', + canActivate: [ + parentalLockXtreamCategoryGuard('vod'), + ], loadComponent: loadVodDetailsRouteComponent, }, ], @@ -118,11 +128,21 @@ export function createXtreamRoutes(): Route[] { }, { path: ':categoryId', + canActivate: [ + parentalLockXtreamCategoryGuard( + 'series' + ), + ], loadComponent: loadCategoryContentViewComponent, }, { path: ':categoryId/:serialId', + canActivate: [ + parentalLockXtreamCategoryGuard( + 'series' + ), + ], loadComponent: loadSerialDetailsComponent, }, ],