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 <noreply@anthropic.com>
This commit is contained in:
4grayandClaude Fable 5.1 committed 2026-09-25 23:19:23 +02:00
1 parent d4a1f8ba03
commit 7bd945e4fb
11 files changed
+239 -30

No files matched your search

+15 -2
View File
@@ -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.
@@ -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);
@@ -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<string>();
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);
@@ -21,6 +21,7 @@ describe('ElectronXtreamDataSource (DB-first strategy)', () => {
type: 'live' as const,
xtream_id: 10,
hidden: false,
locked: false,
};
const dbContentItem = {
id: 1,
@@ -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<XtreamCategoryFromDb[]> {
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(
@@ -88,6 +88,8 @@ export interface XtreamCategoryFromDb {
type: 'movies' | 'live' | 'series';
xtream_id: number;
hidden: boolean;
/** Parental lock index; see ParentalLockService. */
locked: boolean;
}
/**
@@ -80,13 +80,15 @@
[attr.data-category-id]="category.xtream_id"
(click)="toggleCategory(category)"
>
<mat-checkbox
[checked]="category.selected"
[aria-label]="category.name"
(click)="$event.stopPropagation()"
(change)="toggleCategory(category)"
>
</mat-checkbox>
@if (supportsVisibility) {
<mat-checkbox
[checked]="category.selected"
[aria-label]="category.name"
(click)="$event.stopPropagation()"
(change)="toggleCategory(category)"
>
</mat-checkbox>
}
<span class="category-name">{{ category.name }}</span>
<span class="item-count"
>({{ getItemCount(category) }})</span
@@ -1,7 +1,12 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog';
import { TranslateModule } from '@ngx-translate/core';
import { DatabaseService, XCategoryFromDb } from '@iptvnator/services';
import { XTREAM_DATA_SOURCE } from '@iptvnator/portal/xtream/data-access';
import {
DatabaseService,
RuntimeCapabilitiesService,
XCategoryFromDb,
} from '@iptvnator/services';
import {
CategoryManagementDialogComponent,
CategoryManagementDialogData,
@@ -17,15 +22,18 @@ const categories: XCategoryFromDb[] = [
...category,
playlist_id: 'mock-playlist',
type: 'live',
locked: false,
}));
describe('CategoryManagementDialogComponent', () => {
let fixture: ComponentFixture<CategoryManagementDialogComponent>;
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
);
@@ -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<CategoryManagementDialogComponent>
@@ -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);
}
@@ -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;
@@ -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())) {