diff --git a/docs/architecture/parental-lock.md b/docs/architecture/parental-lock.md index 1328931f1..50ef7c458 100644 --- a/docs/architecture/parental-lock.md +++ b/docs/architecture/parental-lock.md @@ -23,7 +23,9 @@ follow in a second PR. - The unlock lives in memory only. The app locks again on every restart, on "Lock now" (header button, command palette, settings), and after `Settings.parentalLockRelockMinutes` minutes without user interaction - (default 15; `0` = only on restart). Active playback of a built-in web + (default 15; `0` = only on restart). The idle timer follows the UNLOCKED + transition: armed the moment a session is unlocked — including the session + that just enabled the feature — and disarmed on lock. Active playback of a built-in web player counts as interaction, so a film never locks half way. - This is a child lock, not a security boundary: the PIN hash and the lock store sit in user-readable app data. The UI says so. There is no PIN @@ -128,8 +130,11 @@ locked default. in the URL): `StalkerSearchComponent` filters each portal page through the same withheld-genre predicate, carries `parentalLockVersion` in its resource params, judges paging progress on the raw page (a page made only - of locked rows is not the end of the results) and, on a lock flip past - page 1, drops the withheld rows and restarts from page 1. + of locked rows is not the end of the results), advances past a run of + fully withheld pages by itself (the infinite scroll stops auto-filling + after a few no-growth loads), closes an open detail whose genre is newly + withheld and, on a lock flip past page 1, drops the withheld rows and + restarts from page 1. - **Stalker:** genres are stored unfiltered; `getCategoryResource` filters them, `getAllCategoriesForSelectedType` is the raw list for the lock dialog. `itvFullChannelList` and the content loader drop rows whose diff --git a/libs/portal/stalker/feature/src/lib/stalker-search/stalker-search.component.spec.ts b/libs/portal/stalker/feature/src/lib/stalker-search/stalker-search.component.spec.ts index cfdf41a91..1b260a890 100644 --- a/libs/portal/stalker/feature/src/lib/stalker-search/stalker-search.component.spec.ts +++ b/libs/portal/stalker/feature/src/lib/stalker-search/stalker-search.component.spec.ts @@ -24,7 +24,11 @@ import { StalkerStore, } from '@iptvnator/portal/stalker/data-access'; import { createPlaybackSessionKey } from '@iptvnator/playback/util'; -import { DataService, PlaylistsService } from '@iptvnator/services'; +import { + DataService, + ParentalLockService, + PlaylistsService, +} from '@iptvnator/services'; import { CONNECTIVITY_GUARD_RESET } from '@iptvnator/shared/interfaces'; import type { ResolvedPortalPlayback } from '@iptvnator/shared/interfaces'; import { StalkerSearchComponent } from './stalker-search.component'; @@ -289,6 +293,37 @@ describe('StalkerSearchComponent playback session key', () => { describe('StalkerSearchComponent result paging', () => { let component: StalkerSearchComponent; let dataService: { sendIpcEvent: jest.Mock }; + let parentalLock: { + active: jest.Mock; + version: ReturnType>; + lockedStalkerIds: jest.Mock; + }; + let stalkerStoreMock: { + selectedItem: ReturnType>; + setSelectedContentType: jest.Mock; + setSelectedItem: jest.Mock; + addToFavorites: jest.Mock; + removeFromFavorites: jest.Mock; + resolveVodPlayback: jest.Mock; + }; + + async function flush(): Promise { + TestBed.flushEffects(); + await Promise.resolve(); + await Promise.resolve(); + TestBed.flushEffects(); + await Promise.resolve(); + } + + async function waitFor(predicate: () => boolean, attempts = 40) { + for (let index = 0; index < attempts; index += 1) { + if (predicate()) { + return; + } + await flush(); + } + throw new Error('Timed out waiting for the search resource'); + } const activePlaylist = signal({ _id: 'playlist|one', title: 'Search portal', @@ -307,6 +342,19 @@ describe('StalkerSearchComponent result paging', () => { dataService = { sendIpcEvent: jest.fn().mockResolvedValue({ success: true }), }; + parentalLock = { + active: jest.fn(() => false), + version: signal(0), + lockedStalkerIds: jest.fn(() => []), + }; + stalkerStoreMock = { + selectedItem: signal(null), + setSelectedContentType: jest.fn(), + setSelectedItem: jest.fn(), + addToFavorites: jest.fn(), + removeFromFavorites: jest.fn(), + resolveVodPlayback: jest.fn(), + }; activePlaylist.set({ _id: 'playlist|one', title: 'Search portal', @@ -336,17 +384,8 @@ describe('StalkerSearchComponent result paging', () => { provide: PlaylistsService, useValue: { getPortalFavorites: () => of([]) }, }, - { - provide: StalkerStore, - useValue: { - selectedItem: signal(null), - setSelectedContentType: jest.fn(), - setSelectedItem: jest.fn(), - addToFavorites: jest.fn(), - removeFromFavorites: jest.fn(), - resolveVodPlayback: jest.fn(), - }, - }, + { provide: StalkerStore, useValue: stalkerStoreMock }, + { provide: ParentalLockService, useValue: parentalLock }, { provide: StalkerSessionService, useValue: {} }, { provide: StalkerPortalRepairService, useValue: {} }, { @@ -403,6 +442,38 @@ describe('StalkerSearchComponent result paging', () => { expect(component.searchHasMore()).toBe(false); }); + it('advances past a page made only of parental-locked rows, but not past a repeated one', async () => { + component.applySearchPageSuccess(1, searchItems('page1', 3), 10); + const pageBefore = component.searchPage(); + + // Locked rows the list had not seen: schedule the next page. + component.advancePastWithheldPage(pageBefore, 0, 2, () => true); + await flushMicrotasks(); + expect(component.searchPage()).toBe(pageBefore + 1); + + // A page with visible rows, or one adding no new withheld ids, or a + // request that is no longer current: stay put. + component.advancePastWithheldPage(pageBefore + 1, 1, 2, () => true); + component.advancePastWithheldPage(pageBefore + 1, 0, 0, () => true); + component.advancePastWithheldPage(pageBefore + 1, 0, 2, () => false); + await flushMicrotasks(); + expect(component.searchPage()).toBe(pageBefore + 1); + }); + + it('closes an open detail whose genre became withheld on relock', () => { + component.selectItem({ id: 'adult-9', name: 'A', category_id: '9' }); + expect(component.itemDetails()).not.toBeNull(); + + // Another genre locked: the detail stays. + component.closeWithheldDetail(new Set(['5'])); + expect(component.itemDetails()).not.toBeNull(); + + component.closeWithheldDetail(new Set(['9'])); + expect(component.itemDetails()).toBeNull(); + expect(component.vodDetailsItem()).toBeNull(); + expect(stalkerStoreMock.setSelectedItem).toHaveBeenLastCalledWith(null); + }); + it('keeps paging past a page whose rows were all withheld by the parental lock', () => { component.applySearchPageSuccess(1, searchItems('page1', 3), 10); expect(component.searchHasMore()).toBe(true); diff --git a/libs/portal/stalker/feature/src/lib/stalker-search/stalker-search.component.ts b/libs/portal/stalker/feature/src/lib/stalker-search/stalker-search.component.ts index de7b463da..576762fd7 100644 --- a/libs/portal/stalker/feature/src/lib/stalker-search/stalker-search.component.ts +++ b/libs/portal/stalker/feature/src/lib/stalker-search/stalker-search.component.ts @@ -125,6 +125,13 @@ export class StalkerSearchComponent { private readonly parentalLock = inject(ParentalLockService); /** Lock version the accumulated results were built under. */ private searchResultsLockVersion: number | null = null; + /** + * Withheld row ids seen for the current search identity. A page adding + * only new withheld ids is still progress and is skipped automatically; + * a page adding nothing new is the end of the results. + */ + private searchWithheldKey = ''; + private readonly searchWithheldIds = new Set(); private readonly playlistContext = inject(PlaylistContextFacade); private readonly playlistService = inject(PlaylistsService); readonly externalPlayback = inject(PORTAL_EXTERNAL_PLAYBACK); @@ -279,15 +286,19 @@ export class StalkerSearchComponent { ) ) : new Set(); - if ( + const lockVersionChanged = this.searchResultsLockVersion !== null && - this.searchResultsLockVersion !== params.parentalLockVersion && - params.page > 1 - ) { + this.searchResultsLockVersion !== params.parentalLockVersion; + this.searchResultsLockVersion = params.parentalLockVersion; + if (lockVersionChanged) { + // A relock must also close an open detail of a genre that is + // withheld now; the list alone hiding it is not enough. + this.closeWithheldDetail(withheldCategoryIds); + } + if (lockVersionChanged && params.page > 1) { // A lock flip past page 1: drop the withheld rows on screen // and rebuild from page 1 rather than appending to pages // accumulated under the old lock state. - this.searchResultsLockVersion = params.parentalLockVersion; const retained = withoutWithheldStalkerItems( this.accumulatedSearchResults(), contentType, @@ -297,7 +308,16 @@ export class StalkerSearchComponent { this.searchPage.set(1); return retained; } - this.searchResultsLockVersion = params.parentalLockVersion; + const withheldKey = JSON.stringify([ + params.playlistId, + contentType, + params.search, + params.parentalLockVersion, + ]); + if (params.page === 1 || this.searchWithheldKey !== withheldKey) { + this.searchWithheldKey = withheldKey; + this.searchWithheldIds.clear(); + } // Mirror the catalog request shape: many Ministra portals // return an empty list for get_ordered_list without the @@ -348,19 +368,41 @@ export class StalkerSearchComponent { contentType, withheldCategoryIds ); + let newWithheldCount = 0; + if (items.length < rawItems.length) { + const kept = new Set(items); + for (const item of rawItems) { + const id = String(item.id ?? ''); + if ( + !kept.has(item) && + !this.searchWithheldIds.has(id) + ) { + this.searchWithheldIds.add(id); + newWithheldCount += 1; + } + } + } if (!isCurrent()) { return items; } - return this.applySearchPageSuccess( + const merged = this.applySearchPageSuccess( params.page, items, response.js?.total_items, // A page made only of withheld rows still is a page the // portal served; judge progress on what it sent. - rawItems.length > 0 + rawItems.length > 0 && + (items.length > 0 || newWithheldCount > 0) ); + this.advancePastWithheldPage( + params.page, + items.length, + newWithheldCount, + isCurrent + ); + return merged; } catch (error) { this.logger.warn('Stalker search page failed', { page: params.page, @@ -655,6 +697,58 @@ export class StalkerSearchComponent { }); } + /** + * The infinite scroll gives up after a few loads that add no height, so + * a run of pages made only of parental-locked rows must advance by + * itself until a visible row (or the real end) is reached. Only a page + * that added withheld ids not seen before counts — a stalled portal + * repeating the same locked rows must still end the loop. + */ + advancePastWithheldPage( + page: number, + visibleCount: number, + newWithheldCount: number, + isCurrent: () => boolean + ): void { + if ( + visibleCount > 0 || + newWithheldCount === 0 || + !this.searchHasMore() + ) { + return; + } + queueMicrotask(() => { + if (isCurrent()) { + this.searchPage.set(page + 1); + } + }); + } + + /** + * Closes the open detail when its genre is withheld by the parental + * lock (Lock now, idle relock): the title, its playback actions and the + * store's selected item must not outlive the list row. + */ + closeWithheldDetail(withheldCategoryIds: ReadonlySet): void { + const details = this.itemDetails(); + const categoryId = details?.category_id; + if ( + !details || + categoryId === undefined || + categoryId === null || + !withheldCategoryIds.has(String(categoryId)) + ) { + return; + } + const cleared = clearStalkerDetailViewState(); + this.itemDetails.set(cleared.itemDetails); + this.vodDetailsItem.set(cleared.vodDetailsItem); + this.isSelectedVodFavorite.set(false); + this.selectedVodPosition.set(null); + this.closeInlinePlayer(); + this.stalkerStore.setSelectedItem(null); + } + /** Leave the search page (e.g. back to the actor page that opened it) */ goBack(): void { this.location.back(); 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 5c6470b32..afab69e54 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 @@ -229,6 +229,28 @@ describe('ParentalLockService', () => { expect(service.lockedGroupTitles('p-1')).toEqual(['Adult']); }); + it('arms the idle timer for the session that just enabled the feature', async () => { + jest.useFakeTimers(); + try { + prompt.requestPin.mockResolvedValue('9876'); + parentalLockRelockMinutes.set(5); + const service = await createService(); + + await expect(service.setupPin()).resolves.toBe(true); + TestBed.flushEffects(); + expect(service.unlocked()).toBe(true); + + // `active` never changed (unlocked before and after enabling), so + // the timer must be armed by the unlocked transition itself. + jest.advanceTimersByTime(5 * 60_000 + 1_500); + TestBed.flushEffects(); + expect(service.unlocked()).toBe(false); + expect(service.active()).toBe(true); + } finally { + jest.useRealTimers(); + } + }); + it('changePin and disable verify the stored PIN even while the session is unlocked', async () => { storage.pinHash = await hashParentalLockPin('1234'); parentalLockEnabled.set(true); 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 9a3616fde..62658b1dd 100644 --- a/libs/services/src/lib/parental-lock/parental-lock.service.ts +++ b/libs/services/src/lib/parental-lock/parental-lock.service.ts @@ -106,20 +106,19 @@ export class ParentalLockService { untracked(() => { this.versionState.update((value) => value + 1); syncParentalLockStateToMainProcess(active); - if (!active) { - this.idleTimer.arm( - this.enabled() ? this.relockMinutes() : 0 - ); - } else { - this.idleTimer.disarm(); - } }); }); + // The idle timer follows the UNLOCKED transition, not `active`: + // enabling the feature from an unlocked session (setupPin) never + // changes `active`, yet that session must still lock itself later. effect(() => { + const unlocked = this.unlocked(); const minutes = this.relockMinutes(); untracked(() => { - if (this.unlocked()) { + if (unlocked) { this.idleTimer.arm(minutes); + } else { + this.idleTimer.disarm(); } }); });