diff --git a/apps/web/src/assets/i18n/ar.json b/apps/web/src/assets/i18n/ar.json index 27e814416..17814fd53 100644 --- a/apps/web/src/assets/i18n/ar.json +++ b/apps/web/src/assets/i18n/ar.json @@ -1911,7 +1911,9 @@ "COOLDOWN": "Too many attempts. Wait 30 seconds and try again.", "NO_RECOVERY": "There is no way to recover a forgotten PIN — only resetting the app data removes it.", "SAVE": "Save PIN", - "UNLOCK": "Unlock" + "UNLOCK": "Unlock", + "CONFIRM_TITLE": "Confirm parental PIN", + "CONFIRM_DESCRIPTION": "Enter the current PIN to change this setting." }, "LOCKED_COUNT": "Locked", "LOCK_CATEGORY": "Lock category", diff --git a/apps/web/src/assets/i18n/ary.json b/apps/web/src/assets/i18n/ary.json index 88f659ec5..9caf09696 100644 --- a/apps/web/src/assets/i18n/ary.json +++ b/apps/web/src/assets/i18n/ary.json @@ -1911,7 +1911,9 @@ "COOLDOWN": "Too many attempts. Wait 30 seconds and try again.", "NO_RECOVERY": "There is no way to recover a forgotten PIN — only resetting the app data removes it.", "SAVE": "Save PIN", - "UNLOCK": "Unlock" + "UNLOCK": "Unlock", + "CONFIRM_TITLE": "Confirm parental PIN", + "CONFIRM_DESCRIPTION": "Enter the current PIN to change this setting." }, "LOCKED_COUNT": "Locked", "LOCK_CATEGORY": "Lock category", diff --git a/apps/web/src/assets/i18n/by.json b/apps/web/src/assets/i18n/by.json index 219511b96..29e3e5009 100644 --- a/apps/web/src/assets/i18n/by.json +++ b/apps/web/src/assets/i18n/by.json @@ -1911,7 +1911,9 @@ "COOLDOWN": "Too many attempts. Wait 30 seconds and try again.", "NO_RECOVERY": "There is no way to recover a forgotten PIN — only resetting the app data removes it.", "SAVE": "Save PIN", - "UNLOCK": "Unlock" + "UNLOCK": "Unlock", + "CONFIRM_TITLE": "Confirm parental PIN", + "CONFIRM_DESCRIPTION": "Enter the current PIN to change this setting." }, "LOCKED_COUNT": "Locked", "LOCK_CATEGORY": "Lock category", diff --git a/apps/web/src/assets/i18n/de.json b/apps/web/src/assets/i18n/de.json index e8539f774..9062e06fd 100644 --- a/apps/web/src/assets/i18n/de.json +++ b/apps/web/src/assets/i18n/de.json @@ -1911,7 +1911,9 @@ "COOLDOWN": "Zu viele Versuche. Warte 30 Sekunden.", "NO_RECOVERY": "Eine vergessene PIN kann nicht wiederhergestellt werden – nur das Zurücksetzen der App-Daten entfernt sie.", "SAVE": "PIN speichern", - "UNLOCK": "Entsperren" + "UNLOCK": "Entsperren", + "CONFIRM_TITLE": "Kindersicherungs-PIN bestätigen", + "CONFIRM_DESCRIPTION": "Gib die aktuelle PIN ein, um diese Einstellung zu ändern." }, "LOCKED_COUNT": "Gesperrt", "LOCK_CATEGORY": "Kategorie sperren", diff --git a/apps/web/src/assets/i18n/el.json b/apps/web/src/assets/i18n/el.json index 7eccdc861..fbc81610c 100644 --- a/apps/web/src/assets/i18n/el.json +++ b/apps/web/src/assets/i18n/el.json @@ -1911,7 +1911,9 @@ "COOLDOWN": "Too many attempts. Wait 30 seconds and try again.", "NO_RECOVERY": "There is no way to recover a forgotten PIN — only resetting the app data removes it.", "SAVE": "Save PIN", - "UNLOCK": "Unlock" + "UNLOCK": "Unlock", + "CONFIRM_TITLE": "Confirm parental PIN", + "CONFIRM_DESCRIPTION": "Enter the current PIN to change this setting." }, "LOCKED_COUNT": "Locked", "LOCK_CATEGORY": "Lock category", diff --git a/apps/web/src/assets/i18n/en.json b/apps/web/src/assets/i18n/en.json index 76788ee97..48c41a1c1 100644 --- a/apps/web/src/assets/i18n/en.json +++ b/apps/web/src/assets/i18n/en.json @@ -1911,7 +1911,9 @@ "COOLDOWN": "Too many attempts. Wait 30 seconds and try again.", "NO_RECOVERY": "There is no way to recover a forgotten PIN — only resetting the app data removes it.", "SAVE": "Save PIN", - "UNLOCK": "Unlock" + "UNLOCK": "Unlock", + "CONFIRM_TITLE": "Confirm parental PIN", + "CONFIRM_DESCRIPTION": "Enter the current PIN to change this setting." }, "LOCKED_COUNT": "Locked", "LOCK_CATEGORY": "Lock category", diff --git a/apps/web/src/assets/i18n/es.json b/apps/web/src/assets/i18n/es.json index 7ea8403f6..841fe3066 100644 --- a/apps/web/src/assets/i18n/es.json +++ b/apps/web/src/assets/i18n/es.json @@ -1911,7 +1911,9 @@ "COOLDOWN": "Too many attempts. Wait 30 seconds and try again.", "NO_RECOVERY": "There is no way to recover a forgotten PIN — only resetting the app data removes it.", "SAVE": "Save PIN", - "UNLOCK": "Unlock" + "UNLOCK": "Unlock", + "CONFIRM_TITLE": "Confirm parental PIN", + "CONFIRM_DESCRIPTION": "Enter the current PIN to change this setting." }, "LOCKED_COUNT": "Locked", "LOCK_CATEGORY": "Lock category", diff --git a/apps/web/src/assets/i18n/fr.json b/apps/web/src/assets/i18n/fr.json index a52b93035..31a58083b 100644 --- a/apps/web/src/assets/i18n/fr.json +++ b/apps/web/src/assets/i18n/fr.json @@ -1911,7 +1911,9 @@ "COOLDOWN": "Too many attempts. Wait 30 seconds and try again.", "NO_RECOVERY": "There is no way to recover a forgotten PIN — only resetting the app data removes it.", "SAVE": "Save PIN", - "UNLOCK": "Unlock" + "UNLOCK": "Unlock", + "CONFIRM_TITLE": "Confirm parental PIN", + "CONFIRM_DESCRIPTION": "Enter the current PIN to change this setting." }, "LOCKED_COUNT": "Locked", "LOCK_CATEGORY": "Lock category", diff --git a/apps/web/src/assets/i18n/hu.json b/apps/web/src/assets/i18n/hu.json index 7ce57b107..6ac3de2df 100644 --- a/apps/web/src/assets/i18n/hu.json +++ b/apps/web/src/assets/i18n/hu.json @@ -1911,7 +1911,9 @@ "COOLDOWN": "Too many attempts. Wait 30 seconds and try again.", "NO_RECOVERY": "There is no way to recover a forgotten PIN — only resetting the app data removes it.", "SAVE": "Save PIN", - "UNLOCK": "Unlock" + "UNLOCK": "Unlock", + "CONFIRM_TITLE": "Confirm parental PIN", + "CONFIRM_DESCRIPTION": "Enter the current PIN to change this setting." }, "LOCKED_COUNT": "Locked", "LOCK_CATEGORY": "Lock category", diff --git a/apps/web/src/assets/i18n/it.json b/apps/web/src/assets/i18n/it.json index d6ad4b221..39ad57933 100644 --- a/apps/web/src/assets/i18n/it.json +++ b/apps/web/src/assets/i18n/it.json @@ -1911,7 +1911,9 @@ "COOLDOWN": "Too many attempts. Wait 30 seconds and try again.", "NO_RECOVERY": "There is no way to recover a forgotten PIN — only resetting the app data removes it.", "SAVE": "Save PIN", - "UNLOCK": "Unlock" + "UNLOCK": "Unlock", + "CONFIRM_TITLE": "Confirm parental PIN", + "CONFIRM_DESCRIPTION": "Enter the current PIN to change this setting." }, "LOCKED_COUNT": "Locked", "LOCK_CATEGORY": "Lock category", diff --git a/apps/web/src/assets/i18n/ja.json b/apps/web/src/assets/i18n/ja.json index 1b798c006..5e9c9b29a 100644 --- a/apps/web/src/assets/i18n/ja.json +++ b/apps/web/src/assets/i18n/ja.json @@ -1911,7 +1911,9 @@ "COOLDOWN": "Too many attempts. Wait 30 seconds and try again.", "NO_RECOVERY": "There is no way to recover a forgotten PIN — only resetting the app data removes it.", "SAVE": "Save PIN", - "UNLOCK": "Unlock" + "UNLOCK": "Unlock", + "CONFIRM_TITLE": "Confirm parental PIN", + "CONFIRM_DESCRIPTION": "Enter the current PIN to change this setting." }, "LOCKED_COUNT": "Locked", "LOCK_CATEGORY": "Lock category", diff --git a/apps/web/src/assets/i18n/ko.json b/apps/web/src/assets/i18n/ko.json index d29a797ff..bbb1fe503 100644 --- a/apps/web/src/assets/i18n/ko.json +++ b/apps/web/src/assets/i18n/ko.json @@ -1911,7 +1911,9 @@ "COOLDOWN": "Too many attempts. Wait 30 seconds and try again.", "NO_RECOVERY": "There is no way to recover a forgotten PIN — only resetting the app data removes it.", "SAVE": "Save PIN", - "UNLOCK": "Unlock" + "UNLOCK": "Unlock", + "CONFIRM_TITLE": "Confirm parental PIN", + "CONFIRM_DESCRIPTION": "Enter the current PIN to change this setting." }, "LOCKED_COUNT": "Locked", "LOCK_CATEGORY": "Lock category", diff --git a/apps/web/src/assets/i18n/nl.json b/apps/web/src/assets/i18n/nl.json index d60ce980d..65a400af7 100644 --- a/apps/web/src/assets/i18n/nl.json +++ b/apps/web/src/assets/i18n/nl.json @@ -1911,7 +1911,9 @@ "COOLDOWN": "Too many attempts. Wait 30 seconds and try again.", "NO_RECOVERY": "There is no way to recover a forgotten PIN — only resetting the app data removes it.", "SAVE": "Save PIN", - "UNLOCK": "Unlock" + "UNLOCK": "Unlock", + "CONFIRM_TITLE": "Confirm parental PIN", + "CONFIRM_DESCRIPTION": "Enter the current PIN to change this setting." }, "LOCKED_COUNT": "Locked", "LOCK_CATEGORY": "Lock category", diff --git a/apps/web/src/assets/i18n/pl.json b/apps/web/src/assets/i18n/pl.json index 5d4e48b69..fea3cda97 100644 --- a/apps/web/src/assets/i18n/pl.json +++ b/apps/web/src/assets/i18n/pl.json @@ -1911,7 +1911,9 @@ "COOLDOWN": "Too many attempts. Wait 30 seconds and try again.", "NO_RECOVERY": "There is no way to recover a forgotten PIN — only resetting the app data removes it.", "SAVE": "Save PIN", - "UNLOCK": "Unlock" + "UNLOCK": "Unlock", + "CONFIRM_TITLE": "Confirm parental PIN", + "CONFIRM_DESCRIPTION": "Enter the current PIN to change this setting." }, "LOCKED_COUNT": "Locked", "LOCK_CATEGORY": "Lock category", diff --git a/apps/web/src/assets/i18n/pt.json b/apps/web/src/assets/i18n/pt.json index 74d2f2aee..1d30d7e82 100644 --- a/apps/web/src/assets/i18n/pt.json +++ b/apps/web/src/assets/i18n/pt.json @@ -1911,7 +1911,9 @@ "COOLDOWN": "Too many attempts. Wait 30 seconds and try again.", "NO_RECOVERY": "There is no way to recover a forgotten PIN — only resetting the app data removes it.", "SAVE": "Save PIN", - "UNLOCK": "Unlock" + "UNLOCK": "Unlock", + "CONFIRM_TITLE": "Confirm parental PIN", + "CONFIRM_DESCRIPTION": "Enter the current PIN to change this setting." }, "LOCKED_COUNT": "Locked", "LOCK_CATEGORY": "Lock category", diff --git a/apps/web/src/assets/i18n/ru.json b/apps/web/src/assets/i18n/ru.json index c73df5efc..988a7f428 100644 --- a/apps/web/src/assets/i18n/ru.json +++ b/apps/web/src/assets/i18n/ru.json @@ -1911,7 +1911,9 @@ "COOLDOWN": "Слишком много попыток. Подождите 30 секунд.", "NO_RECOVERY": "Забытый PIN восстановить нельзя — его удаляет только сброс данных приложения.", "SAVE": "Сохранить PIN", - "UNLOCK": "Разблокировать" + "UNLOCK": "Разблокировать", + "CONFIRM_TITLE": "Подтвердите родительский PIN", + "CONFIRM_DESCRIPTION": "Введите текущий PIN, чтобы изменить эту настройку." }, "LOCKED_COUNT": "Заблокировано", "LOCK_CATEGORY": "Заблокировать категорию", diff --git a/apps/web/src/assets/i18n/tr.json b/apps/web/src/assets/i18n/tr.json index 4346a7585..56dd522a6 100644 --- a/apps/web/src/assets/i18n/tr.json +++ b/apps/web/src/assets/i18n/tr.json @@ -1911,7 +1911,9 @@ "COOLDOWN": "Too many attempts. Wait 30 seconds and try again.", "NO_RECOVERY": "There is no way to recover a forgotten PIN — only resetting the app data removes it.", "SAVE": "Save PIN", - "UNLOCK": "Unlock" + "UNLOCK": "Unlock", + "CONFIRM_TITLE": "Confirm parental PIN", + "CONFIRM_DESCRIPTION": "Enter the current PIN to change this setting." }, "LOCKED_COUNT": "Locked", "LOCK_CATEGORY": "Lock category", diff --git a/apps/web/src/assets/i18n/zh.json b/apps/web/src/assets/i18n/zh.json index 2140bfa73..727b5203c 100644 --- a/apps/web/src/assets/i18n/zh.json +++ b/apps/web/src/assets/i18n/zh.json @@ -1911,7 +1911,9 @@ "COOLDOWN": "Too many attempts. Wait 30 seconds and try again.", "NO_RECOVERY": "There is no way to recover a forgotten PIN — only resetting the app data removes it.", "SAVE": "Save PIN", - "UNLOCK": "Unlock" + "UNLOCK": "Unlock", + "CONFIRM_TITLE": "Confirm parental PIN", + "CONFIRM_DESCRIPTION": "Enter the current PIN to change this setting." }, "LOCKED_COUNT": "Locked", "LOCK_CATEGORY": "Lock category", diff --git a/apps/web/src/assets/i18n/zhtw.json b/apps/web/src/assets/i18n/zhtw.json index 6edb85f7d..cce2242dd 100644 --- a/apps/web/src/assets/i18n/zhtw.json +++ b/apps/web/src/assets/i18n/zhtw.json @@ -1911,7 +1911,9 @@ "COOLDOWN": "Too many attempts. Wait 30 seconds and try again.", "NO_RECOVERY": "There is no way to recover a forgotten PIN — only resetting the app data removes it.", "SAVE": "Save PIN", - "UNLOCK": "Unlock" + "UNLOCK": "Unlock", + "CONFIRM_TITLE": "Confirm parental PIN", + "CONFIRM_DESCRIPTION": "Enter the current PIN to change this setting." }, "LOCKED_COUNT": "Locked", "LOCK_CATEGORY": "Lock category", diff --git a/docs/architecture/category-management.md b/docs/architecture/category-management.md index 8a863b44d..2e75cdf95 100644 --- a/docs/architecture/category-management.md +++ b/docs/architecture/category-management.md @@ -144,6 +144,15 @@ Otherwise the app may still be using an older `dist/apps/electron-backend/workers/database.worker.js` bundle even though the TypeScript source has already been updated. +### Parental Lock Toggles + +While the parental lock is enabled (`Settings → Parental lock`), the same +dialog renders a lock toggle per row and the whole dialog opens only after the +PIN. Locks are a separate concept from `hidden`: they live in the renderer's +lock store keyed by provider category id and are mirrored into +`categories.locked` through `DB_SET_CATEGORY_LOCKS`, so a refresh keeps them +the same way it keeps `hidden`. Contract: [parental lock](parental-lock.md). + ## Files Changed ``` diff --git a/docs/architecture/parental-lock.md b/docs/architecture/parental-lock.md index 7dbde4dca..165387ec2 100644 --- a/docs/architecture/parental-lock.md +++ b/docs/architecture/parental-lock.md @@ -11,8 +11,11 @@ follow in a second PR. ## Product rules -- Off by default. Enabling asks for a new PIN (4–8 digits); disabling asks - for the current PIN and keeps the locks for a later re-enable. +- Off by default. Enabling asks for a new PIN (4–8 digits); disabling and + changing the PIN always verify the current PIN against the stored hash, + even while the session is unlocked (`verifyCurrentPin()`, never the + `requestUnlock()` short-cut) — a parent leaving the app unlocked must not + leave the lock removable. Disabling keeps the locks for a later re-enable. - The unit of locking is the category. Nothing is blurred or greyed: a withheld category and its rows are absent. The only trace is one "N locked · Enter PIN to show" row at the bottom of a portal's category @@ -102,8 +105,13 @@ locked default. dialog. `itvFullChannelList` and the content loader drop rows whose `tv_genre_id` / `category_id` is withheld, the content resource's params carry `parentalLockVersion` so lock/unlock re-fires it, and a stale - response is discarded when the version moved. A selected withheld genre is - cleared and the section root is navigated to. + response is discarded when the version moved. Paging is judged on the RAW + page: withheld ids the list has not seen before count as progress (so a + portal page made only of locked rows does not end the list), a page that + is entirely withheld requests the next page by itself, and the VOD/series + `totalCount` is reduced by the withheld ids seen so the grid stops asking + once every visible row is in. A selected withheld genre is cleared and the + section root is navigated to. - **M3U:** `ChannelListContainerComponent` derives one `visibleChannelList` (all views, favorites, recents, the fullscreen panel and numeric zapping read from it); locked groups are absent from the groups rail. A playing @@ -120,7 +128,8 @@ locked default. dialog (rendered only while the feature is on), plus a new Stalker `StalkerCategoryLockDialogComponent` reached from a lock button above the categories rail; it offers "Lock adult (18+)" for genres the portal flags - `censored`. Opening any of these while locked asks for the PIN first. + `censored`. All three list the locked names and can rewrite the locks, so + each opens only after `requestUnlock()` succeeds. - Header lock/unlock button and the `parental-lock-now` / `parental-unlock` palette commands. diff --git a/docs/architecture/workspace-shell.md b/docs/architecture/workspace-shell.md index bb7384886..efb6add5c 100644 --- a/docs/architecture/workspace-shell.md +++ b/docs/architecture/workspace-shell.md @@ -52,7 +52,10 @@ Current workspace routes: 8. `/workspace/search` 9. `/workspace/downloads` 10. `/workspace/settings/:section` (`/workspace/settings` redirects to - `general`; the settings context panel links each section page) + `general`; the settings context panel links each section page; sections: + `general`, `playback`, `epg`, `dashboard`, `remote-control`, `tmdb`, + `parental` — see [parental lock](parental-lock.md) — `backup`, `reset`, + `about`) 11. `/workspace/xtreams/:id/...` 12. `/workspace/stalker/:id/...` diff --git a/docs/maintenance/agent-context-map.md b/docs/maintenance/agent-context-map.md index e6ccb1076..1f8ee5456 100644 --- a/docs/maintenance/agent-context-map.md +++ b/docs/maintenance/agent-context-map.md @@ -38,6 +38,7 @@ are not prerequisites for reading repository contracts. | Live panels, keyboard focus, grid/layout conventions; shared UI and portal views | [UI guidelines](../architecture/iptvnator-ui-guidelines.md), [detail navigation](../architecture/portal-detail-navigation.md) | [UI design](../../.codex/skills/iptvnator-ui-design/SKILL.md), [theme/style](../../.codex/skills/iptvnator-theme-style/SKILL.md) | | Workspace routes, title bar, switcher, collections and dashboard; `libs/workspace` | [Workspace shell](../architecture/workspace-shell.md), [dashboard](../architecture/workspace-dashboard.md), [collection/detail navigation](../architecture/portal-detail-navigation.md) | UI/theme skills for visible changes | | Remote control, playback queue, channel return and shortcuts; `libs/ui/remote-control`, `apps/remote-control-web` | [Remote control](../architecture/remote-control.md) | Provider skill when queue ownership changes | +| Parental lock: PIN, per-category locks, worker-side filtering; `libs/services/src/lib/parental-lock`, category/group dialogs, `apps/electron-backend/src/app/database/parental-lock-state.ts` | [Parental lock](../architecture/parental-lock.md), affected provider contract | Read the affected provider skill | | Downloads, offline details, catch-up and file availability; `libs/portal/downloads` | [Download manager](../architecture/download-manager.md), provider contract for URL resolution | Read the affected provider skill | | VOD source discovery, factual metadata and failover; `libs/portal/shared/data-access` | [VOD multi-source](../architecture/vod-multi-source.md) | [Xtream](../../.codex/skills/xtream-electron/SKILL.md) | | TMDB enrichment, artwork, actors and recommendations; `libs/services/src/lib/tmdb` | [TMDB contracts](../architecture/tmdb-metadata-enrichment.md), [dashboard](../architecture/workspace-dashboard.md) | UI skill for rendering changes | diff --git a/libs/portal/stalker/data-access/src/lib/stores/features/with-stalker-content.feature.spec.ts b/libs/portal/stalker/data-access/src/lib/stores/features/with-stalker-content.feature.spec.ts index 23ffbb4f1..d798b2f2e 100644 --- a/libs/portal/stalker/data-access/src/lib/stores/features/with-stalker-content.feature.spec.ts +++ b/libs/portal/stalker/data-access/src/lib/stores/features/with-stalker-content.feature.spec.ts @@ -2,7 +2,7 @@ import { signal } from '@angular/core'; import { TestBed } from '@angular/core/testing'; import { patchState, signalStore, withMethods, withState } from '@ngrx/signals'; import { TranslateService } from '@ngx-translate/core'; -import { DataService } from '@iptvnator/services'; +import { DataService, ParentalLockService } from '@iptvnator/services'; import { CONNECTIVITY_GUARD_RESET, PlaylistMeta, @@ -138,16 +138,27 @@ describe('withStalkerContent failure states', () => { let dataService: { sendIpcEvent: jest.Mock, unknown[]>; }; + let parentalLock: { + active: jest.Mock; + version: ReturnType>; + lockedStalkerIds: jest.Mock; + }; beforeEach(() => { dataService = { sendIpcEvent: jest.fn(), }; + parentalLock = { + active: jest.fn(() => false), + version: signal(0), + lockedStalkerIds: jest.fn(() => []), + }; TestBed.configureTestingModule({ providers: [ TestContentStore, { provide: DataService, useValue: dataService }, + { provide: ParentalLockService, useValue: parentalLock }, { provide: StalkerItvCacheService, useValue: createItvCacheMock(), @@ -590,6 +601,62 @@ describe('withStalkerContent failure states', () => { expect(store.totalCount()).toBe(2); }); + it('pages past a VOD page made only of parental-locked rows', async () => { + parentalLock.active.mockReturnValue(true); + parentalLock.lockedStalkerIds.mockReturnValue(['9']); + dataService.sendIpcEvent.mockImplementation( + (_event: unknown, payload: { params?: { p?: number } }) => { + const page = Number(payload.params?.p ?? 1); + // Page 1: one visible film. Page 2: locked rows only. Page 3: + // the visible film paging must still reach. + const data = + page === 1 + ? [{ id: 'movie-1', name: 'One', category_id: '5' }] + : page === 2 + ? [ + { id: 'adult-1', name: 'A', category_id: '9' }, + { id: 'adult-2', name: 'B', category_id: '9' }, + ] + : [ + { + id: 'movie-3', + name: 'Three', + category_id: '5', + }, + ]; + return Promise.resolve({ js: { data, total_items: 4 } }); + } + ); + + store.setSelectedContentType('vod'); + store.setCategories('vod', [ + { category_id: '5', category_name: 'Action' }, + { category_id: '9', category_name: 'Adult' }, + ]); + store.setSelectedCategory('*'); + store.setCurrentPlaylist(PLAYLIST); + void store.isPaginatedContentLoading(); + + await waitForCondition(() => store.getPaginatedContent().length === 1); + expect(store.hasMoreContent()).toBe(true); + + // The append lands on the fully withheld page; the loader must ask + // for the next one by itself instead of ending the list. + store.setPage(1); + await waitForCondition( + () => store.getPaginatedContent().length === 2, + 60 + ); + + expect(store.getPaginatedContent().map((item) => item.id)).toEqual([ + 'movie-1', + 'movie-3', + ]); + // Both withheld ids are subtracted from the portal's total. + expect(store.totalCount()).toBe(2); + expect(store.hasMoreContent()).toBe(false); + }); + it('keeps accumulated pages when an append fails and retries the same page', async () => { let failPageTwo = true; dataService.sendIpcEvent.mockImplementation( diff --git a/libs/portal/stalker/data-access/src/lib/stores/features/with-stalker-content.feature.ts b/libs/portal/stalker/data-access/src/lib/stores/features/with-stalker-content.feature.ts index 9bb39a3ee..5fc3e8d4b 100644 --- a/libs/portal/stalker/data-access/src/lib/stores/features/with-stalker-content.feature.ts +++ b/libs/portal/stalker/data-access/src/lib/stores/features/with-stalker-content.feature.ts @@ -284,6 +284,12 @@ export function withStalkerContent() { }; let lastLivePageKey = ''; + // Withheld (parental-locked) row ids seen while accumulating + // the current list. A page that adds only withheld ids still + // counts as progress, so paging continues past it; a page + // adding nothing new — withheld or not — is a stalled portal. + let withheldSeenKey = ''; + const withheldSeenIds = new Set(); return { categoryResource: resource({ params: () => ({ @@ -648,16 +654,70 @@ export function withStalkerContent() { return []; } + const rawItems = response.js.data.map((item) => + toStalkerContentItem( + item, + playlist.portalUrl ?? '' + ) + ); const newItems = withoutWithheldStalkerItems( - response.js.data.map((item) => - toStalkerContentItem( - item, - playlist.portalUrl ?? '' - ) - ), + rawItems, params.contentType, withheldCategoryIds ); + const listKey = JSON.stringify([ + paramsPlaylistKey, + params.contentType, + params.category, + params.search, + ]); + if ( + params.pageIndex === 1 || + withheldSeenKey !== listKey + ) { + withheldSeenKey = listKey; + withheldSeenIds.clear(); + } + let newWithheldCount = 0; + if (newItems.length < rawItems.length) { + const kept = new Set(newItems); + for (const item of rawItems) { + if (kept.has(item)) { + continue; + } + const id = String(item.id ?? ''); + if (!withheldSeenIds.has(id)) { + withheldSeenIds.add(id); + newWithheldCount += 1; + } + } + } + // A page made only of withheld rows would + // leave the list unchanged; request the next + // one so unlocked rows further on still load. + const skipWithheldPage = (hasMore: boolean) => { + if ( + !hasMore || + newItems.length > 0 || + newWithheldCount === 0 + ) { + return; + } + queueMicrotask(() => { + if (isCurrentRequest()) { + // `page` belongs to the selection + // feature; the facade composes + // its setter ahead of this one. + ( + store as unknown as { + setPage?: ( + page: number + ) => void; + } + ).setPage?.(params.pageIndex); + } + }); + }; if ( params.contentType === 'itv' || @@ -688,6 +748,16 @@ export function withStalkerContent() { const replay = livePageKey === lastLivePageKey; lastLivePageKey = livePageKey; + const hasMoreChannels = + rawItems.length > 0 && + (params.pageIndex === 1 || + replay || + newWithheldCount > 0 || + nextChannels.length > + existingChannels.length) && + nextChannels.length + + withheldSeenIds.size < + (response.js.total_items ?? 0); patchState(store, { totalCount: response.js.total_items ?? 0, @@ -705,15 +775,9 @@ export function withStalkerContent() { }, } : { radioChannels: nextChannels }), - hasMoreChannels: - channels.length > 0 && - (params.pageIndex === 1 || - replay || - nextChannels.length > - existingChannels.length) && - nextChannels.length < - (response.js.total_items ?? 0), + hasMoreChannels, }); + skipWithheldPage(hasMoreChannels); } else { // VOD/series pages accumulate into one // continuous list for the infinite-scroll @@ -737,18 +801,30 @@ export function withStalkerContent() { // end on every scroll crossing. const appendStalled = params.pageIndex > 1 && + newWithheldCount === 0 && nextContent.length <= previousContent.length; + // Withheld rows count against the portal's + // total, or the grid would keep asking for + // pages the lock will never let it show. + const totalCount = appendStalled + ? nextContent.length + : Math.max( + 0, + (response.js.total_items ?? 0) - + withheldSeenIds.size + ); patchState(store, { - totalCount: appendStalled - ? nextContent.length - : (response.js.total_items ?? 0), + totalCount, paginatedContent: nextContent, contentError: null, appendError: null, hasMoreChannels: false, }); + skipWithheldPage( + nextContent.length < totalCount + ); return nextContent; } diff --git a/libs/services/src/lib/parental-lock/parental-lock-bridge.ts b/libs/services/src/lib/parental-lock/parental-lock-bridge.ts new file mode 100644 index 000000000..3fa82ff6c --- /dev/null +++ b/libs/services/src/lib/parental-lock/parental-lock-bridge.ts @@ -0,0 +1,29 @@ +/** + * Electron bridge calls of the parental lock. Both are no-ops in the PWA, + * where there is no main process to inform. + */ + +/** Tells the SQLite worker (through main) whether locked rows are withheld. */ +export function syncParentalLockStateToMainProcess(active: boolean): void { + const bridge = window.electron; + if (typeof bridge?.setParentalLockState !== 'function') { + return; + } + void bridge.setParentalLockState(active).catch((error) => { + console.error('Failed to sync the parental lock state.', error); + }); +} + +/** + * Mirrors the feature switch into electron-conf so a reloaded renderer and a + * restarted worker start locked while the feature is on. + */ +export function mirrorParentalLockEnabledSetting(enabled: boolean): void { + const bridge = window.electron; + if (typeof bridge?.updateSettings !== 'function') { + return; + } + void bridge + .updateSettings({ parentalLockEnabled: enabled }) + .catch(() => undefined); +} diff --git a/libs/services/src/lib/parental-lock/parental-lock-idle-timer.spec.ts b/libs/services/src/lib/parental-lock/parental-lock-idle-timer.spec.ts index 1f55f1c2d..90d12ca99 100644 --- a/libs/services/src/lib/parental-lock/parental-lock-idle-timer.spec.ts +++ b/libs/services/src/lib/parental-lock/parental-lock-idle-timer.spec.ts @@ -33,7 +33,10 @@ function createScheduler(): FakeScheduler { return Object.assign(scheduler, { setTimeout: (callback: () => void, delay: number) => { const handle = nextHandle++; - scheduler.timers.set(handle, { at: scheduler.now + delay, callback }); + scheduler.timers.set(handle, { + at: scheduler.now + delay, + callback, + }); return handle; }, clearTimeout: (handle: number) => { @@ -43,11 +46,17 @@ function createScheduler(): FakeScheduler { } function createTarget() { - const listeners = new Map>(); + const listeners = new Map< + string, + Set + >(); return { listeners, addEventListener: jest.fn((type: string, listener) => { - listeners.set(type, (listeners.get(type) ?? new Set()).add(listener)); + listeners.set( + type, + (listeners.get(type) ?? new Set()).add(listener) + ); }), removeEventListener: jest.fn((type: string, listener) => { listeners.get(type)?.delete(listener); @@ -104,7 +113,9 @@ describe('ParentalLockIdleTimer', () => { it('defers expiry while playback is busy and fires once it stops', () => { let busy = true; - const { timer, scheduler, onExpire } = createTimer({ busy: () => busy }); + const { timer, scheduler, onExpire } = createTimer({ + busy: () => busy, + }); timer.arm(5); scheduler.advance(5 * 60_000); diff --git a/libs/services/src/lib/parental-lock/parental-lock-idle-timer.ts b/libs/services/src/lib/parental-lock/parental-lock-idle-timer.ts index 558f86839..e08fae6f2 100644 --- a/libs/services/src/lib/parental-lock/parental-lock-idle-timer.ts +++ b/libs/services/src/lib/parental-lock/parental-lock-idle-timer.ts @@ -35,7 +35,10 @@ export class ParentalLockIdleTimer { private readonly onExpire: () => void; private readonly isBusy: () => boolean; private readonly now: () => number; - private readonly schedule: (callback: () => void, delayMs: number) => number; + private readonly schedule: ( + callback: () => void, + delayMs: number + ) => number; private readonly cancel: (handle: number) => void; private readonly target: Pick< Document, @@ -94,10 +97,13 @@ export class ParentalLockIdleTimer { } private scheduleCheck(delayMs: number): void { - this.handle = this.schedule(() => { - this.handle = null; - this.check(); - }, Math.max(delayMs, 1_000)); + this.handle = this.schedule( + () => { + this.handle = null; + this.check(); + }, + Math.max(delayMs, 1_000) + ); } private check(): void { diff --git a/libs/services/src/lib/parental-lock/parental-lock.service.spec.ts b/libs/services/src/lib/parental-lock/parental-lock.service.spec.ts index 34c3f6c04..1a1f9cd0a 100644 --- a/libs/services/src/lib/parental-lock/parental-lock.service.spec.ts +++ b/libs/services/src/lib/parental-lock/parental-lock.service.spec.ts @@ -5,7 +5,10 @@ import { hashParentalLockPin } from '@iptvnator/shared/interfaces'; import { DatabaseService } from '../database-electron.service'; import { RuntimeCapabilitiesService } from '../runtime-capabilities.service'; import { SettingsStore } from '../settings-store.service'; -import { PARENTAL_LOCK_PROMPT, ParentalLockPromptRequest } from './parental-lock-prompt.token'; +import { + PARENTAL_LOCK_PROMPT, + ParentalLockPromptRequest, +} from './parental-lock-prompt.token'; import { ParentalLockStorageService } from './parental-lock-storage'; import { ParentalLockService } from './parental-lock.service'; @@ -172,7 +175,9 @@ describe('ParentalLockService', () => { expect(storage.writePinHash).toHaveBeenCalledTimes(1); expect(storage.pinHash).not.toContain('9876'); - expect(updateSettings).toHaveBeenCalledWith({ parentalLockEnabled: true }); + expect(updateSettings).toHaveBeenCalledWith({ + parentalLockEnabled: true, + }); expect(updateBridgeSettings).toHaveBeenCalledWith({ parentalLockEnabled: true, }); @@ -197,6 +202,44 @@ describe('ParentalLockService', () => { expect(service.lockedGroupTitles('p-1')).toEqual(['Adult']); }); + it('changePin and disable verify the stored PIN even while the session is unlocked', async () => { + storage.pinHash = await hashParentalLockPin('1234'); + parentalLockEnabled.set(true); + prompt.requestPin.mockImplementation( + async (request: ParentalLockPromptRequest) => + request.mode === 'set' + ? '5678' + : (await request.verify?.('1234')) + ? '1234' + : null + ); + const service = await createService(); + await service.requestUnlock(); + TestBed.flushEffects(); + expect(service.unlocked()).toBe(true); + prompt.requestPin.mockClear(); + + // A dismissed verification prompt aborts the change. + prompt.requestPin.mockResolvedValueOnce(null); + await expect(service.changePin()).resolves.toBe(false); + expect(prompt.requestPin).toHaveBeenCalledTimes(1); + expect(prompt.requestPin.mock.calls[0][0].mode).toBe('unlock'); + expect(storage.writePinHash).not.toHaveBeenCalled(); + + prompt.requestPin.mockClear(); + await expect(service.changePin()).resolves.toBe(true); + expect( + prompt.requestPin.mock.calls.map((call) => call[0].mode) + ).toEqual(['unlock', 'set']); + expect(storage.writePinHash).toHaveBeenCalledTimes(1); + + prompt.requestPin.mockClear(); + prompt.requestPin.mockResolvedValueOnce(null); + await expect(service.disable()).resolves.toBe(false); + expect(service.enabled()).toBe(true); + expect(prompt.requestPin.mock.calls[0][0].mode).toBe('unlock'); + }); + it('persists locks per portal, stamps the Xtream column and bumps the version', async () => { const service = await createService(); const versionBefore = service.version(); diff --git a/libs/services/src/lib/parental-lock/parental-lock.service.ts b/libs/services/src/lib/parental-lock/parental-lock.service.ts index f4a5e2c46..1d1376408 100644 --- a/libs/services/src/lib/parental-lock/parental-lock.service.ts +++ b/libs/services/src/lib/parental-lock/parental-lock.service.ts @@ -28,6 +28,10 @@ import { PARENTAL_LOCK_PROMPT, ParentalLockPromptRequest, } from './parental-lock-prompt.token'; +import { + mirrorParentalLockEnabledSetting, + syncParentalLockStateToMainProcess, +} from './parental-lock-bridge'; import { ParentalLockStorageService } from './parental-lock-storage'; const XTREAM_CATEGORY_TYPES: readonly ParentalLockXtreamCategoryType[] = [ @@ -80,9 +84,7 @@ export class ParentalLockService { () => this.settingsStore.parentalLockEnabled?.() === true ); /** Feature on and the PIN has been entered this session. */ - readonly unlocked = computed( - () => this.enabled() && this.unlockedState() - ); + readonly unlocked = computed(() => this.enabled() && this.unlockedState()); /** Locked categories must currently be withheld. */ readonly active = computed(() => this.enabled() && !this.unlockedState()); /** A PIN exists; enabling is only possible once this is true. */ @@ -103,9 +105,11 @@ export class ParentalLockService { } untracked(() => { this.versionState.update((value) => value + 1); - this.syncMainProcess(active); + syncParentalLockStateToMainProcess(active); if (!active) { - this.idleTimer.arm(this.enabled() ? this.relockMinutes() : 0); + this.idleTimer.arm( + this.enabled() ? this.relockMinutes() : 0 + ); } else { this.idleTimer.disarm(); } @@ -157,7 +161,10 @@ export class ParentalLockService { * accepted). Concurrent callers share one prompt. */ requestUnlock( - options: Pick = {} + options: Pick< + ParentalLockPromptRequest, + 'titleKey' | 'descriptionKey' + > = {} ): Promise { if (!this.active()) { return Promise.resolve(true); @@ -218,7 +225,7 @@ export class ParentalLockService { await this.settingsStore.updateSettings({ parentalLockEnabled: true, }); - this.mirrorEnabledSetting(true); + mirrorParentalLockEnabledSetting(true); } return true; } @@ -229,7 +236,7 @@ export class ParentalLockService { if (!this.prompt || !this.hasPin()) { return false; } - if (this.enabled() && !(await this.requestUnlock())) { + if (!(await this.verifyCurrentPin())) { return false; } const pin = await this.prompt.requestPin({ mode: 'set' }); @@ -244,17 +251,38 @@ export class ParentalLockService { if (!this.enabled()) { return true; } - if (!(await this.requestUnlock())) { + if (!(await this.verifyCurrentPin())) { return false; } await this.settingsStore.updateSettings({ parentalLockEnabled: false, }); - this.mirrorEnabledSetting(false); + mirrorParentalLockEnabledSetting(false); this.unlockedState.set(false); return true; } + /** + * Always asks for the PIN, unlocked session or not: changing the PIN or + * switching the feature off must not be possible just because a parent + * left the app unlocked. Unlike `requestUnlock()` this never short-cuts + * on `active`. + */ + private async verifyCurrentPin(): Promise { + await this.initialize(); + const hash = this.pinHash(); + if (!this.prompt || !hash) { + return false; + } + const pin = await this.prompt.requestPin({ + mode: 'unlock', + verify: (candidate) => verifyParentalLockPin(candidate, hash), + titleKey: 'PARENTAL_LOCK.PIN_DIALOG.CONFIRM_TITLE', + descriptionKey: 'PARENTAL_LOCK.PIN_DIALOG.CONFIRM_DESCRIPTION', + }); + return pin !== null; + } + async setRelockMinutes(minutes: number): Promise { await this.settingsStore.updateSettings({ parentalLockRelockMinutes: @@ -325,7 +353,8 @@ export class ParentalLockService { isM3uGroupLocked(playlistId: string, groupTitle: string): boolean { return ( - this.active() && this.lockedGroupTitles(playlistId).includes(groupTitle) + this.active() && + this.lockedGroupTitles(playlistId).includes(groupTitle) ); } @@ -450,24 +479,4 @@ export class ParentalLockService { return false; } } - - private syncMainProcess(active: boolean): void { - const bridge = window.electron; - if (typeof bridge?.setParentalLockState !== 'function') { - return; - } - void bridge.setParentalLockState(active).catch((error) => { - console.error('Failed to sync the parental lock state.', error); - }); - } - - private mirrorEnabledSetting(enabled: boolean): void { - const bridge = window.electron; - if (typeof bridge?.updateSettings !== 'function') { - return; - } - void bridge - .updateSettings({ parentalLockEnabled: enabled }) - .catch(() => undefined); - } } diff --git a/libs/ui/components/src/lib/channel-list-container/groups-view/groups-view.component.spec.ts b/libs/ui/components/src/lib/channel-list-container/groups-view/groups-view.component.spec.ts index 122cc5499..c23f1bed1 100644 --- a/libs/ui/components/src/lib/channel-list-container/groups-view/groups-view.component.spec.ts +++ b/libs/ui/components/src/lib/channel-list-container/groups-view/groups-view.component.spec.ts @@ -5,6 +5,7 @@ import { NoopAnimationsModule } from '@angular/platform-browser/animations'; import { MatDialog } from '@angular/material/dialog'; import { TranslateModule } from '@ngx-translate/core'; import { of } from 'rxjs'; +import { ParentalLockService } from '@iptvnator/services'; import { Channel } from '@iptvnator/shared/interfaces'; import { ChannelDetailsDialogComponent } from '../channel-details-dialog/channel-details-dialog.component'; import { GroupManagementDialogComponent } from './group-management-dialog/group-management-dialog.component'; @@ -46,6 +47,7 @@ describe('GroupsViewComponent', () => { let fixture: ComponentFixture; let component: GroupsViewComponent; let dialog: { open: jest.Mock }; + let parentalLock: { requestUnlock: jest.Mock }; const sportsCenter = createChannel( 'sports-1', @@ -102,6 +104,8 @@ describe('GroupsViewComponent', () => { beforeEach(async () => { localStorage.removeItem(GROUP_CHANNEL_SORT_STORAGE_KEY); + parentalLock = { requestUnlock: jest.fn().mockResolvedValue(true) }; + dialog = { open: jest.fn(), }; @@ -117,6 +121,10 @@ describe('GroupsViewComponent', () => { provide: MatDialog, useValue: dialog, }, + { + provide: ParentalLockService, + useValue: parentalLock, + }, ], }).compileComponents(); @@ -498,14 +506,14 @@ describe('GroupsViewComponent', () => { ).toEqual(['Movie Classic']); }); - it('opens the manage-groups dialog with all groups and emits updated hidden titles on save', () => { + it('opens the manage-groups dialog with all groups and emits updated hidden titles on save', async () => { const hiddenGroupTitlesChanged = jest.fn(); component.hiddenGroupTitlesChanged.subscribe(hiddenGroupTitlesChanged); dialog.open.mockReturnValue({ afterClosed: () => of({ hiddenGroupTitles: ['News', 'Sports'] }), }); - component.openGroupManagement(); + await component.openGroupManagement(); expect(dialog.open).toHaveBeenCalledWith( GroupManagementDialogComponent, @@ -529,6 +537,15 @@ describe('GroupsViewComponent', () => { ]); }); + it('keeps the manage-groups dialog closed when the parental PIN is refused', async () => { + // The dialog names every locked group and can rewrite the locks. + parentalLock.requestUnlock.mockResolvedValueOnce(false); + + await component.openGroupManagement(); + + expect(dialog.open).not.toHaveBeenCalled(); + }); + it('drops the selected-group header in compact mode but keeps the groups rail header', () => { fixture.componentRef.setInput('showHeader', false); fixture.detectChanges(); diff --git a/libs/ui/components/src/lib/channel-list-container/groups-view/groups-view.component.ts b/libs/ui/components/src/lib/channel-list-container/groups-view/groups-view.component.ts index e97c45612..cf1f2ecb9 100644 --- a/libs/ui/components/src/lib/channel-list-container/groups-view/groups-view.component.ts +++ b/libs/ui/components/src/lib/channel-list-container/groups-view/groups-view.component.ts @@ -22,7 +22,7 @@ import { MatTooltipModule } from '@angular/material/tooltip'; import { TranslatePipe } from '@ngx-translate/core'; import { EpgRuntimeBridgeService } from '@iptvnator/epg/data-access'; import { resolveChannelEpgLookupKey } from '@iptvnator/m3u-state'; -import { SettingsStore } from '@iptvnator/services'; +import { ParentalLockService, SettingsStore } from '@iptvnator/services'; import { foldSearchText, Channel, @@ -84,6 +84,7 @@ interface FilteredGroupView { }) export class GroupsViewComponent { private readonly dialog = inject(MatDialog); + private readonly parentalLock = inject(ParentalLockService); private readonly epgBridge = inject(EpgRuntimeBridgeService); private readonly settingsStore = inject(SettingsStore); readonly supportsEpgMapping = this.epgBridge.supportsEpgMapping; @@ -488,7 +489,13 @@ export class GroupsViewComponent { this.localGroupSearchTerm.set(value); } - openGroupManagement(): void { + async openGroupManagement(): Promise { + // The dialog lists every group by name, locked ones included, and can + // rewrite the locks — so it sits behind the PIN like the portal + // dialogs do. + if (!(await this.parentalLock.requestUnlock())) { + return; + } const groups = this.managementGroups() ?? this.allGroups().map(