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/CLAUDE.md b/CLAUDE.md index d02196c94..139e61af3 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -625,7 +625,7 @@ See `docs/architecture/m3u-playlist-module.md` for complete documentation. `/workspace/xtreams/:id/downloads/:downloadId` and `/workspace/stalker/:id/downloads/:downloadId`. Focused download details hide the workspace context panel. -- Settings: `/workspace/settings/:section` — one page per section (`general`, `playback`, `epg`, `dashboard`, `remote-control`, `tmdb`, `backup`, `reset`, `about`); `/workspace/settings` redirects to `general`, unknown or capability-gated sections redirect there too, and `/settings` redirects into the workspace. The `general` section's "Window on startup" select (`Settings.startupWindowMode`: `normal` / `maximized` / `fullscreen`, Electron only, gated on `RuntimeCapabilitiesService.supportsStartupWindowMode`) is mirrored into the main-process config by the `SETTINGS_UPDATE` handler and read synchronously at the next window creation — the renderer's IndexedDB is unreachable then, so it is the same pattern as `embeddedMpvFrameCopy`; `iptvnator --fullscreen` forces one fullscreen launch without persisting it, and F11 (`WINDOW:TOGGLE_FULLSCREEN`, bound in `WorkspaceKeyboardShortcutsService`, skipped while the player owns `document.fullscreenElement`) is the exit path on Windows/Linux, where the title bar is hidden (contract: `docs/architecture/workspace-shell.md`, "Startup window mode"). The shared form lives on the parent `SettingsComponent`, so edits survive section switches; a floating unsaved-changes bar (Save/Discard) replaces the old always-visible footer Save button. Leaving the settings AREA with a dirty form triggers `settingsUnsavedChangesGuard` (canDeactivate) and a save/discard/stay dialog — section switches deliberately bypass it, and a failed save cancels the navigation. Non-router exits are covered too: `SettingsUnloadGuardService` (provided by `SettingsComponent`) arms a `beforeunload` handler while the form is dirty (native leave prompt in the PWA) and arms an Electron main-process close guard (`window-close-guard.service.ts`) for the whole settings mount — mount-long on purpose, since arming on the first edit would race the close it protects against. The guard intercepts window close/app quit before `beforeunload` fires and completes the original intent only after the renderer confirms through the same dialog (a pristine form auto-confirms); Electron reloads are cancelled and re-triggered the same way, a failed save always keeps the window open, and installing an app update suspends the whole guard so the updater's quit passes unchallenged — every install entry point (settings About section and the global update notification panel) must go through the root `AppUpdateInstallService`, which owns that suspend/restore choreography +- Settings: `/workspace/settings/:section` — one page per section (`general`, `playback`, `epg`, `dashboard`, `remote-control`, `tmdb`, `parental`, `backup`, `reset`, `about`); `/workspace/settings` redirects to `general`, unknown or capability-gated sections redirect there too, and `/settings` redirects into the workspace. The `general` section's "Window on startup" select (`Settings.startupWindowMode`: `normal` / `maximized` / `fullscreen`, Electron only, gated on `RuntimeCapabilitiesService.supportsStartupWindowMode`) is mirrored into the main-process config by the `SETTINGS_UPDATE` handler and read synchronously at the next window creation — the renderer's IndexedDB is unreachable then, so it is the same pattern as `embeddedMpvFrameCopy`; `iptvnator --fullscreen` forces one fullscreen launch without persisting it, and F11 (`WINDOW:TOGGLE_FULLSCREEN`, bound in `WorkspaceKeyboardShortcutsService`, skipped while the player owns `document.fullscreenElement`) is the exit path on Windows/Linux, where the title bar is hidden (contract: `docs/architecture/workspace-shell.md`, "Startup window mode"). The shared form lives on the parent `SettingsComponent`, so edits survive section switches; a floating unsaved-changes bar (Save/Discard) replaces the old always-visible footer Save button. Leaving the settings AREA with a dirty form triggers `settingsUnsavedChangesGuard` (canDeactivate) and a save/discard/stay dialog — section switches deliberately bypass it, and a failed save cancels the navigation. Non-router exits are covered too: `SettingsUnloadGuardService` (provided by `SettingsComponent`) arms a `beforeunload` handler while the form is dirty (native leave prompt in the PWA) and arms an Electron main-process close guard (`window-close-guard.service.ts`) for the whole settings mount — mount-long on purpose, since arming on the first edit would race the close it protects against. The guard intercepts window close/app quit before `beforeunload` fires and completes the original intent only after the renderer confirms through the same dialog (a pristine form auto-confirms); Electron reloads are cancelled and re-triggered the same way, a failed save always keeps the window open, and installing an app update suspends the whole guard so the updater's quit passes unchallenged — every install entry point (settings About section and the global update notification panel) must go through the root `AppUpdateInstallService`, which owns that suspend/restore choreography **Service Architecture** (Factory Pattern): @@ -768,7 +768,7 @@ This project uses modern Angular signal-based APIs and patterns. **ALWAYS** use - **Location**: `~/.iptvnator/databases/iptvnator.db` (avoids spaces in path) - **Schema** (`libs/shared/database/src/lib/schema.ts` — canonical; `apps/electron-backend/src/app/database/schema.ts` is a backwards-compat re-export shim): - `playlists` - Playlist metadata (M3U, Xtream, Stalker) - - `categories` - Content categories (live, movies, series) + - `categories` - Content categories (live, movies, series); `hidden` (category management) and `locked` (parental lock index, derived from the renderer's lock store) - `content` - Streams/VOD/series items. Besides the catalog fields it carries what a detail view learned and handed back: `backdrop_url`, plus the TMDB identity (`tmdb_id`, `release_year`, `original_title`) that lets an activity row repeat the detail view's lookup instead of rebuilding a weaker one from the display title - `favorites` - User favorites - `recentlyViewed` - Watch history @@ -809,6 +809,29 @@ This project uses modern Angular signal-based APIs and patterns. **ALWAYS** use - Non-EPG SQLite work: `database.worker.ts` (see `docs/architecture/sqlite-db-worker.md`). Catalog deletes and inserts commit in row-budgeted transactions of ~5,000 rows (`database/operations/catalog-deletion.ts`: per-category row counts → category groups → set-based `DELETE`s scoped to the captured category ids, never playlist-wide, since the worker interleaves requests between commits and a newer import's categories must survive an older refresh; never 100-row autocommit batches, which flush FTS5 segments and re-append index pages to the WAL on every commit, and never one giant transaction, which would starve the main-process and EPG-worker connections past their 5 s `busy_timeout`). Progress events are throttled to one per 100 ms per operation with summed `increment`s (`operation-progress-throttle.ts`); phase starts, totals reached and terminal events are never held back - Playlist refresh: `playlist-refresh.worker.ts`; explicit cancellation is main-process-owned and terminates the one-shot worker before acknowledging `PLAYLIST_CANCEL_REFRESH` (see `docs/architecture/m3u-playlist-module.md`) +### Parental Lock + +Issue #285. Settings → Parental lock sets a PIN (PBKDF2 hash in `app_state` +/ localStorage, never in `Settings`) and enables the feature +(`Settings.parentalLockEnabled`, mirrored to electron-conf +`PARENTAL_LOCK_ENABLED`). Locks are per category — Xtream category ids, +Stalker genre ids, M3U group titles — kept in one renderer lock store +(`ParentalLockService`, `libs/services/src/lib/parental-lock/`), with +`categories.locked` as the SQLite index re-stamped by +`DB_SET_CATEGORY_LOCKS`. While `active` (enabled and no PIN entered this +session) the DB worker appends `locked = 0` to every content read +(`database/parental-lock-state.ts`, seeded via `workerData` and flipped by the +`parental-lock` port message that `PARENTAL_LOCK_SET_STATE` forwards; a +renderer reload or crash falls back to the mirrored setting), the PWA data +source, the Stalker store and the M3U channel list filter in memory, and +`ParentalLockEnforcementService` reloads the stores and steps off a withheld +selection. Re-lock: restart, `Lock now`, or `parentalLockRelockMinutes` of +idle (playback counts as activity). Lock toggles live in the Xtream/M3U +management dialogs and the new Stalker lock dialog, all behind the PIN. +Phase 2 (favorites/recent/positions, dashboard, global collections, guide, +downloads, remote control) is listed in the contract: +`docs/architecture/parental-lock.md`. + ### Xtream Category Management The Electron Live TV, Movies, and Series category dialog applies Select/Deselect @@ -1854,30 +1877,31 @@ preventing destination failures from penalizing the initial endpoint. Contracts: Portal live layouts (Xtream `live`, Stalker `itv`/`radio`) fold their panels from the outside in, in three nested levels owned by `LiveSidebarState` (`@iptvnator/portal/shared/util`): `expanded` (categories rail + channels rail -+ player), `categories-hidden` (channels rail + player) and `collapsed` -(player only). `LiveLayoutSidebarStateService` is the single source of truth, per -surface (`m3u` / `portal` / `collection`; the levels apply to `portal`); the -shell context sidebar folds the categories rail on -`areCategoriesHiddenFor('portal')` (at level 2 only while the portal store has -a selected category — the live root has no channels header to host the way -back — and always at level 3), the channels rail folds on -`isCollapsedFor('portal')`. While the rail is folded the -channels header turns its title into a category dropdown that opens the same -`WorkspaceContextPanelComponent` as a CDK popover through the -`LIVE_CATEGORIES_POPOVER` token: the workspace shell provides -`WorkspaceLiveCategoriesPopoverService` (focus-trapped `role="dialog"`, -closed by backdrop, Escape, selection, its footer and any `NavigationStart`), -the live layouts reach it through `createLivePanelsController()` (level -flags, dropdown bridge and focus handoff in one shared object; the token is -optional). `Cmd/Ctrl+B`, the header toggle and the -floating restore handle return to the level the user collapsed from (the -target is session-only; every level is restored as stored per surface). -Folded rails carry `inert`, and -`handoffFocusOnLiveSidebarChange()` / `focusIfFocusLost()` move focus to the -replacement affordance only when the activated button was removed or inerted. -M3U and the unified live tab have no categories rail and treat level 2 like -level 1. Contract: `docs/architecture/iptvnator-ui-guidelines.md` -("Collapsible Live Sidebar"). + +- player), `categories-hidden` (channels rail + player) and `collapsed` + (player only). `LiveLayoutSidebarStateService` is the single source of truth, per + surface (`m3u` / `portal` / `collection`; the levels apply to `portal`); the + shell context sidebar folds the categories rail on + `areCategoriesHiddenFor('portal')` (at level 2 only while the portal store has + a selected category — the live root has no channels header to host the way + back — and always at level 3), the channels rail folds on + `isCollapsedFor('portal')`. While the rail is folded the + channels header turns its title into a category dropdown that opens the same + `WorkspaceContextPanelComponent` as a CDK popover through the + `LIVE_CATEGORIES_POPOVER` token: the workspace shell provides + `WorkspaceLiveCategoriesPopoverService` (focus-trapped `role="dialog"`, + closed by backdrop, Escape, selection, its footer and any `NavigationStart`), + the live layouts reach it through `createLivePanelsController()` (level + flags, dropdown bridge and focus handoff in one shared object; the token is + optional). `Cmd/Ctrl+B`, the header toggle and the + floating restore handle return to the level the user collapsed from (the + target is session-only; every level is restored as stored per surface). + Folded rails carry `inert`, and + `handoffFocusOnLiveSidebarChange()` / `focusIfFocusLost()` move focus to the + replacement affordance only when the activated button was removed or inerted. + M3U and the unified live tab have no categories rail and treat level 2 like + level 1. Contract: `docs/architecture/iptvnator-ui-guidelines.md` + ("Collapsible Live Sidebar"). ## Live Channel Return 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 a3e895386..183b68495 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 580167b15..e73b21a60 100644 --- a/apps/electron-backend/src/app/api/main.preload.ts +++ b/apps/electron-backend/src/app/api/main.preload.ts @@ -93,6 +93,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< ( @@ -447,6 +448,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, @@ -812,14 +815,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), @@ -829,6 +834,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..e0177338b 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,89 @@ 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 }), + ]); + }); + + it('re-stamps one playlist/type: clears everything, then locks the listed ids', async () => { + const where = jest.fn().mockResolvedValue(undefined); + const set = jest.fn().mockReturnValue({ where }); + const update = jest.fn().mockReturnValue({ set }); + const db = { update } as unknown as AppDatabase; + + await setCategoryLocks(db, 'playlist-1', 'live', [5, 5, 7, 1.5]); + + 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 where = jest.fn().mockResolvedValue(undefined); + const set = jest.fn().mockReturnValue({ where }); + const update = jest.fn().mockReturnValue({ set }); + const db = { update } as unknown as AppDatabase; + + await setCategoryLocks(db, 'playlist-1', 'series', []); + + expect(set).toHaveBeenCalledTimes(1); + expect(set).toHaveBeenCalledWith({ locked: false }); + }); +}); 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..465c9d287 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,35 @@ 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))), + ]; + + await db.update(schema.categories).set({ locked: false }).where(scope); + + if (lockedIds.length > 0) { + await db + .update(schema.categories) + .set({ locked: true }) + .where(and(scope, inArray(schema.categories.xtreamId, lockedIds))); + } + + 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.ts b/apps/electron-backend/src/app/events/settings.events.ts index bb32607f5..5587f4f42 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'; export default class SettingsEvents { static bootstrapSettingsEvents(): Electron.IpcMain { @@ -41,6 +43,16 @@ ipcMain.handle('SETTINGS_UPDATE', (_event, arg) => { setHostConnectivityGuardEnabled(enabled); } + // Mirrored so the database worker and a reloaded renderer start locked + // whenever the feature is on. Turning the feature off also releases the + // worker filter immediately; turning it on locks it — the renderer + // announces an explicit unlock through PARENTAL_LOCK_SET_STATE. + if (arg.parentalLockEnabled !== undefined) { + const enabled = arg.parentalLockEnabled === true; + store.set(PARENTAL_LOCK_ENABLED, enabled); + void applyParentalLockState(enabled).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 d53338efb..af01082fe 100644 --- a/apps/electron-backend/src/app/services/store.service.ts +++ b/apps/electron-backend/src/app/services/store.service.ts @@ -28,6 +28,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'; + /** * Extra libmpv options for embedded sessions, one "key=value" per line, as * typed in Settings > Playback. Mirrored here by the SETTINGS_UPDATE handler @@ -56,6 +64,7 @@ export type StoreType = { [EMBEDDED_MPV_AUTO_RECONNECT]: boolean; [STARTUP_WINDOW_MODE]: StartupWindowMode; [PORTAL_CONNECTIVITY_GUARD]: boolean; + [PARENTAL_LOCK_ENABLED]: boolean; }; // Export singleton store instance 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/electron-backend/src/main.ts b/apps/electron-backend/src/main.ts index 69827f094..83600433e 100644 --- a/apps/electron-backend/src/main.ts +++ b/apps/electron-backend/src/main.ts @@ -26,6 +26,7 @@ import { shutdownVlcSession } from './app/events/vlc-session.service'; import PlaylistEvents from './app/events/playlist.events'; import PlaylistOpenEvents from './app/events/playlist-open.events'; import RemoteControlEvents from './app/events/remote-control.events'; +import ParentalLockEvents from './app/events/parental-lock.events'; import SettingsEvents from './app/events/settings.events'; import SharedEvents from './app/events/shared.events'; import SquirrelEvents from './app/events/squirrel.events'; @@ -160,6 +161,7 @@ export default class Main { SharedEvents.bootstrapSharedEvents(); PlayerEvents.bootstrapPlayerEvents(); SettingsEvents.bootstrapSettingsEvents(); + ParentalLockEvents.bootstrapParentalLockEvents(); StalkerEvents.bootstrapStalkerEvents(); XtreamEvents.bootstrapXtreamEvents(); registerStreamProbeHandlers(); diff --git a/apps/web-e2e/dist/.playwright/apps/web-e2e/screenshots/settings/@settings @web Change app language.png b/apps/web-e2e/dist/.playwright/apps/web-e2e/screenshots/settings/@settings @web Change app language.png index 9b09f0443..03b075691 100644 Binary files a/apps/web-e2e/dist/.playwright/apps/web-e2e/screenshots/settings/@settings @web Change app language.png and b/apps/web-e2e/dist/.playwright/apps/web-e2e/screenshots/settings/@settings @web Change app language.png differ diff --git a/apps/web-e2e/dist/.playwright/apps/web-e2e/screenshots/settings/@settings @web Change app theme.png b/apps/web-e2e/dist/.playwright/apps/web-e2e/screenshots/settings/@settings @web Change app theme.png index ae5559a5e..786651934 100644 Binary files a/apps/web-e2e/dist/.playwright/apps/web-e2e/screenshots/settings/@settings @web Change app theme.png and b/apps/web-e2e/dist/.playwright/apps/web-e2e/screenshots/settings/@settings @web Change app theme.png differ diff --git a/apps/web-e2e/dist/.playwright/apps/web-e2e/screenshots/settings/@settings @web Change video player.png b/apps/web-e2e/dist/.playwright/apps/web-e2e/screenshots/settings/@settings @web Change video player.png index 9e4c06376..03b075691 100644 Binary files a/apps/web-e2e/dist/.playwright/apps/web-e2e/screenshots/settings/@settings @web Change video player.png and b/apps/web-e2e/dist/.playwright/apps/web-e2e/screenshots/settings/@settings @web Change video player.png differ diff --git a/apps/web-e2e/dist/.playwright/apps/web-e2e/screenshots/settings/@settings @web Check settings page.png b/apps/web-e2e/dist/.playwright/apps/web-e2e/screenshots/settings/@settings @web Check settings page.png index 34a05696c..2c857d743 100644 Binary files a/apps/web-e2e/dist/.playwright/apps/web-e2e/screenshots/settings/@settings @web Check settings page.png and b/apps/web-e2e/dist/.playwright/apps/web-e2e/screenshots/settings/@settings @web Check settings page.png differ diff --git a/apps/web-e2e/dist/.playwright/apps/web-e2e/screenshots/settings/@settings @web Deep links open one section page and unknown sections redirect.png b/apps/web-e2e/dist/.playwright/apps/web-e2e/screenshots/settings/@settings @web Deep links open one section page and unknown sections redirect.png index 18f7992d2..c36707b6f 100644 Binary files a/apps/web-e2e/dist/.playwright/apps/web-e2e/screenshots/settings/@settings @web Deep links open one section page and unknown sections redirect.png and b/apps/web-e2e/dist/.playwright/apps/web-e2e/screenshots/settings/@settings @web Deep links open one section page and unknown sections redirect.png differ diff --git a/apps/web-e2e/dist/.playwright/apps/web-e2e/screenshots/settings/@settings @web Leaving with unsaved edits asks for confirmation.png b/apps/web-e2e/dist/.playwright/apps/web-e2e/screenshots/settings/@settings @web Leaving with unsaved edits asks for confirmation.png index b63a79e02..03b075691 100644 Binary files a/apps/web-e2e/dist/.playwright/apps/web-e2e/screenshots/settings/@settings @web Leaving with unsaved edits asks for confirmation.png and b/apps/web-e2e/dist/.playwright/apps/web-e2e/screenshots/settings/@settings @web Leaving with unsaved edits asks for confirmation.png differ 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/dist/.playwright/apps/web-e2e/screenshots/settings/@settings @web Save-and-leave persists the staged edit.png b/apps/web-e2e/dist/.playwright/apps/web-e2e/screenshots/settings/@settings @web Save-and-leave persists the staged edit.png index 06cb9eb42..03b075691 100644 Binary files a/apps/web-e2e/dist/.playwright/apps/web-e2e/screenshots/settings/@settings @web Save-and-leave persists the staged edit.png and b/apps/web-e2e/dist/.playwright/apps/web-e2e/screenshots/settings/@settings @web Save-and-leave persists the staged edit.png differ diff --git a/apps/web-e2e/dist/.playwright/apps/web-e2e/screenshots/settings/@settings @web Unsaved bar survives section switches and discard reverts.png b/apps/web-e2e/dist/.playwright/apps/web-e2e/screenshots/settings/@settings @web Unsaved bar survives section switches and discard reverts.png index 9f075ac73..03b075691 100644 Binary files a/apps/web-e2e/dist/.playwright/apps/web-e2e/screenshots/settings/@settings @web Unsaved bar survives section switches and discard reverts.png and b/apps/web-e2e/dist/.playwright/apps/web-e2e/screenshots/settings/@settings @web Unsaved bar survives section switches and discard reverts.png differ diff --git a/apps/web-e2e/src/settings.e2e.ts b/apps/web-e2e/src/settings.e2e.ts index 1ed31f3f8..6461bd370 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.afterEach(async ({ page }, testInfo) => { diff --git a/apps/web/src/app/app.component.spec.ts b/apps/web/src/app/app.component.spec.ts index 8a38ba1a1..1f98dd6d5 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, @@ -96,6 +98,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 1ad524812..035ab2415 100644 --- a/apps/web/src/app/app.component.ts +++ b/apps/web/src/app/app.component.ts @@ -25,6 +25,7 @@ import { PlaylistActions, selectAllPlaylistsMeta } from '@iptvnator/m3u-state'; import { filter, take } from 'rxjs'; import { DataService, + ParentalLockService, RuntimeCapabilitiesService, SettingsStore, EpgSourceSettingsService, @@ -38,6 +39,7 @@ import { createDevLogger, } from '@iptvnator/shared/interfaces'; 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'; @@ -76,6 +78,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); @@ -101,6 +107,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 8aa62f5f1..270dc262c 100644 --- a/apps/web/src/app/app.config.ts +++ b/apps/web/src/app/app.config.ts @@ -39,6 +39,7 @@ 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'; @@ -159,6 +160,7 @@ export const appConfig: ApplicationConfig = { useExisting: AppStalkerPlaylistConnectionEditorService, }, ...provideWorkspaceShellActions(), + ...provideParentalLockPrompt(), ...provideXtreamDataSource(), { provide: MAT_FORM_FIELD_DEFAULT_OPTIONS, 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..117e21cb7 --- /dev/null +++ b/apps/web/src/app/services/parental-lock-enforcement.service.ts @@ -0,0 +1,155 @@ +import { effect, inject, Injectable, untracked } from '@angular/core'; +import { Router } from '@angular/router'; +import { Store } from '@ngrx/store'; +import { ChannelActions, selectActive } from '@iptvnator/m3u-state'; +import { StalkerStore } from '@iptvnator/portal/stalker/data-access'; +import { XtreamStore } from '@iptvnator/portal/xtream/data-access'; +import { ParentalLockService } from '@iptvnator/services'; +import { + toParentalLockStalkerCategoryType, + toParentalLockXtreamCategoryType, +} from '@iptvnator/shared/interfaces'; +import { PlaybackKeepAwakeService } from './playback-keep-awake.service'; + +const XTREAM_ROUTE = + /^\/workspace\/xtreams\/([^/?#]+)\/(live|vod|series)(?:\/(\d+))?/; +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 stalkerStore = inject(StalkerStore); + 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; + + start(): void { + if (this.started) { + return; + } + this.started = true; + this.parentalLock.registerBusyProbe(() => + this.keepAwake.hasPlayingVideo() + ); + effect(() => { + const version = this.parentalLock.version(); + untracked(() => { + if (version === this.lastVersion) { + return; + } + const first = this.lastVersion === -1; + this.lastVersion = version; + if (!first) { + void this.apply(); + } + }); + }); + } + + private async apply(): Promise { + await Promise.all([this.applyXtream(), this.applyStalker()]); + this.applyM3u(); + } + + private async applyXtream(): Promise { + const playlistId = this.xtreamStore.playlistId?.(); + if (!playlistId) { + return; + } + await this.xtreamStore.reloadCategories(); + await this.xtreamStore.reloadCachedContent(); + + const match = XTREAM_ROUTE.exec(this.router.url); + const selectedCategoryId = this.xtreamStore.selectedCategoryId(); + if (selectedCategoryId === null) { + return; + } + const categoryType = toParentalLockXtreamCategoryType(match?.[2]); + const categories = this.xtreamStore.getCategoriesBySelectedType(); + const stillVisible = categories.some( + (category) => + Number( + (category as { id?: number | string }).id ?? + (category as { category_id?: string }).category_id + ) === Number(selectedCategoryId) + ); + if (stillVisible) { + return; + } + this.xtreamStore.setSelectedItem(null); + this.xtreamStore.setSelectedCategory(null); + if (match && match[1] === playlistId && categoryType) { + void this.router.navigate([ + '/workspace', + 'xtreams', + match[1], + match[2], + ]); + } + } + + private async applyStalker(): Promise { + const playlist = this.stalkerStore.currentPlaylist(); + const playlistId = playlist?._id; + if (!playlistId) { + return; + } + const contentType = toParentalLockStalkerCategoryType( + this.stalkerStore.selectedContentType() + ); + const selectedCategoryId = this.stalkerStore.selectedCategoryId(); + if ( + !contentType || + !selectedCategoryId || + !this.parentalLock.isStalkerCategoryLocked( + playlistId, + contentType, + selectedCategoryId + ) + ) { + return; + } + this.stalkerStore.clearSelectedItem(); + this.stalkerStore.setSelectedCategory(null); + const match = STALKER_ROUTE.exec(this.router.url); + if (match && match[1] === playlistId) { + void this.router.navigate([ + '/workspace', + 'stalker', + match[1], + match[2], + ]); + } + } + + private applyM3u(): void { + const channel = this.activeChannel(); + const playlistId = this.activeM3uPlaylistId(); + if (!channel || !playlistId) { + return; + } + const groupTitle = channel.group?.title ?? ''; + if (this.parentalLock.isM3uGroupLocked(playlistId, groupTitle)) { + this.store.dispatch(ChannelActions.resetActiveChannel()); + } + } + + private activeM3uPlaylistId(): string | null { + const match = /^\/workspace\/playlists\/([^/?#]+)/.exec( + this.router.url + ); + return match ? decodeURIComponent(match[1]) : null; + } +} diff --git a/apps/web/src/app/services/parental-lock-prompt.service.ts b/apps/web/src/app/services/parental-lock-prompt.service.ts new file mode 100644 index 000000000..347b79363 --- /dev/null +++ b/apps/web/src/app/services/parental-lock-prompt.service.ts @@ -0,0 +1,42 @@ +import { inject, Injectable, Provider } from '@angular/core'; +import { MatDialog } from '@angular/material/dialog'; +import { firstValueFrom } from 'rxjs'; +import { + PARENTAL_LOCK_PROMPT, + ParentalLockPrompt, + ParentalLockPromptRequest, +} from '@iptvnator/services'; +import { ParentalLockPinDialogComponent } from '@iptvnator/ui/components'; + +/** + * Application implementation of the parental lock prompt: the shared PIN + * dialog from `@iptvnator/ui/components`, which the data-access lock + * service cannot import itself. + */ +@Injectable({ providedIn: 'root' }) +export class AppParentalLockPromptService implements ParentalLockPrompt { + private readonly dialog = inject(MatDialog); + + async requestPin( + request: ParentalLockPromptRequest + ): Promise { + const dialogRef = ParentalLockPinDialogComponent.open(this.dialog, { + mode: request.mode, + verify: request.verify, + titleKey: request.titleKey, + descriptionKey: request.descriptionKey, + }); + const pin = await firstValueFrom(dialogRef.afterClosed()); + return typeof pin === 'string' ? pin : null; + } +} + +export function provideParentalLockPrompt(): Provider[] { + return [ + AppParentalLockPromptService, + { + provide: PARENTAL_LOCK_PROMPT, + useExisting: AppParentalLockPromptService, + }, + ]; +} diff --git a/apps/web/src/app/services/playback-keep-awake.service.ts b/apps/web/src/app/services/playback-keep-awake.service.ts index 5c54ec8b8..eb2f512b7 100644 --- a/apps/web/src/app/services/playback-keep-awake.service.ts +++ b/apps/web/src/app/services/playback-keep-awake.service.ts @@ -72,6 +72,11 @@ export class PlaybackKeepAwakeService { this.sync(); }; + /** Whether any tracked `