diff --git a/.changes/settings-parental-lock.md b/.changes/settings-parental-lock.md new file mode 100644 index 000000000..90d9b41f4 --- /dev/null +++ b/.changes/settings-parental-lock.md @@ -0,0 +1,8 @@ +--- +type: feature +area: settings +issues: [285] +highlight: Parental lock +--- + +New parental lock: set a PIN in Settings → Parental lock, then mark Xtream categories, Stalker genres or M3U groups as locked. Locked categories and their channels disappear from the app until the PIN is entered; the app locks again on restart, after a chosen idle time or with "Lock now". diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml index 93b8e120f..74e397a8f 100644 --- a/.github/workflows/codeql-analysis.yml +++ b/.github/workflows/codeql-analysis.yml @@ -15,6 +15,15 @@ on: schedule: - cron: '0 20 * * 3' +# A newer push to a pull request cancels that PR's still-running analysis: +# only the latest commit's result matters, and superseded runs otherwise hold +# runners the rest of the pipeline is queued for. Pushes to master, the weekly +# schedule and manual dispatches get a unique group (run_id), so they are never +# cancelled or replaced, the same pattern as ci.yml. +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.run_id }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + # Required so `codeql-action/analyze` can upload its SARIF results. Without an # explicit grant the default token is read-only and the upload fails with # "Resource not accessible by integration". diff --git a/apps/electron-backend/src/app/api/main.preload.spec-data.ts b/apps/electron-backend/src/app/api/main.preload.spec-data.ts index c9c597bb3..97b7a8fb2 100644 --- a/apps/electron-backend/src/app/api/main.preload.spec-data.ts +++ b/apps/electron-backend/src/app/api/main.preload.spec-data.ts @@ -176,9 +176,15 @@ export const dbPreloadCases: PreloadInvokeCase[] = [ }, { method: 'dbSaveCategories', - args: [playlistId, categories, 'live', categoryIds], + args: [playlistId, categories, 'live', categoryIds, categoryIds], channel: 'DB_SAVE_CATEGORIES', - forwardedArgs: [playlistId, categories, 'live', categoryIds], + forwardedArgs: [ + playlistId, + categories, + 'live', + categoryIds, + categoryIds, + ], }, { method: 'dbGetAllCategories', @@ -192,6 +198,12 @@ export const dbPreloadCases: PreloadInvokeCase[] = [ channel: 'DB_UPDATE_CATEGORY_VISIBILITY', forwardedArgs: [categoryIds, true], }, + { + method: 'dbSetCategoryLocks', + args: [playlistId, 'live', categoryIds], + channel: 'DB_SET_CATEGORY_LOCKS', + forwardedArgs: [playlistId, 'live', categoryIds], + }, { method: 'dbHasContent', args: [playlistId, 'movie'], diff --git a/apps/electron-backend/src/app/api/main.preload.ts b/apps/electron-backend/src/app/api/main.preload.ts index adb8fefb9..8c4557ebd 100644 --- a/apps/electron-backend/src/app/api/main.preload.ts +++ b/apps/electron-backend/src/app/api/main.preload.ts @@ -99,6 +99,7 @@ const WINDOW_CONFIRM_CLOSE = 'WINDOW:CONFIRM_CLOSE'; const WINDOW_CANCEL_CLOSE = 'WINDOW:CANCEL_CLOSE'; const WINDOW_CLOSE_REQUESTED = 'WINDOW:CLOSE_REQUESTED'; const PLAYBACK_SET_KEEP_AWAKE = 'PLAYBACK:SET_KEEP_AWAKE'; +const PARENTAL_LOCK_SET_STATE = 'PARENTAL_LOCK:SET_STATE'; const dbSaveContentProgressListeners = new Set< ( @@ -461,6 +462,8 @@ const electronApi: ElectronBridgeApi = { }, setPlaybackKeepAwake: (active: boolean) => ipcRenderer.invoke(PLAYBACK_SET_KEEP_AWAKE, active === true), + setParentalLockState: (active: boolean) => + ipcRenderer.invoke(PARENTAL_LOCK_SET_STATE, active === true), fetchPlaylistByUrl: ( url: string, title?: string, @@ -827,14 +830,16 @@ const electronApi: ElectronBridgeApi = { playlistId: string, categories: XtreamCategory[], type: string, - hiddenCategoryXtreamIds?: number[] + hiddenCategoryXtreamIds?: number[], + lockedCategoryXtreamIds?: number[] ) => ipcRenderer.invoke( 'DB_SAVE_CATEGORIES', playlistId, categories, type, - hiddenCategoryXtreamIds + hiddenCategoryXtreamIds, + lockedCategoryXtreamIds ), dbGetAllCategories: (playlistId: string, type: string) => ipcRenderer.invoke('DB_GET_ALL_CATEGORIES', playlistId, type), @@ -844,6 +849,17 @@ const electronApi: ElectronBridgeApi = { categoryIds, hidden ), + dbSetCategoryLocks: ( + playlistId: string, + type: string, + lockedXtreamIds: number[] + ) => + ipcRenderer.invoke( + 'DB_SET_CATEGORY_LOCKS', + playlistId, + type, + lockedXtreamIds + ), dbHasContent: (playlistId: string, type: string) => ipcRenderer.invoke('DB_HAS_CONTENT', playlistId, type), dbGetContent: (playlistId: string, type: string) => diff --git a/apps/electron-backend/src/app/api/main.preload.xtream-performance.spec-data.ts b/apps/electron-backend/src/app/api/main.preload.xtream-performance.spec-data.ts index 4f3855676..f8ed846ce 100644 --- a/apps/electron-backend/src/app/api/main.preload.xtream-performance.spec-data.ts +++ b/apps/electron-backend/src/app/api/main.preload.xtream-performance.spec-data.ts @@ -91,6 +91,9 @@ export const XTREAM_PRELOAD_TARGET_CASES: TargetCase[] = [ categoryItems, 'live', [91], + // Parental-lock ids: the preload forwards the optional fifth + // argument as-is, so an omitted one travels as undefined. + undefined, ], expectedMetadata: { ...EMPTY_METADATA, diff --git a/apps/electron-backend/src/app/database/operations/category.operations.performance.spec.ts b/apps/electron-backend/src/app/database/operations/category.operations.performance.spec.ts index f03a60895..020919ab9 100644 --- a/apps/electron-backend/src/app/database/operations/category.operations.performance.spec.ts +++ b/apps/electron-backend/src/app/database/operations/category.operations.performance.spec.ts @@ -74,6 +74,7 @@ describe('category operation performance phases', () => { ], 'live', [102], + undefined, recording.capture ) ).resolves.toEqual({ success: true }); diff --git a/apps/electron-backend/src/app/database/operations/category.operations.spec.ts b/apps/electron-backend/src/app/database/operations/category.operations.spec.ts index 2c38e5ab6..582efb537 100644 --- a/apps/electron-backend/src/app/database/operations/category.operations.spec.ts +++ b/apps/electron-backend/src/app/database/operations/category.operations.spec.ts @@ -4,7 +4,15 @@ import { getAllCategories, getCategories, saveCategories, + setCategoryLocks, } from './category.operations'; +import { SQLiteSyncDialect } from 'drizzle-orm/sqlite-core'; +import type { SQL } from 'drizzle-orm'; +import { setParentalLockActive } from '../parental-lock-state'; + +function renderSql(query: SQL): string { + return new SQLiteSyncDialect().sqlToQuery(query).sql; +} // Renderer consumers (XCategoryFromDb/XtreamCategoryFromDb) expect category // rows in this snake_case wire shape. A bare select() would return Drizzle's @@ -17,6 +25,7 @@ const categoryWireShape = { type: schema.categories.type, xtream_id: schema.categories.xtreamId, hidden: schema.categories.hidden, + locked: schema.categories.locked, }; function createDbMock(existingCount = 0) { @@ -93,6 +102,7 @@ describe('category.operations', () => { type: 'live', xtreamId: 101, hidden: false, + locked: false, }, { playlistId: 'playlist-1', @@ -100,6 +110,7 @@ describe('category.operations', () => { type: 'live', xtreamId: 102, hidden: true, + locked: false, }, ]); }); @@ -125,6 +136,7 @@ describe('category.operations', () => { type: 'movies', xtreamId: 201, hidden: true, + locked: false, }, ]); }); @@ -143,3 +155,120 @@ describe('category.operations', () => { expect(insert).not.toHaveBeenCalled(); }); }); + +describe('category.operations parental lock', () => { + afterEach(() => { + setParentalLockActive(false); + }); + + function createReadDb() { + const orderBy = jest.fn().mockResolvedValue([]); + const where = jest.fn().mockReturnValue({ orderBy }); + const from = jest.fn().mockReturnValue({ where }); + const select = jest.fn().mockReturnValue({ from }); + return { db: { select } as unknown as AppDatabase, where }; + } + + it('adds the locked filter to visible-category reads only while active', async () => { + const unlocked = createReadDb(); + await getCategories(unlocked.db, 'playlist-1', 'live'); + expect(renderSql(unlocked.where.mock.calls[0][0])).not.toContain( + '"locked"' + ); + + setParentalLockActive(true); + const locked = createReadDb(); + await getCategories(locked.db, 'playlist-1', 'live'); + expect(renderSql(locked.where.mock.calls[0][0])).toContain( + '"categories"."locked" = ?' + ); + }); + + it('never filters the management read, which lists locked rows by design', async () => { + setParentalLockActive(true); + const { db, where } = createReadDb(); + await getAllCategories(db, 'playlist-1', 'movies'); + expect(renderSql(where.mock.calls[0][0])).not.toContain('"locked"'); + }); + + it('stamps locked from the caller-supplied provider ids on insert', async () => { + const { db, values } = createDbMock(0); + + await saveCategories( + db, + 'playlist-1', + [ + { category_id: '1', category_name: 'Kids' }, + { category_id: '2', category_name: 'Adult' }, + ], + 'live', + undefined, + [2] + ); + + expect(values).toHaveBeenCalledWith([ + expect.objectContaining({ xtreamId: 1, locked: false }), + expect.objectContaining({ xtreamId: 2, locked: true }), + ]); + }); + + function lockIndexDb() { + const run = jest.fn(); + const where = jest.fn().mockReturnValue({ run }); + const set = jest.fn().mockReturnValue({ where }); + const update = jest.fn().mockReturnValue({ set }); + const transaction = jest.fn((callback: () => void) => callback()); + return { + db: { update, transaction } as unknown as AppDatabase, + run, + set, + transaction, + where, + }; + } + + it('re-stamps one playlist/type in one transaction: clears everything, then locks the listed ids', async () => { + const { db, run, set, transaction, where } = lockIndexDb(); + + await setCategoryLocks(db, 'playlist-1', 'live', [5, 5, 7, 1.5]); + + expect(transaction).toHaveBeenCalledTimes(1); + expect(run).toHaveBeenCalledTimes(2); + expect(set).toHaveBeenNthCalledWith(1, { locked: false }); + expect(set).toHaveBeenNthCalledWith(2, { locked: true }); + const lockScope = new SQLiteSyncDialect().sqlToQuery( + where.mock.calls[1][0] + ); + expect(lockScope.sql).toContain('"categories"."xtream_id" in (?, ?)'); + expect(lockScope.params).toEqual(['playlist-1', 'live', 5, 7]); + }); + + it('only clears when no id is locked', async () => { + const { db, set } = lockIndexDb(); + + await setCategoryLocks(db, 'playlist-1', 'series', []); + + expect(set).toHaveBeenCalledTimes(1); + expect(set).toHaveBeenCalledWith({ locked: false }); + }); +}); + +describe('setCategoryLocks atomicity', () => { + it('runs both statements inside the transaction callback', async () => { + const order: string[] = []; + const run = jest.fn(() => order.push('run')); + const where = jest.fn().mockReturnValue({ run }); + const set = jest.fn().mockReturnValue({ where }); + const update = jest.fn().mockReturnValue({ set }); + const transaction = jest.fn((callback: () => void) => { + order.push('begin'); + callback(); + order.push('commit'); + }); + const db = { update, transaction } as unknown as AppDatabase; + + await setCategoryLocks(db, 'playlist-1', 'live', [5]); + + expect(order).toEqual(['begin', 'run', 'run', 'commit']); + }); +}); diff --git a/apps/electron-backend/src/app/database/operations/category.operations.ts b/apps/electron-backend/src/app/database/operations/category.operations.ts index 8bfd5b6e8..f1d3e2415 100644 --- a/apps/electron-backend/src/app/database/operations/category.operations.ts +++ b/apps/electron-backend/src/app/database/operations/category.operations.ts @@ -2,6 +2,7 @@ import { and, eq, inArray, sql } from 'drizzle-orm'; import * as schema from '@iptvnator/shared/database/schema'; import { XTREAM_DATABASE_PERFORMANCE_PHASE } from '@iptvnator/shared/interfaces'; import type { AppDatabase } from '../database.types'; +import { unlockedCategoryCondition } from '../parental-lock-state'; import type { DatabaseOperationPerformancePhaseCapture } from './performance-phase-capture'; type XtreamCategoryInput = { @@ -23,6 +24,7 @@ const categoryWireShape = { type: schema.categories.type, xtream_id: schema.categories.xtreamId, hidden: schema.categories.hidden, + locked: schema.categories.locked, }; function normalizeXtreamCategoryId( @@ -37,9 +39,11 @@ function normalizeXtreamCategories( playlistId: string, categories: XtreamCategoryInput[], type: 'live' | 'movies' | 'series', - hiddenCategoryXtreamIds?: number[] + hiddenCategoryXtreamIds?: number[], + lockedCategoryXtreamIds?: number[] ): XtreamCategoryValue[] { const hiddenSet = new Set(hiddenCategoryXtreamIds || []); + const lockedSet = new Set(lockedCategoryXtreamIds || []); return categories.flatMap((category) => { const xtreamId = normalizeXtreamCategoryId(category.category_id); @@ -55,6 +59,7 @@ function normalizeXtreamCategories( type, xtreamId, hidden: hiddenSet.has(xtreamId), + locked: lockedSet.has(xtreamId), }, ]; }); @@ -111,7 +116,8 @@ export async function getCategories( and( eq(schema.categories.playlistId, playlistId), eq(schema.categories.type, type), - eq(schema.categories.hidden, false) + eq(schema.categories.hidden, false), + unlockedCategoryCondition() ) ) .orderBy(schema.categories.id); @@ -131,6 +137,7 @@ export async function saveCategories( categories: XtreamCategoryInput[], type: 'live' | 'movies' | 'series', hiddenCategoryXtreamIds?: number[], + lockedCategoryXtreamIds?: number[], capturePhase?: DatabaseOperationPerformancePhaseCapture ): Promise<{ success: boolean }> { if (!categories || categories.length === 0) { @@ -159,7 +166,8 @@ export async function saveCategories( playlistId, categories, type, - hiddenCategoryXtreamIds + hiddenCategoryXtreamIds, + lockedCategoryXtreamIds ), (result) => ({ itemCount: result.length }) ) @@ -167,7 +175,8 @@ export async function saveCategories( playlistId, categories, type, - hiddenCategoryXtreamIds + hiddenCategoryXtreamIds, + lockedCategoryXtreamIds ); if (values.length === 0) { @@ -220,3 +229,43 @@ export async function updateCategoryVisibility( return { success: true }; } + +/** + * Re-stamps the parental lock index for one playlist and category type from + * the renderer's lock store: listed provider ids become locked, every other + * row of that playlist/type is unlocked. Idempotent by construction, so the + * renderer can replay the store after a refresh or a backup restore. + */ +export async function setCategoryLocks( + db: AppDatabase, + playlistId: string, + type: 'live' | 'movies' | 'series', + lockedXtreamIds: number[] +): Promise<{ success: boolean }> { + const scope = and( + eq(schema.categories.playlistId, playlistId), + eq(schema.categories.type, type) + ); + const lockedIds = [ + ...new Set(lockedXtreamIds.filter((id) => Number.isInteger(id))), + ]; + + // One transaction: a re-stamp that fails after the clear would otherwise + // leave every category of this playlist/type unlocked while the lock + // store still lists the intended locks. `.run()` (synchronous), not + // `.execute()`: see playback-position.operations.ts. + await db.transaction(() => { + db.update(schema.categories).set({ locked: false }).where(scope).run(); + + if (lockedIds.length > 0) { + db.update(schema.categories) + .set({ locked: true }) + .where( + and(scope, inArray(schema.categories.xtreamId, lockedIds)) + ) + .run(); + } + }); + + return { success: true }; +} diff --git a/apps/electron-backend/src/app/database/operations/content.operations.ts b/apps/electron-backend/src/app/database/operations/content.operations.ts index 6a0a0b45c..6f6770ffd 100644 --- a/apps/electron-backend/src/app/database/operations/content.operations.ts +++ b/apps/electron-backend/src/app/database/operations/content.operations.ts @@ -15,6 +15,10 @@ import { XtreamGlobalSearchResult, } from '@iptvnator/shared/interfaces'; import type { AppDatabase } from '../database.types'; +import { + unlockedCategoryCondition, + unlockedCategorySql, +} from '../parental-lock-state'; import { countContentRowsByCategory, sumCategoryRowCounts, @@ -311,6 +315,7 @@ async function selectXtreamGlobalSearchCandidatesWithTitleIndex( )}) AND ${sql.join(titleConditions, sql` AND `)} ${excludeHidden ? sql`AND cat.hidden = 0` : sql``} + ${unlockedCategorySql()} ORDER BY c.title LIMIT ${candidateLimit} `)) as XtreamGlobalSearchCandidate[]; @@ -359,6 +364,7 @@ async function selectXtreamGlobalSearchCandidatesWithFts( : sql`` } ${excludeHidden ? sql`AND cat.hidden = 0` : sql``} + ${unlockedCategorySql()} ORDER BY rank, c.title LIMIT ${candidateLimit} `)) as XtreamGlobalSearchCandidate[]; @@ -382,6 +388,10 @@ async function selectXtreamGlobalSearchCandidatesWithContentScan( if (excludeHidden) { conditions.push(eq(schema.categories.hidden, false)); } + const unlockedCondition = unlockedCategoryCondition(); + if (unlockedCondition) { + conditions.push(unlockedCondition); + } return db .select({ @@ -694,7 +704,8 @@ export async function getContent( .where( and( eq(schema.categories.playlistId, playlistId), - eq(schema.content.type, type) + eq(schema.content.type, type), + unlockedCategoryCondition() ) ); @@ -775,6 +786,7 @@ function getGlobalRecentlyAddedByType( const whereConditions = [ eq(schema.content.type, type), eq(schema.categories.hidden, false), + unlockedCategoryCondition(), sql`${schema.content.added} <> ''`, sql`${schema.content.added} <= ${getXtreamRecentlyAddedMaxEpochSeconds()}`, ]; @@ -1074,6 +1086,10 @@ export async function searchContent( if (excludeHidden) { conditions.push(eq(schema.categories.hidden, false)); } + const unlockedCondition = unlockedCategoryCondition(); + if (unlockedCondition) { + conditions.push(unlockedCondition); + } const query = db .select(selectContentFields()) diff --git a/apps/electron-backend/src/app/database/operations/title-match.operations.ts b/apps/electron-backend/src/app/database/operations/title-match.operations.ts index f3f5e4c75..d4a6b9ee5 100644 --- a/apps/electron-backend/src/app/database/operations/title-match.operations.ts +++ b/apps/electron-backend/src/app/database/operations/title-match.operations.ts @@ -4,6 +4,7 @@ import { normalizeTitleKeys, } from '@iptvnator/shared/interfaces'; import type { AppDatabase } from '../database.types'; +import { unlockedCategorySql } from '../parental-lock-state'; /** * Batched cross-playlist title matching for the actor page's "All portals" @@ -75,6 +76,7 @@ export async function matchTitles( WHERE content_title_fts MATCH ${matchQuery} AND c.type IN ('movie', 'series') AND cat.hidden = 0 + ${unlockedCategorySql()} ORDER BY rank, c.title LIMIT ${PER_TITLE_CANDIDATE_LIMIT} `)) as TitleMatchRow[]; diff --git a/apps/electron-backend/src/app/database/operations/title-sources.operations.ts b/apps/electron-backend/src/app/database/operations/title-sources.operations.ts index 1fbc7ca55..16d198456 100644 --- a/apps/electron-backend/src/app/database/operations/title-sources.operations.ts +++ b/apps/electron-backend/src/app/database/operations/title-sources.operations.ts @@ -5,6 +5,7 @@ import { type VodSourceCandidateRow, } from '@iptvnator/shared/interfaces'; import type { AppDatabase } from '../database.types'; +import { unlockedCategorySql } from '../parental-lock-state'; import { caseInsensitiveGlobBody, caseInsensitiveGlobPattern, @@ -258,6 +259,7 @@ function scanCandidateQuery( WHERE c.type = 'movie' AND cat.hidden = 0 AND p.type = 'xtream' + ${unlockedCategorySql()} AND ${wordMatches} ${excludePlaylist} ORDER BY LENGTH(c.title), c.title @@ -292,6 +294,7 @@ function ftsCandidateQuery(matchQuery: string, excludePlaylist: SQL) { AND c.type = 'movie' AND cat.hidden = 0 AND p.type = 'xtream' + ${unlockedCategorySql()} ${excludePlaylist} GROUP BY cat.playlist_id, c.xtream_id ORDER BY rank, c.title diff --git a/apps/electron-backend/src/app/database/parental-lock-state.spec.ts b/apps/electron-backend/src/app/database/parental-lock-state.spec.ts new file mode 100644 index 000000000..5c68db44f --- /dev/null +++ b/apps/electron-backend/src/app/database/parental-lock-state.spec.ts @@ -0,0 +1,47 @@ +import { and, eq, type SQL } from 'drizzle-orm'; +import { SQLiteSyncDialect } from 'drizzle-orm/sqlite-core'; +import * as schema from '@iptvnator/shared/database/schema'; + +function renderSql(query: SQL): string { + return new SQLiteSyncDialect().sqlToQuery(query).sql; +} +import { + isParentalLockActive, + setParentalLockActive, + unlockedCategoryCondition, + unlockedCategorySql, +} from './parental-lock-state'; + +describe('parental-lock-state', () => { + afterEach(() => { + setParentalLockActive(false); + }); + + it('starts unlocked and contributes nothing to queries', () => { + expect(isParentalLockActive()).toBe(false); + expect(unlockedCategoryCondition()).toBeUndefined(); + expect(renderSql(unlockedCategorySql())).toBe(''); + // `and()` must tolerate the undefined condition so callers can add + // it unconditionally. + expect( + and(eq(schema.categories.type, 'live'), undefined) + ).toBeDefined(); + }); + + it('filters on categories.locked while active', () => { + setParentalLockActive(true); + + expect(isParentalLockActive()).toBe(true); + expect(unlockedCategoryCondition()).toEqual( + eq(schema.categories.locked, false) + ); + expect(renderSql(unlockedCategorySql())).toContain( + 'AND cat.locked = 0' + ); + }); + + it('coerces anything but true to inactive', () => { + setParentalLockActive('yes' as unknown as boolean); + expect(isParentalLockActive()).toBe(false); + }); +}); diff --git a/apps/electron-backend/src/app/database/parental-lock-state.ts b/apps/electron-backend/src/app/database/parental-lock-state.ts new file mode 100644 index 000000000..3648fcd8b --- /dev/null +++ b/apps/electron-backend/src/app/database/parental-lock-state.ts @@ -0,0 +1,39 @@ +import { eq, sql, type SQL } from 'drizzle-orm'; +import * as schema from '@iptvnator/shared/database/schema'; + +/** + * Process-wide parental lock enforcement flag for the SQLite worker. + * + * While active, every content-returning read appends "category is not + * locked", so a surface added later is withheld by default instead of + * leaking. The value arrives from the main process: seeded through + * `workerData` when the worker is (re)started and updated by the + * `DB_SET_PARENTAL_LOCK_STATE` request. It is deliberately not read from + * settings inside the worker, which has no access to the renderer's + * IndexedDB or to electron-conf. + */ +let parentalLockActive = false; + +export function setParentalLockActive(active: boolean): void { + parentalLockActive = active === true; +} + +export function isParentalLockActive(): boolean { + return parentalLockActive; +} + +/** + * Drizzle condition for query-builder reads joined on `categories`; + * `undefined` while unlocked so `and(...)` ignores it. + */ +export function unlockedCategoryCondition(): SQL | undefined { + return parentalLockActive ? eq(schema.categories.locked, false) : undefined; +} + +/** + * Raw fragment for `sql` template reads that alias the categories table as + * `cat`; empty while unlocked. + */ +export function unlockedCategorySql(): SQL { + return parentalLockActive ? sql`AND cat.locked = 0` : sql``; +} diff --git a/apps/electron-backend/src/app/events/database/category.events.ts b/apps/electron-backend/src/app/events/database/category.events.ts index e25d97bce..1e466db79 100644 --- a/apps/electron-backend/src/app/events/database/category.events.ts +++ b/apps/electron-backend/src/app/events/database/category.events.ts @@ -30,12 +30,27 @@ handleWorkerRequest( category_id: string | number; }>, type: 'live' | 'movies' | 'series', - hiddenCategoryXtreamIds?: number[] + hiddenCategoryXtreamIds?: number[], + lockedCategoryXtreamIds?: number[] ) => ({ playlistId, categories, type, hiddenCategoryXtreamIds, + lockedCategoryXtreamIds, + }) +); + +handleWorkerRequest( + 'DB_SET_CATEGORY_LOCKS', + ( + playlistId: string, + type: 'live' | 'movies' | 'series', + lockedXtreamIds: number[] + ) => ({ + playlistId, + type, + lockedXtreamIds: Array.isArray(lockedXtreamIds) ? lockedXtreamIds : [], }) ); diff --git a/apps/electron-backend/src/app/events/database/worker-ipc-contract.spec-data.ts b/apps/electron-backend/src/app/events/database/worker-ipc-contract.spec-data.ts index 5487705de..f50a2a7db 100644 --- a/apps/electron-backend/src/app/events/database/worker-ipc-contract.spec-data.ts +++ b/apps/electron-backend/src/app/events/database/worker-ipc-contract.spec-data.ts @@ -154,12 +154,13 @@ export const workerIpcContractCases: WorkerIpcContractCase[] = [ }, { operation: 'DB_SAVE_CATEGORIES', - args: [playlistId, categories, 'live', categoryIds], + args: [playlistId, categories, 'live', categoryIds, categoryIds], payload: { playlistId, categories, type: 'live', hiddenCategoryXtreamIds: categoryIds, + lockedCategoryXtreamIds: categoryIds, }, }, { @@ -172,6 +173,11 @@ export const workerIpcContractCases: WorkerIpcContractCase[] = [ args: [categoryIds, true], payload: { categoryIds, hidden: true }, }, + { + operation: 'DB_SET_CATEGORY_LOCKS', + args: [playlistId, 'live', categoryIds], + payload: { playlistId, type: 'live', lockedXtreamIds: categoryIds }, + }, { operation: 'DB_HAS_CONTENT', args: [playlistId, 'movie'], diff --git a/apps/electron-backend/src/app/events/parental-lock.events.spec.ts b/apps/electron-backend/src/app/events/parental-lock.events.spec.ts new file mode 100644 index 000000000..0bad7e44c --- /dev/null +++ b/apps/electron-backend/src/app/events/parental-lock.events.spec.ts @@ -0,0 +1,96 @@ +type IpcHandler = (event: unknown, ...args: unknown[]) => Promise; + +const mockRegisteredHandlers = new Map(); +const mockSetParentalLockState = jest.fn(); +const mockStoreGet = jest.fn(); + +jest.mock('electron', () => ({ + ipcMain: { + handle: jest.fn((channel: string, handler: IpcHandler) => { + mockRegisteredHandlers.set(channel, handler); + }), + }, +})); + +jest.mock('../services/database-worker-client', () => ({ + databaseWorkerClient: { + setParentalLockState: (...args: unknown[]) => + mockSetParentalLockState(...args), + }, +})); + +jest.mock('../services/store.service', () => ({ + PARENTAL_LOCK_ENABLED: 'PARENTAL_LOCK_ENABLED', + store: { get: (...args: unknown[]) => mockStoreGet(...args) }, +})); + +type Listener = (...args: unknown[]) => void; + +function createSender() { + const listeners = new Map(); + return { + id: 1, + on: jest.fn((event: string, listener: Listener) => { + listeners.set(event, listener); + }), + off: jest.fn(), + emit(event: string, ...args: unknown[]) { + listeners.get(event)?.(...args); + }, + }; +} + +describe('parental-lock.events', () => { + beforeEach(async () => { + jest.resetModules(); + mockRegisteredHandlers.clear(); + mockSetParentalLockState.mockReset().mockResolvedValue(undefined); + mockStoreGet.mockReset().mockReturnValue(true); + const module = await import('./parental-lock.events'); + module.default.bootstrapParentalLockEvents(); + }); + + it('forwards the renderer state to the worker and remembers it', async () => { + const { getParentalLockActive } = + await import('../services/parental-lock-state'); + const handler = mockRegisteredHandlers.get('PARENTAL_LOCK:SET_STATE'); + expect(handler).toBeDefined(); + + await handler?.({ sender: createSender() }, false); + expect(mockSetParentalLockState).toHaveBeenLastCalledWith(false); + expect(getParentalLockActive()).toBe(false); + + await handler?.({ sender: createSender() }, 'yes'); + expect(mockSetParentalLockState).toHaveBeenLastCalledWith(false); + }); + + it('locks again from the mirrored setting when the renderer reloads or dies', async () => { + const handler = mockRegisteredHandlers.get('PARENTAL_LOCK:SET_STATE'); + const sender = createSender(); + + await handler?.({ sender }, false); + mockSetParentalLockState.mockClear(); + + sender.emit('did-start-navigation', { + isMainFrame: true, + isSameDocument: true, + }); + expect(mockSetParentalLockState).not.toHaveBeenCalled(); + + sender.emit('did-start-navigation', { + isMainFrame: true, + isSameDocument: false, + }); + expect(mockSetParentalLockState).toHaveBeenLastCalledWith(true); + + mockStoreGet.mockReturnValue(false); + sender.emit('render-process-gone'); + expect(mockSetParentalLockState).toHaveBeenLastCalledWith(false); + }); + + it('seeds the main-process copy from the mirrored setting at bootstrap', async () => { + const { getParentalLockActive } = + await import('../services/parental-lock-state'); + expect(getParentalLockActive()).toBe(true); + }); +}); diff --git a/apps/electron-backend/src/app/events/parental-lock.events.ts b/apps/electron-backend/src/app/events/parental-lock.events.ts new file mode 100644 index 000000000..eed938196 --- /dev/null +++ b/apps/electron-backend/src/app/events/parental-lock.events.ts @@ -0,0 +1,85 @@ +import { ipcMain, WebContents } from 'electron'; +import { PARENTAL_LOCK_SET_STATE } from '@iptvnator/shared/interfaces'; +import { databaseWorkerClient } from '../services/database-worker-client'; +import { + getParentalLockActive, + setParentalLockActiveState, +} from '../services/parental-lock-state'; +import { PARENTAL_LOCK_ENABLED, store } from '../services/store.service'; + +/** + * Parental lock IPC. + * + * The renderer reports whether locked categories must be withheld (feature + * enabled and no PIN entered). The value is kept in the main process and + * pushed into the SQLite worker, which filters every content read on it. + * + * Like the playback keep-awake vote, the renderer's word must not outlive the + * page that gave it: on reload, navigation or a dead render process the flag + * falls back to the mirrored `parentalLockEnabled` setting, i.e. locked + * while the feature is on. A crashed page can therefore never leave the + * library unlocked. + */ + +const senderCleanups = new Map void>(); + +export async function applyParentalLockState(active: boolean): Promise { + setParentalLockActiveState(active); + try { + await databaseWorkerClient.setParentalLockState( + getParentalLockActive() + ); + } catch (error) { + console.error('Failed to update parental lock state:', error); + throw error; + } +} + +/** The state a renderer that has not announced itself yet must get. */ +export function defaultParentalLockState(): boolean { + return store.get(PARENTAL_LOCK_ENABLED, false) === true; +} + +function watchSenderLifetime(sender: WebContents): void { + const senderId = sender.id; + if (senderCleanups.has(senderId)) { + return; + } + const relock = () => { + void applyParentalLockState(defaultParentalLockState()).catch( + () => undefined + ); + }; + const onNavigation = ( + event: Electron.Event + ) => { + if (event.isMainFrame && !event.isSameDocument) { + relock(); + } + }; + const onDestroyed = () => { + relock(); + senderCleanups.get(senderId)?.(); + senderCleanups.delete(senderId); + }; + sender.on('destroyed', onDestroyed); + sender.on('render-process-gone', relock); + sender.on('did-start-navigation', onNavigation); + senderCleanups.set(senderId, () => { + sender.off('destroyed', onDestroyed); + sender.off('render-process-gone', relock); + sender.off('did-start-navigation', onNavigation); + }); +} + +export default class ParentalLockEvents { + static bootstrapParentalLockEvents(): Electron.IpcMain { + setParentalLockActiveState(defaultParentalLockState()); + return ipcMain; + } +} + +ipcMain.handle(PARENTAL_LOCK_SET_STATE, async (event, active: boolean) => { + watchSenderLifetime(event.sender); + await applyParentalLockState(active === true); +}); diff --git a/apps/electron-backend/src/app/events/settings.events.spec.ts b/apps/electron-backend/src/app/events/settings.events.spec.ts index a5cd5c03a..ebfbb2a74 100644 --- a/apps/electron-backend/src/app/events/settings.events.spec.ts +++ b/apps/electron-backend/src/app/events/settings.events.spec.ts @@ -38,6 +38,7 @@ const handlers = new Map(); const mockStoreGet = jest.fn(); const mockStoreSet = jest.fn(); const mockUpdateSettings = jest.fn(); +const mockApplyParentalLockState = jest.fn(); const mockIpcHandle = jest.fn( (channel: string, handler: SettingsUpdateHandler): void => { handlers.set(channel, handler); @@ -61,12 +62,18 @@ jest.mock('../services/store.service', () => ({ PORTAL_CONNECTIVITY_GUARD: STORE_KEYS.PORTAL_CONNECTIVITY_GUARD, VLC_PLAYER_ARGUMENTS: STORE_KEYS.VLC_PLAYER_ARGUMENTS, VLC_REUSE_INSTANCE: STORE_KEYS.VLC_REUSE_INSTANCE, + PARENTAL_LOCK_ENABLED: 'PARENTAL_LOCK_ENABLED', store: { get: mockStoreGet, set: mockStoreSet, }, })); +jest.mock('./parental-lock.events', () => ({ + applyParentalLockState: (...args: unknown[]) => + mockApplyParentalLockState(...args), +})); + jest.mock('../server/http-server', () => ({ httpServer: { updateSettings: mockUpdateSettings, @@ -84,6 +91,7 @@ describe('SETTINGS_UPDATE', () => { mockStoreGet.mockReset(); mockStoreSet.mockReset(); mockUpdateSettings.mockReset(); + mockApplyParentalLockState.mockReset().mockResolvedValue(undefined); mockStoreGet.mockImplementation( (_key: string, fallbackValue: unknown): unknown => fallbackValue ); @@ -136,6 +144,35 @@ describe('SETTINGS_UPDATE', () => { ); }); + it('mirrors the parental lock switch and releases the worker only on switch-off', () => { + // Off → on: persist; the renderer announces its own live state. + settingsUpdateHandler({}, { parentalLockEnabled: true }); + expect(mockStoreSet).toHaveBeenCalledWith( + 'PARENTAL_LOCK_ENABLED', + true + ); + expect(mockApplyParentalLockState).not.toHaveBeenCalled(); + + // An ordinary save carrying the unchanged flag must not re-lock a + // worker the renderer believes is unlocked. + mockStoreGet.mockImplementation((key: string, fallback: unknown) => + key === 'PARENTAL_LOCK_ENABLED' ? true : fallback + ); + settingsUpdateHandler( + {}, + { parentalLockEnabled: true, showCaptions: true } + ); + expect(mockApplyParentalLockState).not.toHaveBeenCalled(); + + // On → off: release at once. + settingsUpdateHandler({}, { parentalLockEnabled: false }); + expect(mockStoreSet).toHaveBeenCalledWith( + 'PARENTAL_LOCK_ENABLED', + false + ); + expect(mockApplyParentalLockState).toHaveBeenCalledWith(false); + }); + it('normalizes external-player arguments and preserves explicit false reuse settings', () => { settingsUpdateHandler( {}, diff --git a/apps/electron-backend/src/app/events/settings.events.ts b/apps/electron-backend/src/app/events/settings.events.ts index c289739c2..083cb4a93 100644 --- a/apps/electron-backend/src/app/events/settings.events.ts +++ b/apps/electron-backend/src/app/events/settings.events.ts @@ -11,6 +11,7 @@ import { EMBEDDED_MPV_FRAME_COPY, MPV_PLAYER_ARGUMENTS, MPV_REUSE_INSTANCE, + PARENTAL_LOCK_ENABLED, STARTUP_WINDOW_MODE, PORTAL_CONNECTIVITY_GUARD, store, @@ -19,6 +20,7 @@ import { } from '../services/store.service'; import { httpServer } from '../server/http-server'; import { setHostConnectivityGuardEnabled } from '../util/host-connectivity-guard'; +import { applyParentalLockState } from './parental-lock.events'; import { persistAppUpdateChannel } from '../services/app-update-channel'; export default class SettingsEvents { @@ -42,6 +44,22 @@ ipcMain.handle('SETTINGS_UPDATE', (_event, arg) => { setHostConnectivityGuardEnabled(enabled); } + // Mirrored so the database worker and a reloaded renderer start locked + // whenever the feature is on. The LIVE enforcement state is the + // renderer's to announce through PARENTAL_LOCK_SET_STATE: every full + // settings save carries this flag unchanged, so applying it here would + // silently re-lock the worker under a renderer that still shows + // "unlocked". Only a switch-off releases the worker at once — nothing + // may stay withheld once the feature is gone. + if (arg.parentalLockEnabled !== undefined) { + const enabled = arg.parentalLockEnabled === true; + const wasEnabled = store.get(PARENTAL_LOCK_ENABLED, false) === true; + store.set(PARENTAL_LOCK_ENABLED, enabled); + if (wasEnabled && !enabled) { + void applyParentalLockState(false).catch(() => undefined); + } + } + if (arg.mpvPlayerArguments !== undefined) { store.set( MPV_PLAYER_ARGUMENTS, diff --git a/apps/electron-backend/src/app/services/database-worker-client.spec.ts b/apps/electron-backend/src/app/services/database-worker-client.spec.ts index cfffa699a..5b711f2ba 100644 --- a/apps/electron-backend/src/app/services/database-worker-client.spec.ts +++ b/apps/electron-backend/src/app/services/database-worker-client.spec.ts @@ -86,6 +86,7 @@ describe('DatabaseWorkerClient', () => { nativeModuleSearchPaths: [ '/mock/resources/app.asar.unpacked/node_modules', ], + parentalLockActive: false, }, }); diff --git a/apps/electron-backend/src/app/services/database-worker-client.ts b/apps/electron-backend/src/app/services/database-worker-client.ts index 033aa99b0..898f09368 100644 --- a/apps/electron-backend/src/app/services/database-worker-client.ts +++ b/apps/electron-backend/src/app/services/database-worker-client.ts @@ -2,6 +2,7 @@ import { app } from 'electron'; import { randomUUID } from 'crypto'; import * as path from 'path'; import { pathToFileURL } from 'url'; +import { getParentalLockActive } from './parental-lock-state'; import { Worker } from 'worker_threads'; import type { DbOperationEvent, @@ -83,6 +84,19 @@ export class DatabaseWorkerClient { }); } + /** + * Flips the worker's parental lock filter. Awaits readiness so the + * message is never lost to a worker that has not started, and is posted + * on the request port so later reads observe it in order. + */ + async setParentalLockState(active: boolean): Promise { + await this.ensureWorker(); + this.worker?.postMessage({ + type: 'parental-lock', + active: active === true, + }); + } + async cancel(operationId: string): Promise<{ success: boolean }> { if (!operationId) { return { success: false }; @@ -157,6 +171,10 @@ export class DatabaseWorkerClient { this.worker = new Worker(workerURL, { workerData: { nativeModuleSearchPaths: bootstrap.nativeModuleSearchPaths, + // Seeded here rather than requested afterwards, so a + // restarted worker never answers a read unfiltered while + // the lock state is still in flight. + parentalLockActive: getParentalLockActive(), }, }); } catch (error) { diff --git a/apps/electron-backend/src/app/services/parental-lock-state.ts b/apps/electron-backend/src/app/services/parental-lock-state.ts new file mode 100644 index 000000000..a9bbb8617 --- /dev/null +++ b/apps/electron-backend/src/app/services/parental-lock-state.ts @@ -0,0 +1,18 @@ +/** + * Main-process copy of the parental lock enforcement flag. + * + * The renderer owns the unlock decision and reports it over + * `PARENTAL_LOCK_SET_STATE`; this module remembers the latest value so the + * database worker can be seeded with it whenever it is (re)started, and so a + * renderer reload or crash can fall back to "locked while the feature is on" + * without waiting for a page that may never come back. + */ +let parentalLockActive = false; + +export function getParentalLockActive(): boolean { + return parentalLockActive; +} + +export function setParentalLockActiveState(active: boolean): void { + parentalLockActive = active === true; +} diff --git a/apps/electron-backend/src/app/services/store.service.ts b/apps/electron-backend/src/app/services/store.service.ts index 9b1203ed3..c8b6ffbee 100644 --- a/apps/electron-backend/src/app/services/store.service.ts +++ b/apps/electron-backend/src/app/services/store.service.ts @@ -33,6 +33,14 @@ export const STARTUP_WINDOW_MODE = 'STARTUP_WINDOW_MODE'; /** Desktop portal request cooldown; absent means enabled. */ export const PORTAL_CONNECTIVITY_GUARD = 'PORTAL_CONNECTIVITY_GUARD'; +/** + * Parental lock feature switch, mirrored from the renderer's settings by the + * SETTINGS_UPDATE handler. Read when the database worker starts and when a + * renderer reloads or dies, so the SQLite reads are locked before any page + * has announced its lock state. Absent means off. + */ +export const PARENTAL_LOCK_ENABLED = 'PARENTAL_LOCK_ENABLED'; + /** * Update channel (`stable` / `nightly`). Mirrored here from the renderer's * settings by the SETTINGS_UPDATE handler because the startup update check @@ -78,6 +86,7 @@ export type StoreType = { [EMBEDDED_MPV_AUTO_RECONNECT]: boolean; [STARTUP_WINDOW_MODE]: StartupWindowMode; [PORTAL_CONNECTIVITY_GUARD]: boolean; + [PARENTAL_LOCK_ENABLED]: boolean; [APP_UPDATE_CHANNEL]: AppUpdateChannel; [TRUSTED_LOCAL_EPG_SOURCES]: string[]; }; diff --git a/apps/electron-backend/src/app/startup/deferred-events.ts b/apps/electron-backend/src/app/startup/deferred-events.ts index 38a708900..03e41d8f9 100644 --- a/apps/electron-backend/src/app/startup/deferred-events.ts +++ b/apps/electron-backend/src/app/startup/deferred-events.ts @@ -35,6 +35,7 @@ import { shutdownMpvSession } from '../events/mpv-session.service'; import PlayerEvents from '../events/player.events'; import { shutdownVlcSession } from '../events/vlc-session.service'; import PlaylistEvents from '../events/playlist.events'; +import ParentalLockEvents from '../events/parental-lock.events'; import RemoteControlEvents from '../events/remote-control.events'; import SettingsEvents from '../events/settings.events'; import SharedEvents from '../events/shared.events'; @@ -85,6 +86,7 @@ export function bootstrapDeferredEvents( SharedEvents.bootstrapSharedEvents(); PlayerEvents.bootstrapPlayerEvents(); SettingsEvents.bootstrapSettingsEvents(); + ParentalLockEvents.bootstrapParentalLockEvents(); StalkerEvents.bootstrapStalkerEvents(); XtreamEvents.bootstrapXtreamEvents(); registerStreamProbeHandlers(); diff --git a/apps/electron-backend/src/app/workers/database-worker.types.ts b/apps/electron-backend/src/app/workers/database-worker.types.ts index 36db8a5d4..2aa403a03 100644 --- a/apps/electron-backend/src/app/workers/database-worker.types.ts +++ b/apps/electron-backend/src/app/workers/database-worker.types.ts @@ -6,6 +6,7 @@ export const DB_WORKER_OPERATIONS = [ 'DB_SAVE_CATEGORIES', 'DB_GET_ALL_CATEGORIES', 'DB_UPDATE_CATEGORY_VISIBILITY', + 'DB_SET_CATEGORY_LOCKS', 'DB_HAS_CONTENT', 'DB_GET_CONTENT', 'DB_GET_GLOBAL_RECENTLY_ADDED', @@ -131,6 +132,16 @@ export interface DbWorkerCancelMessage { operationId: string; } +/** + * Main-process control message, not a renderer request: flips the worker's + * parental lock filter. Ordered with the requests on the same port, so a + * read posted after it observes the new state. + */ +export interface DbWorkerParentalLockMessage { + type: 'parental-lock'; + active: boolean; +} + export interface DbWorkerReadyMessage { type: 'ready'; } @@ -158,7 +169,9 @@ export interface DbWorkerResponseMessage { } export type DbWorkerIncomingMessage = - DbWorkerRequestMessage | DbWorkerCancelMessage; + | DbWorkerRequestMessage + | DbWorkerCancelMessage + | DbWorkerParentalLockMessage; export type DbWorkerMessage = | DbWorkerReadyMessage diff --git a/apps/electron-backend/src/app/workers/database.worker.ts b/apps/electron-backend/src/app/workers/database.worker.ts index 042c11744..73b780635 100644 --- a/apps/electron-backend/src/app/workers/database.worker.ts +++ b/apps/electron-backend/src/app/workers/database.worker.ts @@ -3,7 +3,7 @@ import { closeWorkerDatabase, getWorkerDatabase, } from './database.worker-connection'; -import { parentPort } from 'worker_threads'; +import { parentPort, workerData } from 'worker_threads'; import type { ContentMetadataPatch, VodSourcePin, @@ -25,8 +25,10 @@ import { getCategories, hasCategories, saveCategories, + setCategoryLocks, updateCategoryVisibility, } from '../database/operations/category.operations'; +import { setParentalLockActive } from '../database/parental-lock-state'; import { addFavorite, getAllGlobalFavorites, @@ -133,6 +135,12 @@ import { } from './operation-progress-throttle'; const loggerLabel = '[DB Worker]'; +// Seeded by the main process so a (re)started worker is locked before its +// first read; DB_SET_PARENTAL_LOCK_STATE updates it afterwards. +setParentalLockActive( + (workerData as { parentalLockActive?: unknown } | undefined) + ?.parentalLockActive === true +); const batchDelayMs = Number.parseInt( process.env['IPTVNATOR_DB_WORKER_BATCH_DELAY_MS'] ?? '0', 10 @@ -440,6 +448,7 @@ async function executeRequest( }>; type: 'live' | 'movies' | 'series'; hiddenCategoryXtreamIds?: number[]; + lockedCategoryXtreamIds?: number[]; }; const capturePhase = createWorkerPerformancePhaseAdapter(performanceCapture); @@ -449,6 +458,7 @@ async function executeRequest( payload.categories, payload.type, payload.hiddenCategoryXtreamIds, + payload.lockedCategoryXtreamIds, capturePhase ); } @@ -473,6 +483,20 @@ async function executeRequest( ); } + case 'DB_SET_CATEGORY_LOCKS': { + const payload = message.payload as { + playlistId: string; + type: 'live' | 'movies' | 'series'; + lockedXtreamIds: number[]; + }; + return setCategoryLocks( + db, + payload.playlistId, + payload.type, + payload.lockedXtreamIds + ); + } + case 'DB_HAS_CONTENT': { const payload = message.payload as { playlistId: string; @@ -1246,6 +1270,11 @@ parentPort.on('message', async (message: DbWorkerIncomingMessage) => { return; } + if (message.type === 'parental-lock') { + setParentalLockActive(message.active === true); + return; + } + if (message.type === 'cancel') { const activeOperation = activeOperations.get(message.operationId); if (activeOperation) { diff --git a/apps/web-e2e/dist/.playwright/apps/web-e2e/screenshots/settings/@settings @web Opt out of shared web player controls.png b/apps/web-e2e/dist/.playwright/apps/web-e2e/screenshots/settings/@settings @web Opt out of shared web player controls.png new file mode 100644 index 000000000..ef815e67e Binary files /dev/null and b/apps/web-e2e/dist/.playwright/apps/web-e2e/screenshots/settings/@settings @web Opt out of shared web player controls.png differ diff --git a/apps/web-e2e/dist/.playwright/apps/web-e2e/screenshots/settings/@settings @web Parental lock — set a PIN, lock, survive a reload, unlock.png b/apps/web-e2e/dist/.playwright/apps/web-e2e/screenshots/settings/@settings @web Parental lock — set a PIN, lock, survive a reload, unlock.png new file mode 100644 index 000000000..19cf5b8a6 Binary files /dev/null and b/apps/web-e2e/dist/.playwright/apps/web-e2e/screenshots/settings/@settings @web Parental lock — set a PIN, lock, survive a reload, unlock.png differ diff --git a/apps/web-e2e/src/settings.e2e.ts b/apps/web-e2e/src/settings.e2e.ts index c4b131b6b..e12bb9d7b 100644 --- a/apps/web-e2e/src/settings.e2e.ts +++ b/apps/web-e2e/src/settings.e2e.ts @@ -12,7 +12,9 @@ async function openSettings(page: Page) { /** Settings render one section page at a time — open it via the rail. */ async function openSettingsSection(page: Page, sectionId: string) { - await page.locator(`[data-test-id="settings-section-${sectionId}"]`).click(); + await page + .locator(`[data-test-id="settings-section-${sectionId}"]`) + .click(); await page.waitForURL(new RegExp(`/workspace/settings/${sectionId}$`)); } @@ -41,11 +43,11 @@ test.describe('Settings', () => { await openSettings(page); await openSettingsSection(page, 'playback'); - const playerSelect = page.locator('[data-test-id="select-video-player"]'); - - await expect(playerSelect).toContainText( - /Video\.js/i + const playerSelect = page.locator( + '[data-test-id="select-video-player"]' ); + + await expect(playerSelect).toContainText(/Video\.js/i); await playerSelect.click(); await page.locator('mat-option[data-test-id="html5"]').click(); @@ -54,9 +56,7 @@ test.describe('Settings', () => { await openSettings(page); await openSettingsSection(page, 'playback'); - await expect(playerSelect).toContainText( - /HTML5/i - ); + await expect(playerSelect).toContainText(/HTML5/i); }); test('@settings @web Opt out of shared web player controls', async ({ @@ -235,13 +235,71 @@ test.describe('Settings', () => { ).toHaveAttribute('aria-checked', 'true'); }); + test('@settings @web Parental lock — set a PIN, lock, survive a reload, unlock', async ({ + page, + }) => { + await openSettings(page); + await openSettingsSection(page, 'parental'); + + const enableToggle = page.locator( + '[data-test-id="parental-lock-enabled"] button[role="switch"]' + ); + const pinInput = page.locator('[data-test-id="parental-lock-pin"]'); + const pinConfirm = page.locator( + '[data-test-id="parental-lock-pin-confirm"]' + ); + const pinSubmit = page.locator( + '[data-test-id="parental-lock-pin-submit"]' + ); + const lockNow = page.locator('[data-test-id="parental-lock-lock-now"]'); + const unlock = page.locator('[data-test-id="parental-lock-unlock"]'); + const headerLock = page.locator( + '[data-test-id="header-parental-lock"]' + ); + + // Enabling asks for a new PIN twice; the parent stays unlocked. + await expect(enableToggle).toHaveAttribute('aria-checked', 'false'); + await enableToggle.click(); + await expect(pinInput).toBeVisible(); + await pinInput.fill('2468'); + await pinConfirm.fill('2468'); + await pinSubmit.click(); + await expect(enableToggle).toHaveAttribute('aria-checked', 'true'); + await expect(lockNow).toBeVisible(); + await expect(headerLock).toBeVisible(); + + // Lock now flips the state; a reload keeps the lock (never persisted + // as unlocked). + await lockNow.click(); + await expect(unlock).toBeVisible(); + await page.reload(); + await openSettings(page); + await openSettingsSection(page, 'parental'); + await expect(enableToggle).toHaveAttribute('aria-checked', 'true'); + await expect(unlock).toBeVisible(); + + // A wrong PIN is refused, the right one unlocks. + await unlock.click(); + await expect(pinInput).toBeVisible(); + await pinInput.fill('0000'); + await pinSubmit.click(); + await expect( + page.locator('[data-test-id="parental-lock-pin-error"]') + ).toBeVisible(); + await pinInput.fill('2468'); + await pinSubmit.click(); + await expect(lockNow).toBeVisible(); + + // The header button locks from anywhere. + await headerLock.click(); + await expect(unlock).toBeVisible(); + }); + test('@settings @web Change app language', async ({ page }) => { await openSettings(page); const languageSelect = page.locator('[data-test-id="select-language"]'); - await expect(languageSelect).toContainText( - 'English' - ); + await expect(languageSelect).toContainText('English'); await languageSelect.click(); await page.locator('mat-option[data-test-id="de"]').click(); @@ -249,9 +307,7 @@ test.describe('Settings', () => { await page.reload(); await openSettings(page); - await expect(languageSelect).toContainText( - 'Deutsch' - ); + await expect(languageSelect).toContainText('Deutsch'); }); test('@settings @search @web Search settings from the header and open a result', async ({ diff --git a/apps/web/src/app/app-date-locales.ts b/apps/web/src/app/app-date-locales.ts index df69e39cb..54c58d7df 100644 --- a/apps/web/src/app/app-date-locales.ts +++ b/apps/web/src/app/app-date-locales.ts @@ -2,7 +2,7 @@ import { registerLocaleData } from '@angular/common'; import localeEn from '@angular/common/locales/en'; import { inject, Injectable } from '@angular/core'; import { TranslateService } from '@ngx-translate/core'; -import { normalizeDateLocale } from '@iptvnator/pipes'; +import { normalizeDateLocale } from '@iptvnator/pipes/date-format'; import { createDevLogger } from '@iptvnator/shared/interfaces'; type LocaleDataModule = { default: unknown }; diff --git a/apps/web/src/app/app.component.spec.ts b/apps/web/src/app/app.component.spec.ts index 99557c15b..e11422009 100644 --- a/apps/web/src/app/app.component.spec.ts +++ b/apps/web/src/app/app.component.spec.ts @@ -17,9 +17,11 @@ import { EMPTY, of } from 'rxjs'; import { DataService, EpgSourceSettingsService, + ParentalLockService, SettingsStore, RuntimeCapabilitiesService, } from '@iptvnator/services'; +import { ParentalLockEnforcementService } from './services/parental-lock-enforcement.service'; import { Language, Settings, @@ -97,6 +99,14 @@ describe('AppComponent', () => { imports: [AppComponent], providers: [ provideMockStore(), + // The parental lock boots from AppComponent; its collaborators + // (Xtream/Stalker stores, SQLite bridge) are out of scope here. + MockProvider(ParentalLockService, { + initialize: jest.fn().mockResolvedValue(undefined), + }), + MockProvider(ParentalLockEnforcementService, { + start: jest.fn(), + }), { provide: Actions, useValue: new Actions(EMPTY), diff --git a/apps/web/src/app/app.component.ts b/apps/web/src/app/app.component.ts index 079fc8e9f..8c61fb626 100644 --- a/apps/web/src/app/app.component.ts +++ b/apps/web/src/app/app.component.ts @@ -28,6 +28,7 @@ import { PlaylistActions, selectAllPlaylistsMeta } from '@iptvnator/m3u-state'; import { filter, take } from 'rxjs'; import { DataService, + ParentalLockService, RuntimeCapabilitiesService, SettingsStore, EpgSourceSettingsService, @@ -42,6 +43,7 @@ import { } from '@iptvnator/shared/interfaces'; import { AppDateLocaleService } from './app-date-locales'; import { SettingsService } from './services/settings.service'; +import { ParentalLockEnforcementService } from './services/parental-lock-enforcement.service'; import { PlaybackKeepAwakeService } from './services/playback-keep-awake.service'; import { PlaylistOpenRequestService } from './services/playlist-open-request.service'; import { AppUpdateNotificationPanelComponent } from './app-update-notification-panel.component'; @@ -83,6 +85,10 @@ export class AppComponent implements OnInit { private settingsStore = inject(SettingsStore); private readonly epgSources = inject(EpgSourceSettingsService); private playbackKeepAwake = inject(PlaybackKeepAwakeService); + private readonly parentalLock = inject(ParentalLockService); + private readonly parentalLockEnforcement = inject( + ParentalLockEnforcementService + ); private playlistOpenRequests = inject(PlaylistOpenRequestService); private runtime = inject(RuntimeCapabilitiesService); private readonly workspaceShellActions = inject(WORKSPACE_SHELL_ACTIONS); @@ -108,6 +114,11 @@ export class AppComponent implements OnInit { // (Electron powerSaveBlocker / PWA Screen Wake Lock, issue #1095). this.playbackKeepAwake.start(); + // Parental lock: load the PIN hash and lock store, then keep the + // in-memory catalogs in step with lock/unlock (issue #285). + void this.parentalLock.initialize(); + this.parentalLockEnforcement.start(); + effect(() => { const size = this.settingsStore.coverSize?.() ?? 'medium'; document.documentElement.dataset.coverSize = size; diff --git a/apps/web/src/app/app.config.ts b/apps/web/src/app/app.config.ts index 1a4a956fe..1fc0d8950 100644 --- a/apps/web/src/app/app.config.ts +++ b/apps/web/src/app/app.config.ts @@ -30,16 +30,20 @@ import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader'; import { PORTAL_EXTERNAL_PLAYBACK, PORTAL_PLAYER, -} from '@iptvnator/portal/shared/util'; +} from '@iptvnator/portal/shared/util/tokens'; import { STALKER_PLAYLIST_CONNECTION_EDITOR } from '@iptvnator/playlist/shared/ui/stalker-connection-editor'; import { provideXtreamDataSource } from '@iptvnator/portal/xtream/data-access'; -import { DataService } from '@iptvnator/services'; +import { + provideParentalLockPlaylistCleanup, + DataService, +} from '@iptvnator/services'; import { dbConfig } from '@iptvnator/shared/interfaces'; import { AppConfig } from '../environments/environment'; import { routes } from './app.routes'; import { ElectronService } from './services/electron.service'; import { ExternalPlaybackService } from './services/external-playback.service'; import { PlayerService } from './services/player.service'; +import { provideParentalLockPrompt } from './services/parental-lock-prompt.service'; import { providePortalPlaybackPositions } from './services/portal-playback-positions.service'; import { PwaService } from './services/pwa.service'; import { shouldEnableServiceWorker } from './services/runtime-config'; @@ -160,6 +164,8 @@ export const appConfig: ApplicationConfig = { useExisting: LazyStalkerPlaylistConnectionEditor, }, ...provideWorkspaceShellActions(), + ...provideParentalLockPrompt(), + provideParentalLockPlaylistCleanup(), ...provideXtreamDataSource(), { provide: MAT_FORM_FIELD_DEFAULT_OPTIONS, diff --git a/apps/web/src/app/app.routes.spec.ts b/apps/web/src/app/app.routes.spec.ts index 691f79699..00969c2fb 100644 --- a/apps/web/src/app/app.routes.spec.ts +++ b/apps/web/src/app/app.routes.spec.ts @@ -1,5 +1,5 @@ import { TestBed } from '@angular/core/testing'; -import { SettingsStore } from '@iptvnator/services'; +import { ParentalLockService, SettingsStore } from '@iptvnator/services'; describe('app routes', () => { let workspaceRoute: import('@angular/router').Route | undefined; @@ -67,6 +67,52 @@ describe('app routes', () => { TestBed.resetTestingModule(); }); + it('waits for the parental lock state before activating workspace children', async () => { + let releaseLock!: () => void; + const lockPending = new Promise((resolve) => { + releaseLock = resolve; + }); + const initialize = jest.fn(() => lockPending); + TestBed.configureTestingModule({ + providers: [ + { + provide: SettingsStore, + useValue: { + loadSettings: jest.fn().mockResolvedValue(undefined), + }, + }, + { provide: ParentalLockService, useValue: { initialize } }, + ], + }); + const settingsReadyResolver = + workspaceRoute?.resolve?.['settingsReady']; + if (typeof settingsReadyResolver !== 'function') { + throw new Error('resolver missing'); + } + let resolved = false; + const resolution = TestBed.runInInjectionContext(() => + Promise.resolve( + ( + settingsReadyResolver as import('@angular/router').ResolveFn + )( + {} as import('@angular/router').ActivatedRouteSnapshot, + {} as import('@angular/router').RouterStateSnapshot + ) + ) + ).then(() => { + resolved = true; + }); + await Promise.resolve(); + await Promise.resolve(); + + expect(initialize).toHaveBeenCalled(); + expect(resolved).toBe(false); + + releaseLock(); + await resolution; + expect(resolved).toBe(true); + }); + it('waits for settings before activating workspace children', async () => { let releaseSettings!: () => void; const settingsPending = new Promise((resolve) => { @@ -79,6 +125,12 @@ describe('app routes', () => { provide: SettingsStore, useValue: { loadSettings }, }, + { + provide: ParentalLockService, + useValue: { + initialize: jest.fn().mockResolvedValue(undefined), + }, + }, ], }); const settingsReadyResolver = @@ -120,8 +172,7 @@ describe('app routes', () => { (route) => route.path === 'playlists/:id' ); const loadChildren = playlistRoute?.loadChildren as - | (() => Promise) - | undefined; + (() => Promise) | undefined; const m3uRoutes = (await loadChildren?.()) ?? []; const defaultRoute = m3uRoutes.find((route) => route.path === ''); const favoritesRoute = m3uRoutes.find( diff --git a/apps/web/src/app/app.routes.ts b/apps/web/src/app/app.routes.ts index c02ee01da..123f51372 100644 --- a/apps/web/src/app/app.routes.ts +++ b/apps/web/src/app/app.routes.ts @@ -1,10 +1,24 @@ import { inject } from '@angular/core'; import { Router, Routes } from '@angular/router'; -import { RuntimeCapabilitiesService, SettingsStore } from '@iptvnator/services'; +import { + ParentalLockService, + RuntimeCapabilitiesService, + SettingsStore, +} from '@iptvnator/services'; import { WorkspaceStartupPreferencesService } from '@iptvnator/workspace/shell/util'; import { settingsUnsavedChangesGuard } from './settings/settings-unsaved-changes.guard'; -const settingsReadyResolver = () => inject(SettingsStore).loadSettings(); +// The workspace activates only once settings AND the parental lock state +// (PIN, lock store) are known: before that the lock reads as off and a +// slower IndexedDB read would let the catalogs admit protected rows. +const settingsReadyResolver = async () => { + const settingsStore = inject(SettingsStore); + const parentalLock = inject(ParentalLockService); + await Promise.all([ + settingsStore.loadSettings(), + parentalLock.initialize(), + ]); +}; const workspaceEntryRedirect = async () => inject(WorkspaceStartupPreferencesService).resolveInitialWorkspacePath(); diff --git a/apps/web/src/app/services/electron.service.ts b/apps/web/src/app/services/electron.service.ts index dd5be4f0b..ec2c7e85f 100644 --- a/apps/web/src/app/services/electron.service.ts +++ b/apps/web/src/app/services/electron.service.ts @@ -36,7 +36,7 @@ import { createLogger, createPortalDebugRequestContext, logPortalDebugEvent, -} from '@iptvnator/portal/shared/util'; +} from '@iptvnator/portal/shared/util/logger'; interface PlayerLaunchPayload { readonly headers?: Record; 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 new file mode 100644 index 000000000..db13c5d85 --- /dev/null +++ b/apps/web/src/app/services/parental-lock-enforcement.service.spec.ts @@ -0,0 +1,583 @@ +import { signal } from '@angular/core'; +import { TestBed } from '@angular/core/testing'; +import { Router } from '@angular/router'; +import { Store } from '@ngrx/store'; +import { StalkerStore } from '@iptvnator/portal/stalker/data-access'; +import { + XTREAM_DATA_SOURCE, + XtreamStore, +} from '@iptvnator/portal/xtream/data-access'; +import { ParentalLockService } from '@iptvnator/services'; +import { ParentalLockEnforcementService } from './parental-lock-enforcement.service'; +import { PlaybackKeepAwakeService } from './playback-keep-awake.service'; + +interface Applier { + apply(): Promise; + failClosedNow(): void; + applyXtream(version: number): Promise; + applyStalker(): Promise; +} + +describe('ParentalLockEnforcementService', () => { + const router = { url: '/', navigate: jest.fn() }; + const activeChannel = signal<{ group?: { title: string } } | null>(null); + const dispatch = jest.fn(); + const lockedStalkerIds = new Set(); + const parentalLock = { + version: signal(0), + active: signal(false), + registerBusyProbe: jest.fn(), + isXtreamCategoryLocked: jest.fn(() => false), + isStalkerCategoryLocked: jest.fn( + (_playlistId: string, _type: string, id: unknown) => + id !== null && + id !== undefined && + lockedStalkerIds.has(String(id)) + ), + isM3uGroupLocked: jest.fn(() => false), + }; + const stalkerStore = { + currentPlaylist: signal<{ _id: string } | null>({ _id: 'stalker-1' }), + selectedContentType: signal('vod'), + selectedCategoryId: signal('*'), + selectedItem: signal<{ + category_id?: string; + tv_genre_id?: string; + } | null>(null), + clearSelectedItem: jest.fn(), + setSelectedCategory: jest.fn(), + }; + const xtreamStore = { + playlistId: signal('xtream-1'), + selectedCategoryId: signal(null), + selectedItem: signal<{ category_id?: number } | null>(null), + reloadCategories: jest.fn(async () => undefined), + reloadCachedContent: jest.fn(async () => undefined), + refreshSearchResults: jest.fn(async () => undefined), + withholdCatalog: jest.fn(), + clearSearchResults: jest.fn(), + getCategoriesBySelectedType: jest.fn(() => [ + { id: 7, xtream_id: 70 }, + { id: 8, xtream_id: 80 }, + ]), + setSelectedItem: jest.fn(), + setSelectedCategory: jest.fn(), + }; + const xtreamDataSource = { + getAllCategories: jest.fn(async () => [ + { id: 7, xtream_id: 70 }, + { id: 8, xtream_id: 80 }, + { id: 55, xtream_id: 550 }, + { id: 99, xtream_id: 990 }, + ]), + }; + let service: Applier; + + beforeEach(() => { + jest.clearAllMocks(); + lockedStalkerIds.clear(); + router.url = '/'; + parentalLock.active.set(false); + parentalLock.isXtreamCategoryLocked.mockReturnValue(false); + stalkerStore.selectedCategoryId.set('*'); + stalkerStore.selectedItem.set(null); + xtreamStore.selectedCategoryId.set(null); + xtreamStore.selectedItem.set(null); + TestBed.configureTestingModule({ + providers: [ + { provide: ParentalLockService, useValue: parentalLock }, + { provide: XtreamStore, useValue: xtreamStore }, + { provide: XTREAM_DATA_SOURCE, useValue: xtreamDataSource }, + { provide: StalkerStore, useValue: stalkerStore }, + { provide: Router, useValue: router }, + { + provide: Store, + useValue: { selectSignal: () => activeChannel, dispatch }, + }, + { + provide: PlaybackKeepAwakeService, + useValue: { hasPlayingVideo: () => false }, + }, + ], + }); + activeChannel.set(null); + service = TestBed.inject( + ParentalLockEnforcementService + ) as unknown as Applier; + }); + + describe('M3U', () => { + it('resets a locked playing channel before awaiting the portal reloads', async () => { + router.url = '/workspace/playlists/m3u-1'; + activeChannel.set({ group: { title: 'Adult' } }); + parentalLock.isM3uGroupLocked.mockReturnValue(true); + let releaseReload: () => void = () => undefined; + xtreamStore.reloadCategories.mockImplementationOnce( + () => new Promise((resolve) => (releaseReload = resolve)) + ); + + const applying = service.apply(); + await Promise.resolve(); + + expect(parentalLock.isM3uGroupLocked).toHaveBeenCalledWith( + 'm3u-1', + 'Adult' + ); + expect(dispatch).toHaveBeenCalledTimes(1); + expect(xtreamStore.reloadCategories).toHaveBeenCalledTimes(1); + + releaseReload(); + await applying; + parentalLock.isM3uGroupLocked.mockReturnValue(false); + }); + }); + + it('resets a channel of a locked group as soon as it becomes active while locked', () => { + // Numeric zapping, next/previous or a remote command can select a + // channel without any lock-version change following it. + router.url = '/workspace/playlists/m3u-1'; + parentalLock.active.set(true); + parentalLock.isM3uGroupLocked.mockImplementation( + (_playlistId: string, group: string) => group === 'Adult' + ); + TestBed.runInInjectionContext(() => + (service as unknown as { start(): void }).start() + ); + TestBed.flushEffects(); + dispatch.mockClear(); + + activeChannel.set({ group: { title: 'News' } }); + TestBed.flushEffects(); + expect(dispatch).not.toHaveBeenCalled(); + + activeChannel.set({ group: { title: 'Adult' } }); + TestBed.flushEffects(); + expect(dispatch).toHaveBeenCalledTimes(1); + parentalLock.isM3uGroupLocked.mockReset(); + parentalLock.isM3uGroupLocked.mockReturnValue(false); + }); + + describe('Stalker', () => { + it('leaves the Stalker route when the Stalker step cannot load', async () => { + router.url = '/workspace/stalker/stalker-1/itv'; + jest.spyOn(console, 'error').mockImplementation(() => undefined); + ( + service as unknown as { + loadStalkerEnforcement: () => Promise; + } + ).loadStalkerEnforcement = () => + Promise.reject(new Error('ChunkLoadError')); + + await expect(service.applyStalker()).resolves.toBeUndefined(); + + expect(router.navigate).toHaveBeenCalledWith([ + '/workspace', + 'sources', + ]); + }); + + it('leaves the Stalker route synchronously on relock while the step is not loaded', () => { + router.url = '/workspace/stalker/stalker-1/itv'; + ( + service as unknown as { + loadStalkerEnforcement: () => Promise; + } + ).loadStalkerEnforcement = () => new Promise(() => undefined); + + service.failClosedNow(); + + expect(router.navigate).toHaveBeenCalledWith([ + '/workspace', + 'sources', + ]); + }); + + it('runs a preloaded Stalker step synchronously on relock', async () => { + router.url = '/workspace/stalker/stalker-1/vod/42'; + await service.applyStalker(); // loads the step + lockedStalkerIds.add('9'); + stalkerStore.selectedItem.set({ category_id: '9' }); + stalkerStore.clearSelectedItem.mockClear(); + + service.failClosedNow(); + + expect(stalkerStore.clearSelectedItem).toHaveBeenCalled(); + }); + + it('does not load the Stalker step outside a Stalker route', async () => { + router.url = '/workspace/xtreams/xtream-1/live'; + const load = jest.spyOn( + service as unknown as { loadStalkerEnforcement: () => unknown }, + 'loadStalkerEnforcement' + ); + + await service.applyStalker(); + + expect(load).not.toHaveBeenCalled(); + expect(stalkerStore.clearSelectedItem).not.toHaveBeenCalled(); + }); + + it('clears a detail opened from All whose own genre is withheld', async () => { + router.url = '/workspace/stalker/stalker-1/vod/42'; + lockedStalkerIds.add('9'); + stalkerStore.selectedItem.set({ category_id: '9' }); + + await service.applyStalker(); + + expect(stalkerStore.clearSelectedItem).toHaveBeenCalled(); + // "All" itself is not locked, so the category stays selected. + expect(stalkerStore.setSelectedCategory).not.toHaveBeenCalled(); + expect(router.navigate).toHaveBeenCalledWith([ + '/workspace', + 'stalker', + 'stalker-1', + 'vod', + ]); + }); + + it('leaves a detail from All alone when its genre is not locked', async () => { + router.url = '/workspace/stalker/stalker-1/vod/42'; + lockedStalkerIds.add('9'); + stalkerStore.selectedItem.set({ category_id: '3' }); + + await service.applyStalker(); + + expect(stalkerStore.clearSelectedItem).not.toHaveBeenCalled(); + 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'); + stalkerStore.selectedCategoryId.set('9'); + + await service.applyStalker(); + + expect(stalkerStore.clearSelectedItem).toHaveBeenCalled(); + expect(stalkerStore.setSelectedCategory).toHaveBeenCalledWith(null); + }); + }); + + describe('Xtream', () => { + function lockProvider(providerId: number): void { + parentalLock.active.set(true); + parentalLock.isXtreamCategoryLocked.mockImplementation( + (_p: string, _t: string, id: number) => id === providerId + ); + } + + it('clears a selected item whose category the lock store withholds', async () => { + router.url = '/workspace/xtreams/xtream-1/vod/42'; + lockProvider(990); + xtreamStore.selectedItem.set({ category_id: 99 }); + + await service.applyXtream(parentalLock.version()); + + expect(xtreamStore.reloadCategories).toHaveBeenCalled(); + expect(xtreamDataSource.getAllCategories).toHaveBeenCalledWith( + 'xtream-1', + 'movies' + ); + expect(xtreamStore.setSelectedItem).toHaveBeenCalledWith(null); + expect(xtreamStore.setSelectedCategory).not.toHaveBeenCalled(); + expect(router.navigate).toHaveBeenCalledWith([ + '/workspace', + 'xtreams', + 'xtream-1', + 'vod', + ]); + }); + + it('keeps a detail from a category that is merely hidden, not locked', async () => { + router.url = '/workspace/xtreams/xtream-1/vod/42'; + lockProvider(990); + // Row 55 is absent from the (hidden-filtered) visible list but + // exists unlocked in the unfiltered rows. + xtreamStore.selectedItem.set({ category_id: 55 }); + + await service.applyXtream(parentalLock.version()); + + expect(xtreamStore.setSelectedItem).not.toHaveBeenCalled(); + expect(router.navigate).not.toHaveBeenCalled(); + }); + + it('steps off a selected locked category', async () => { + router.url = '/workspace/xtreams/xtream-1/live'; + lockProvider(990); + xtreamStore.selectedCategoryId.set(99); + + await service.applyXtream(parentalLock.version()); + + expect(xtreamStore.setSelectedItem).toHaveBeenCalledWith(null); + expect(xtreamStore.setSelectedCategory).toHaveBeenCalledWith(null); + expect(router.navigate).toHaveBeenCalledWith([ + '/workspace', + 'xtreams', + 'xtream-1', + 'live', + ]); + }); + + it('fails closed when the category rows cannot be read', async () => { + router.url = '/workspace/xtreams/xtream-1/vod/42'; + parentalLock.active.set(true); + xtreamDataSource.getAllCategories.mockRejectedValueOnce( + new Error('db') + ); + xtreamStore.selectedItem.set({ category_id: 7 }); + + await service.applyXtream(parentalLock.version()); + + expect(xtreamStore.setSelectedItem).toHaveBeenCalledWith(null); + }); + + it('skips the post-reload checks while unlocked and hands the reloads a publish guard', async () => { + router.url = '/workspace/xtreams/xtream-1/vod'; + + await service.applyXtream(parentalLock.version()); + + expect(xtreamStore.withholdCatalog).not.toHaveBeenCalled(); + expect(xtreamDataSource.getAllCategories).not.toHaveBeenCalled(); + const guard = xtreamStore.reloadCategories.mock.calls[0][0] as + (() => boolean) | undefined; + expect(guard?.()).toBe(true); + parentalLock.version.set(parentalLock.version() + 1); + expect(guard?.()).toBe(false); + }); + + it('abandons the reloads and checks once the Xtream playlist is switched', async () => { + router.url = '/workspace/xtreams/xtream-1/vod/7'; + parentalLock.active.set(true); + xtreamStore.selectedCategoryId.set(7); + parentalLock.isXtreamCategoryLocked.mockReturnValue(true); + let guard: () => boolean = () => true; + (xtreamStore.reloadCategories as jest.Mock).mockImplementationOnce( + async (shouldPublish: () => boolean) => { + guard = shouldPublish; + xtreamStore.playlistId.set('xtream-2'); + router.url = '/workspace/xtreams/xtream-2/vod'; + } + ); + + try { + await service.applyXtream(parentalLock.version()); + expect(guard()).toBe(false); + expect(xtreamStore.refreshSearchResults).not.toHaveBeenCalled(); + expect( + xtreamDataSource.getAllCategories + ).not.toHaveBeenCalled(); + expect(xtreamStore.setSelectedCategory).not.toHaveBeenCalled(); + expect(router.navigate).not.toHaveBeenCalled(); + } finally { + xtreamStore.playlistId.set('xtream-1'); + } + }); + + 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('clears on relock, synchronously, a detail whose category is not in the visible list', () => { + // Opened through search from a manually hidden category: the + // on-screen list cannot place it, and the unfiltered lookup is + // an awaited read that may hang. + router.url = '/workspace/xtreams/xtream-1/vod/55/900'; + xtreamStore.selectedItem.set({ category_id: 55 }); + + service.failClosedNow(); + + expect(xtreamStore.setSelectedItem).toHaveBeenCalledWith(null); + expect(router.navigate).toHaveBeenCalledWith([ + '/workspace', + 'xtreams', + 'xtream-1', + 'vod', + ]); + }); + + it('keeps a detail on relock whose visible category is not locked', () => { + router.url = '/workspace/xtreams/xtream-1/vod/7/900'; + xtreamStore.selectedItem.set({ category_id: 7 }); + + service.failClosedNow(); + + expect(xtreamStore.setSelectedItem).not.toHaveBeenCalled(); + }); + + it('fails closed synchronously on relock: detail, catalog and search', () => { + router.url = '/workspace/xtreams/xtream-1/vod/42'; + lockProvider(70); + xtreamStore.selectedItem.set({ category_id: 7 }); + + service.failClosedNow(); + + expect(xtreamStore.setSelectedItem).toHaveBeenCalledWith(null); + expect(xtreamStore.withholdCatalog).toHaveBeenCalled(); + expect(xtreamStore.clearSearchResults).toHaveBeenCalled(); + expect(xtreamStore.reloadCategories).not.toHaveBeenCalled(); + }); + }); +}); + +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), + withholdCatalog: jest.fn(), + clearSearchResults: jest.fn(), + getCategoriesBySelectedType: jest.fn(() => [] as unknown[]), + setSelectedItem: jest.fn(), + setSelectedCategory: jest.fn(), + }; + TestBed.configureTestingModule({ + providers: [ + { + provide: ParentalLockService, + useValue: { + version, + active: signal(true), + isXtreamCategoryLocked: jest.fn(() => false), + registerBusyProbe: jest.fn(), + isStalkerCategoryLocked: jest.fn(() => false), + isM3uGroupLocked: jest.fn(() => false), + }, + }, + { provide: XtreamStore, useValue: xtreamStore }, + { + // No rows: the selected category cannot be placed and + // fails closed once an apply gets to judge it. + provide: XTREAM_DATA_SOURCE, + useValue: { getAllCategories: jest.fn(async () => []) }, + }, + { + provide: StalkerStore, + useValue: { currentPlaylist: signal(null) }, + }, + { + provide: Router, + useValue: { + url: '/workspace/xtreams/xtream-1/live', + 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, + // but the catalog is withheld at once rather than behind the hung + // read. + xtreamStore.withholdCatalog.mockClear(); + version.set(2); + TestBed.flushEffects(); + expect(xtreamStore.withholdCatalog).toHaveBeenCalledTimes(1); + 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); + }); +}); + +describe('ParentalLockEnforcementService busy probe', () => { + it('counts playing audio (the radio player) as activity', () => { + let probe: (() => boolean) | undefined; + TestBed.resetTestingModule(); + TestBed.configureTestingModule({ + providers: [ + { + provide: ParentalLockService, + useValue: { + version: signal(0), + registerBusyProbe: (fn: () => boolean) => (probe = fn), + }, + }, + { provide: XtreamStore, useValue: {} }, + { provide: XTREAM_DATA_SOURCE, useValue: {} }, + { provide: StalkerStore, useValue: {} }, + { provide: Router, useValue: { url: '/' } }, + { + provide: Store, + useValue: { selectSignal: () => signal(null) }, + }, + { + provide: PlaybackKeepAwakeService, + useValue: { hasPlayingVideo: () => false }, + }, + ], + }); + const service = TestBed.inject(ParentalLockEnforcementService); + TestBed.runInInjectionContext(() => service.start()); + expect(probe?.()).toBe(false); + + const audio = document.createElement('audio'); + Object.defineProperty(audio, 'paused', { value: false }); + Object.defineProperty(audio, 'ended', { value: false }); + document.body.appendChild(audio); + try { + expect(probe?.()).toBe(true); + } finally { + audio.remove(); + } + }); +}); diff --git a/apps/web/src/app/services/parental-lock-enforcement.service.ts b/apps/web/src/app/services/parental-lock-enforcement.service.ts new file mode 100644 index 000000000..16a092755 --- /dev/null +++ b/apps/web/src/app/services/parental-lock-enforcement.service.ts @@ -0,0 +1,394 @@ +import { + effect, + EnvironmentInjector, + inject, + Injectable, + untracked, +} from '@angular/core'; +import { NavigationEnd, Router } from '@angular/router'; +import { Store } from '@ngrx/store'; +import { ChannelActions, selectActive } from '@iptvnator/m3u-state'; +import { + XTREAM_DATA_SOURCE, + XtreamStore, +} from '@iptvnator/portal/xtream/data-access'; +import { ParentalLockService } from '@iptvnator/services'; +import { toParentalLockXtreamCategoryType } from '@iptvnator/shared/interfaces'; +import { PlaybackKeepAwakeService } from './playback-keep-awake.service'; + +const XTREAM_ROUTE = + /^\/workspace\/xtreams\/([^/?#]+)\/(live|vod|series)(?:\/(\d+))?/; +export const STALKER_ROUTE = + /^\/workspace\/stalker\/([^/?#]+)\/(itv|vod|series|radio)(?:\/([^/?#]+))?/; + +/** + * Applies a parental lock change to the parts of the app that hold catalog + * data in memory. The stores and the SQLite worker filter what they READ; + * this service makes them read again and steps off anything that is now + * withheld — a selected category, a playing channel — so a locked category + * cannot stay on screen just because it was opened before the lock. + */ +@Injectable({ providedIn: 'root' }) +export class ParentalLockEnforcementService { + private readonly parentalLock = inject(ParentalLockService); + private readonly xtreamStore = inject(XtreamStore); + private readonly xtreamDataSource = inject(XTREAM_DATA_SOURCE); + private readonly injector = inject(EnvironmentInjector); + private readonly router = inject(Router); + private readonly store = inject(Store); + private readonly keepAwake = inject(PlaybackKeepAwakeService); + private readonly activeChannel = this.store.selectSignal(selectActive); + private started = false; + private lastVersion = -1; + private applyChain: Promise = Promise.resolve(); + private stalkerModule: StalkerEnforcementModule | null = null; + private stalkerModuleLoad: Promise | null = null; + + start(): void { + if (this.started) { + return; + } + this.started = true; + // Playback counts as activity: video through the keep-awake + // tracker, and audio (the radio player) read directly, since the + // keep-awake service deliberately ignores