From 7bd945e4fb9b1e9f3821c73bdf520f04f984500c Mon Sep 17 00:00:00 2001 From: 4gray Date: Fri, 25 Sep 2026 23:19:23 +0200 Subject: [PATCH] fix(settings): cover PWA cold navigation, Stalker search and the PWA lock editor - The Xtream detail guard hydrates the PWA session cache before judging an item on a cold navigation and fails closed when the catalog cannot place the item. - The dedicated Stalker search route filters withheld genres, re-fires on lock changes, judges paging on the raw page and restarts from page 1 on a lock flip. - The Xtream category dialog loads its lock candidates through the capability-selected data source; the PWA source now lists its raw categories with lock flags, so locks can be configured there too. Co-Authored-By: Claude Fable 5.1 --- docs/architecture/parental-lock.md | 17 ++++- .../stalker-search.component.spec.ts | 15 +++++ .../stalker-search.component.ts | 62 +++++++++++++++++-- .../electron-xtream-data-source.spec.ts | 1 + .../data-sources/pwa-xtream-data-source.ts | 48 ++++++++++++-- .../xtream-data-source.interface.ts | 2 + .../category-management-dialog.component.html | 16 ++--- ...tegory-management-dialog.component.spec.ts | 21 +++++-- .../category-management-dialog.component.ts | 15 ++++- .../lib/parental-lock-category.guard.spec.ts | 42 ++++++++++++- .../src/lib/parental-lock-category.guard.ts | 30 ++++++++- 11 files changed, 239 insertions(+), 30 deletions(-) diff --git a/docs/architecture/parental-lock.md b/docs/architecture/parental-lock.md index 8e679126d..1328931f1 100644 --- a/docs/architecture/parental-lock.md +++ b/docs/architecture/parental-lock.md @@ -118,9 +118,18 @@ locked default. by URL — bookmark, typed address, stale link — and redirect to the section root on refusal. Detail routes also resolve the ITEM's own category through `getContentByXtreamId`, so a locked movie paired with an unlocked category - id in the URL is still refused. Electron routes carry SQLite row ids, so + id in the URL is still refused; on a cold PWA navigation the guard + hydrates the session cache first (`getPlaylist` + `getContent`), and an + item the catalog cannot place fails closed (PIN prompt) rather than + passing as unlocked. Electron routes carry SQLite row ids, so the Xtream guard maps them through the unfiltered category read; Stalker routes already carry the genre id. +- **Stalker search route** (`/workspace/stalker/:id/search`, no category + 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. - **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 @@ -153,7 +162,11 @@ locked default. `StalkerCategoryLockDialogComponent` reached from a lock button above the categories rail; it offers "Lock adult (18+)" for genres the portal flags `censored`. All three list the locked names and can rewrite the locks, so - each opens only after `requestUnlock()` succeeds. + each opens only after `requestUnlock()` succeeds. The Xtream dialog loads + its candidates through the capability-selected data source + (`IXtreamDataSource.getAllCategories`, which the PWA source answers from + its session cache or the API), so PWA users can set locks too; the + hide/show checkboxes remain Electron-only. - Header lock/unlock button and the `parental-lock-now` / `parental-unlock` palette commands. 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 70b02ab47..cfdf41a91 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 @@ -403,6 +403,21 @@ describe('StalkerSearchComponent result paging', () => { expect(component.searchHasMore()).toBe(false); }); + 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); + + // The portal sent rows, every one of them locked: no visible growth, + // but not the end of the results either. + component.applySearchPageSuccess(2, [], 10, true); + expect(component.searchResults()).toHaveLength(3); + expect(component.searchHasMore()).toBe(true); + + // An actually empty page still ends it. + component.applySearchPageSuccess(3, [], 10, false); + expect(component.searchHasMore()).toBe(false); + }); + it('stops paging when a total-backed append makes no progress', () => { 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 8cde3321d..de7b463da 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 @@ -21,9 +21,11 @@ import { executeStalkerRequest, StalkerPortalRepairService, StalkerSessionService, + withoutWithheldStalkerItems, } from '@iptvnator/portal/stalker/data-access'; import { DataService, + ParentalLockService, PlaylistsService, resetHostConnectivityGuard, } from '@iptvnator/services'; @@ -120,6 +122,9 @@ export class StalkerSearchComponent { private readonly activatedRoute = inject(ActivatedRoute); private readonly location = inject(Location); private readonly dataService = inject(DataService); + private readonly parentalLock = inject(ParentalLockService); + /** Lock version the accumulated results were built under. */ + private searchResultsLockVersion: number | null = null; private readonly playlistContext = inject(PlaylistContextFacade); private readonly playlistService = inject(PlaylistsService); readonly externalPlayback = inject(PORTAL_EXTERNAL_PLAYBACK); @@ -241,6 +246,9 @@ export class StalkerSearchComponent { page: this.searchPage(), playlistId: this.currentPlaylist()?._id ?? null, action: StalkerPortalActions.GetOrderedList, + // Lock/unlock re-fires the search: withheld rows are dropped at + // page time, so the results must be rebuilt when they change. + parentalLockVersion: this.parentalLock.version(), }), loader: async ({ params }) => { if (params.search.length < 3) { @@ -260,6 +268,36 @@ export class StalkerSearchComponent { return []; } const contentType = params.contentType; + // The dedicated search route has no category guard, so it filters + // the portal's rows itself: a locked genre's title must not reach + // the grid, its detail or playback through search. + const withheldCategoryIds = this.parentalLock.active() + ? new Set( + this.parentalLock.lockedStalkerIds( + playlist._id, + contentType + ) + ) + : new Set(); + if ( + this.searchResultsLockVersion !== null && + this.searchResultsLockVersion !== params.parentalLockVersion && + 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, + withheldCategoryIds + ); + this.accumulatedSearchResults.set(retained); + this.searchPage.set(1); + return retained; + } + this.searchResultsLockVersion = params.parentalLockVersion; // Mirror the catalog request shape: many Ministra portals // return an empty list for get_ordered_list without the @@ -301,10 +339,15 @@ export class StalkerSearchComponent { playlist, requestParams ); - const items = (response.js?.data || []).map( + const rawItems = (response.js?.data || []).map( (item: StalkerVodSource) => this.processItemUrls(item, portalUrl) ); + const items = withoutWithheldStalkerItems( + rawItems, + contentType, + withheldCategoryIds + ); if (!isCurrent()) { return items; @@ -313,7 +356,10 @@ export class StalkerSearchComponent { return this.applySearchPageSuccess( params.page, items, - response.js?.total_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 ); } catch (error) { this.logger.warn('Stalker search page failed', { @@ -343,7 +389,8 @@ export class StalkerSearchComponent { applySearchPageSuccess( page: number, items: StalkerVodSource[], - totalItems: number | undefined + totalItems: number | undefined, + pageHadRows: boolean = items.length > 0 ): StalkerVodSource[] { const previous = page === 1 ? [] : this.accumulatedSearchResults(); const merged = @@ -352,13 +399,16 @@ export class StalkerSearchComponent { // a reported total. Dedup after mid-list portal mutations can leave // the unique list permanently shorter than total_items, and a // repeated page dedupes to no growth; either way a no-progress - // append is the practical end of the results. - const madeProgress = page === 1 || merged.length > previous.length; + // append is the practical end of the results. A page whose rows were + // all withheld by the parental lock counts as progress too. + const withheldRows = pageHadRows && items.length === 0; + const madeProgress = + page === 1 || merged.length > previous.length || withheldRows; this.searchHasMore.set( madeProgress && (typeof totalItems === 'number' && totalItems >= 0 ? merged.length < totalItems - : items.length > 0) + : pageHadRows) ); this.searchAppendError.set(false); this.accumulatedSearchResults.set(merged); diff --git a/libs/portal/xtream/data-access/src/lib/data-sources/electron-xtream-data-source.spec.ts b/libs/portal/xtream/data-access/src/lib/data-sources/electron-xtream-data-source.spec.ts index cb2a4d4c0..67386d7ba 100644 --- a/libs/portal/xtream/data-access/src/lib/data-sources/electron-xtream-data-source.spec.ts +++ b/libs/portal/xtream/data-access/src/lib/data-sources/electron-xtream-data-source.spec.ts @@ -21,6 +21,7 @@ describe('ElectronXtreamDataSource (DB-first strategy)', () => { type: 'live' as const, xtream_id: 10, hidden: false, + locked: false, }; const dbContentItem = { id: 1, diff --git a/libs/portal/xtream/data-access/src/lib/data-sources/pwa-xtream-data-source.ts b/libs/portal/xtream/data-access/src/lib/data-sources/pwa-xtream-data-source.ts index 3a94887f5..eee9cbcc7 100644 --- a/libs/portal/xtream/data-access/src/lib/data-sources/pwa-xtream-data-source.ts +++ b/libs/portal/xtream/data-access/src/lib/data-sources/pwa-xtream-data-source.ts @@ -404,14 +404,54 @@ export class PwaXtreamDataSource implements IXtreamDataSource { ); } + /** + * The raw category list in the SQLite wire shape, locked ones included: + * the lock editor's candidates. Hidden/shown is not tracked in the PWA, + * so every row reports `hidden: false`. Reads the session cache and + * falls back to the API with the stored credentials on a cold session. + */ async getAllCategories( playlistId: string, type: DbCategoryType ): Promise { - void playlistId; - void type; - // PWA doesn't track hidden categories - return empty - return []; + const categoryType: CategoryType = type === 'movies' ? 'vod' : type; + const cacheKey = `${playlistId}-${categoryType}-categories`; + let categories = this.categoryCache.get(cacheKey); + if (!categories) { + const playlist = await this.getPlaylist(playlistId); + if (!playlist) { + return []; + } + categories = await this.apiService.getCategories( + { + serverUrl: playlist.serverUrl, + username: playlist.username, + password: playlist.password, + }, + categoryType + ); + this.categoryCache.set(cacheKey, categories); + } + const locked = new Set( + this.parentalLock.lockedXtreamIds(playlistId, type) + ); + const rows: XtreamCategoryFromDb[] = []; + for (const category of categories) { + const xtreamId = Number(category.category_id); + if (!Number.isFinite(xtreamId)) { + continue; + } + rows.push({ + id: xtreamId, + name: category.category_name, + playlist_id: playlistId, + type, + xtream_id: xtreamId, + hidden: false, + locked: locked.has(xtreamId), + }); + } + return rows; } async getCachedCategories( diff --git a/libs/portal/xtream/data-access/src/lib/data-sources/xtream-data-source.interface.ts b/libs/portal/xtream/data-access/src/lib/data-sources/xtream-data-source.interface.ts index 383e108a3..b7ec0e116 100644 --- a/libs/portal/xtream/data-access/src/lib/data-sources/xtream-data-source.interface.ts +++ b/libs/portal/xtream/data-access/src/lib/data-sources/xtream-data-source.interface.ts @@ -88,6 +88,8 @@ export interface XtreamCategoryFromDb { type: 'movies' | 'live' | 'series'; xtream_id: number; hidden: boolean; + /** Parental lock index; see ParentalLockService. */ + locked: boolean; } /** diff --git a/libs/portal/xtream/feature/src/lib/category-management-dialog/category-management-dialog.component.html b/libs/portal/xtream/feature/src/lib/category-management-dialog/category-management-dialog.component.html index ec238a273..9ade8e26d 100644 --- a/libs/portal/xtream/feature/src/lib/category-management-dialog/category-management-dialog.component.html +++ b/libs/portal/xtream/feature/src/lib/category-management-dialog/category-management-dialog.component.html @@ -80,13 +80,15 @@ [attr.data-category-id]="category.xtream_id" (click)="toggleCategory(category)" > - - + @if (supportsVisibility) { + + + } {{ category.name }} ({{ getItemCount(category) }}) { let fixture: ComponentFixture; let component: CategoryManagementDialogComponent; const db = { - getAllXtreamCategories: jest.fn(), updateCategoryVisibility: jest.fn(), }; + const dataSource = { + getAllCategories: jest.fn(), + }; const dialogRef = { close: jest.fn() }; const data: CategoryManagementDialogData = { playlistId: 'mock-playlist', @@ -35,7 +43,7 @@ describe('CategoryManagementDialogComponent', () => { beforeEach(async () => { jest.clearAllMocks(); - db.getAllXtreamCategories.mockResolvedValue(categories); + dataSource.getAllCategories.mockResolvedValue(categories); db.updateCategoryVisibility.mockResolvedValue(undefined); data.contentType = 'live'; await TestBed.configureTestingModule({ @@ -45,6 +53,11 @@ describe('CategoryManagementDialogComponent', () => { ], providers: [ { provide: DatabaseService, useValue: db }, + { provide: XTREAM_DATA_SOURCE, useValue: dataSource }, + { + provide: RuntimeCapabilitiesService, + useValue: { supportsXtreamSqliteDataSource: true }, + }, { provide: MatDialogRef, useValue: dialogRef }, { provide: MAT_DIALOG_DATA, useValue: data }, ], @@ -194,7 +207,7 @@ describe('CategoryManagementDialogComponent', () => { async (contentType, dbType) => { data.contentType = contentType; await component.ngOnInit(); - expect(db.getAllXtreamCategories).toHaveBeenLastCalledWith( + expect(dataSource.getAllCategories).toHaveBeenLastCalledWith( 'mock-playlist', dbType ); diff --git a/libs/portal/xtream/feature/src/lib/category-management-dialog/category-management-dialog.component.ts b/libs/portal/xtream/feature/src/lib/category-management-dialog/category-management-dialog.component.ts index daee12765..b3ffa1594 100644 --- a/libs/portal/xtream/feature/src/lib/category-management-dialog/category-management-dialog.component.ts +++ b/libs/portal/xtream/feature/src/lib/category-management-dialog/category-management-dialog.component.ts @@ -18,9 +18,11 @@ import { MatProgressSpinnerModule } from '@angular/material/progress-spinner'; import { MatSnackBar } from '@angular/material/snack-bar'; import { MatTooltipModule } from '@angular/material/tooltip'; import { TranslatePipe } from '@ngx-translate/core'; +import { XTREAM_DATA_SOURCE } from '@iptvnator/portal/xtream/data-access'; import { DatabaseService, ParentalLockService, + RuntimeCapabilitiesService, XCategoryFromDb, } from '@iptvnator/services'; import { createLogger } from '@iptvnator/portal/shared/util'; @@ -55,7 +57,14 @@ interface CategoryWithSelection extends XCategoryFromDb { }) export class CategoryManagementDialogComponent implements OnInit { private readonly dbService = inject(DatabaseService); + private readonly dataSource = inject(XTREAM_DATA_SOURCE); + private readonly runtime = inject(RuntimeCapabilitiesService); private readonly parentalLock = inject(ParentalLockService); + /** + * Hide/show is SQLite-backed and Electron-only; the PWA data source lists + * its raw categories so the lock toggles still have candidates there. + */ + readonly supportsVisibility = this.runtime.supportsXtreamSqliteDataSource; private readonly snackBar = inject(MatSnackBar); private readonly dialogRef = inject( MatDialogRef @@ -132,7 +141,7 @@ export class CategoryManagementDialogComponent implements OnInit { const expectedCategoryCount = this.data.itemCounts.size; while (true) { - const categories = await this.dbService.getAllXtreamCategories( + const categories = await this.dataSource.getAllCategories( this.data.playlistId, type ); @@ -212,10 +221,10 @@ export class CategoryManagementDialogComponent implements OnInit { .filter((c) => c.selected) .map((c) => c.id); - if (toHide.length > 0) { + if (this.supportsVisibility && toHide.length > 0) { await this.dbService.updateCategoryVisibility(toHide, true); } - if (toShow.length > 0) { + if (this.supportsVisibility && toShow.length > 0) { await this.dbService.updateCategoryVisibility(toShow, false); } diff --git a/libs/portal/xtream/feature/src/lib/parental-lock-category.guard.spec.ts b/libs/portal/xtream/feature/src/lib/parental-lock-category.guard.spec.ts index d6a6c3ea9..59cdc042d 100644 --- a/libs/portal/xtream/feature/src/lib/parental-lock-category.guard.spec.ts +++ b/libs/portal/xtream/feature/src/lib/parental-lock-category.guard.spec.ts @@ -41,7 +41,11 @@ describe('parentalLockXtreamCategoryGuard', () => { let databaseService: { getAllXtreamCategories: jest.Mock }; let runtime: { supportsXtreamSqliteDataSource: boolean }; let router: { createUrlTree: jest.Mock }; - let dataSource: { getContentByXtreamId: jest.Mock }; + let dataSource: { + getContentByXtreamId: jest.Mock; + getPlaylist: jest.Mock; + getContent: jest.Mock; + }; beforeEach(() => { parentalLock = { @@ -61,6 +65,12 @@ describe('parentalLockXtreamCategoryGuard', () => { router = { createUrlTree: jest.fn(() => ({}) as UrlTree) }; dataSource = { getContentByXtreamId: jest.fn().mockResolvedValue(null), + getPlaylist: jest.fn().mockResolvedValue({ + serverUrl: 'http://panel.example', + username: 'u', + password: 'p', + }), + getContent: jest.fn().mockResolvedValue([]), }; TestBed.configureTestingModule({ providers: [ @@ -152,6 +162,36 @@ describe('parentalLockXtreamCategoryGuard', () => { expect(router.createUrlTree).not.toHaveBeenCalled(); }); + it('hydrates the PWA session cache before judging a detail item on a cold navigation', async () => { + runtime.supportsXtreamSqliteDataSource = false; + parentalLock.isXtreamCategoryLocked.mockImplementation( + (_playlist: string, _type: string, xtreamId: number) => + xtreamId === 900 + ); + dataSource.getContentByXtreamId + .mockResolvedValueOnce(null) + .mockResolvedValueOnce({ category_id: '900' }); + + const result = await run('vod', '901', { vodId: '555' }); + + expect(dataSource.getContent).toHaveBeenCalledWith( + 'playlist-1', + { serverUrl: 'http://panel.example', username: 'u', password: 'p' }, + 'movie' + ); + expect(parentalLock.requestUnlock).toHaveBeenCalled(); + expect(result).not.toBe(true); + }); + + it('fails closed for a detail item the catalog cannot place', async () => { + dataSource.getContentByXtreamId.mockResolvedValue(null); + + const result = await run('series', '13', { serialId: '777' }); + + expect(parentalLock.requestUnlock).toHaveBeenCalled(); + expect(result).not.toBe(true); + }); + it('uses the route id as the provider id in the PWA', async () => { runtime.supportsXtreamSqliteDataSource = false; diff --git a/libs/portal/xtream/feature/src/lib/parental-lock-category.guard.ts b/libs/portal/xtream/feature/src/lib/parental-lock-category.guard.ts index 07d5749ae..f5652ed7b 100644 --- a/libs/portal/xtream/feature/src/lib/parental-lock-category.guard.ts +++ b/libs/portal/xtream/feature/src/lib/parental-lock-category.guard.ts @@ -99,14 +99,38 @@ export function parentalLockXtreamCategoryGuard( route.paramMap.get('vodId') ?? route.paramMap.get('serialId') ); if (!locked && section !== 'live' && Number.isFinite(itemId)) { - const item = await dataSource.getContentByXtreamId( + const contentType = section === 'vod' ? 'movie' : 'series'; + let item = await dataSource.getContentByXtreamId( itemId, playlistId, - section === 'vod' ? 'movie' : 'series' + contentType ); + if (!item && rows === null) { + // PWA: the session cache is empty on a cold navigation, so + // hydrate it the way the route session would before judging. + const playlist = await dataSource.getPlaylist(playlistId); + if (playlist) { + await dataSource.getContent( + playlistId, + { + serverUrl: playlist.serverUrl, + username: playlist.username, + password: playlist.password, + }, + contentType + ); + item = await dataSource.getContentByXtreamId( + itemId, + playlistId, + contentType + ); + } + } const itemCategoryId = Number(item?.category_id); + // An item the catalog cannot place fails closed: while the lock + // is active an unknown title is not proof of an unlocked one. locked = - Number.isFinite(itemCategoryId) && isLocked(itemCategoryId); + !Number.isFinite(itemCategoryId) || isLocked(itemCategoryId); } if (!locked || (await parentalLock.requestUnlock())) {