fix(settings): guard locked categories on routes, PWA search and paging

- Xtream and Stalker `:categoryId` routes carry a parental-lock guard: a
  locked category reached by URL prompts for the PIN and redirects to the
  section root on refusal (Electron row ids are mapped to provider ids).
- PWA search filters withheld categories like the catalog reads.
- Electron warm-cache detection confirms an empty, lock-filtered read with
  the unfiltered existence check instead of refetching from the provider.
- A Stalker lock flip past page 1 drops withheld rows at once and restarts
  the list from page 1 instead of appending onto stale pages.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
4grayandClaude Fable 5.1 committed 2026-09-25 22:30:11 +02:00
1 parent 299a162416
commit ba5a9c0745
13 files changed
+600 -7

No files matched your search

+19 -3
View File
@@ -98,8 +98,21 @@ locked default.
`XtreamStore.reloadCategories()` + `reloadCachedContent()` and, if the
selected category vanished, clears the selection and navigates to the
section root.
- **Xtream (PWA):** `PwaXtreamDataSource` drops withheld categories and
streams at read time; the same reloads apply.
- **Xtream (PWA):** `PwaXtreamDataSource` drops withheld categories,
streams and search hits at read time; the same reloads apply.
- **Warm-cache detection (Electron):** the filtered category/content reads
can be empty while the offline cache is complete (every category locked),
so `ElectronXtreamDataSource` confirms an empty read with the unfiltered
`hasXtreamCategories` / `hasXtreamContent` before refetching from the
provider.
- **Routes:** `parentalLockXtreamCategoryGuard(section)` on every Xtream
`:categoryId` route (live, vod, series and their detail children) and
`parentalLockStalkerCategoryGuard(section)` on the Stalker vod/series
`:categoryId` routes prompt for the PIN when a locked category is reached
by URL — bookmark, typed address, stale link — and redirect to the section
root on refusal. 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:** genres are stored unfiltered; `getCategoryResource` filters
them, `getAllCategoriesForSelectedType` is the raw list for the lock
dialog. `itvFullChannelList` and the content loader drop rows whose
@@ -110,7 +123,10 @@ locked default.
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
once every visible row is in. A lock flip while the list sits past page 1
drops the withheld rows on screen at once and restarts from page 1, so rows
accumulated under the old lock state are never appended to. 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
@@ -657,6 +657,56 @@ describe('withStalkerContent failure states', () => {
expect(store.hasMoreContent()).toBe(false);
});
it('drops accumulated rows of a newly locked genre and restarts from page 1', async () => {
dataService.sendIpcEvent.mockImplementation(
(_event: unknown, payload: { params?: { p?: number } }) => {
const page = Number(payload.params?.p ?? 1);
const data =
page === 1
? [
{ id: 'movie-1', name: 'One', category_id: '5' },
{ id: 'adult-1', name: 'A', category_id: '9' },
]
: [{ id: 'movie-2', name: 'Two', category_id: '5' }];
return Promise.resolve({ js: { data, total_items: 3 } });
}
);
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 === 2);
store.setPage(1);
await waitForCondition(() => store.getPaginatedContent().length === 3);
// Lock now: the genre-9 row loaded on page 1 must leave the screen
// and the list must be rebuilt from page 1 under the new lock state.
parentalLock.active.mockReturnValue(true);
parentalLock.lockedStalkerIds.mockReturnValue(['9']);
parentalLock.version.set(1);
await waitForCondition(
() =>
store.page() === 0 &&
!store.isPaginatedContentLoading() &&
store
.getPaginatedContent()
.every((item) => item.category_id !== '9'),
60
);
expect(store.getPaginatedContent().map((item) => item.id)).toEqual([
'movie-1',
]);
expect(store.totalCount()).toBe(2);
});
it('keeps accumulated pages when an append fails and retries the same page', async () => {
let failPageTwo = true;
dataService.sendIpcEvent.mockImplementation(
@@ -290,6 +290,7 @@ export function withStalkerContent() {
// adding nothing new — withheld or not — is a stalled portal.
let withheldSeenKey = '';
const withheldSeenIds = new Set<string>();
let lastParentalLockVersion: number | null = null;
return {
categoryResource: resource({
params: () => ({
@@ -496,6 +497,55 @@ export function withStalkerContent() {
playlist,
params.contentType
);
// A lock flip while the list is past page 1 must
// not append filtered rows onto pages that were
// accumulated under the old lock state: drop the
// withheld rows on screen now and restart from
// page 1 so the list is rebuilt under the new one.
if (
lastParentalLockVersion !== null &&
params.parentalLockVersion !==
lastParentalLockVersion &&
params.pageIndex > 1
) {
lastParentalLockVersion =
params.parentalLockVersion;
const retained = withoutWithheldStalkerItems(
store.paginatedContent(),
params.contentType,
withheldCategoryIds
);
patchState(store, {
paginatedContent: retained,
...(params.contentType === 'itv'
? {
itvChannels:
withoutWithheldStalkerItems(
store.itvChannels(),
'itv',
withheldCategoryIds
),
}
: params.contentType === 'radio'
? {
radioChannels:
withoutWithheldStalkerItems(
store.radioChannels(),
'radio',
withheldCategoryIds
),
}
: {}),
});
(
store as unknown as {
setPage?: (page: number) => void;
}
).setPage?.(0);
return retained;
}
lastParentalLockVersion =
params.parentalLockVersion;
if (params.contentType === 'itv') {
const cachedChannels =
@@ -0,0 +1,87 @@
import { TestBed } from '@angular/core/testing';
import {
ActivatedRouteSnapshot,
convertToParamMap,
Router,
RouterStateSnapshot,
UrlTree,
} from '@angular/router';
import { ParentalLockService } from '@iptvnator/services';
import { parentalLockStalkerCategoryGuard } from './parental-lock-category.guard';
function createRoute(playlistId: string, categoryId: string) {
const parent = {
paramMap: convertToParamMap({ id: playlistId }),
} as ActivatedRouteSnapshot;
const route = {
paramMap: convertToParamMap({ categoryId }),
} as ActivatedRouteSnapshot;
Object.defineProperty(route, 'pathFromRoot', {
value: [parent, route],
});
return route;
}
describe('parentalLockStalkerCategoryGuard', () => {
let parentalLock: {
initialize: jest.Mock;
active: jest.Mock;
isStalkerCategoryLocked: jest.Mock;
requestUnlock: jest.Mock;
};
let router: { createUrlTree: jest.Mock };
beforeEach(() => {
parentalLock = {
initialize: jest.fn().mockResolvedValue(undefined),
active: jest.fn(() => true),
isStalkerCategoryLocked: jest.fn(() => false),
requestUnlock: jest.fn().mockResolvedValue(false),
};
router = { createUrlTree: jest.fn(() => ({}) as UrlTree) };
TestBed.configureTestingModule({
providers: [
{ provide: ParentalLockService, useValue: parentalLock },
{ provide: Router, useValue: router },
],
});
});
function run(section: 'vod' | 'series', categoryId: string) {
return TestBed.runInInjectionContext(() =>
parentalLockStalkerCategoryGuard(section)(
createRoute('portal-1', categoryId),
{} as RouterStateSnapshot
)
);
}
it('passes an unlocked genre and an inactive lock', async () => {
await expect(run('vod', '9')).resolves.toBe(true);
parentalLock.active.mockReturnValue(false);
parentalLock.isStalkerCategoryLocked.mockReturnValue(true);
await expect(run('vod', '9')).resolves.toBe(true);
});
it('prompts for a locked genre and redirects to the section root on refusal', async () => {
parentalLock.isStalkerCategoryLocked.mockReturnValue(true);
const result = await run('series', '9');
expect(parentalLock.isStalkerCategoryLocked).toHaveBeenCalledWith(
'portal-1',
'series',
'9'
);
expect(router.createUrlTree).toHaveBeenCalledWith([
'/workspace',
'stalker',
'portal-1',
'series',
]);
expect(result).not.toBe(true);
parentalLock.requestUnlock.mockResolvedValue(true);
await expect(run('series', '9')).resolves.toBe(true);
});
});
@@ -0,0 +1,62 @@
import { inject } from '@angular/core';
import {
ActivatedRouteSnapshot,
CanActivateFn,
Router,
UrlTree,
} from '@angular/router';
import { ParentalLockService } from '@iptvnator/services';
import { ParentalLockStalkerCategoryType } from '@iptvnator/shared/interfaces';
function findPlaylistId(route: ActivatedRouteSnapshot): string | null {
for (const snapshot of route.pathFromRoot) {
const id = snapshot.paramMap.get('id');
if (id) {
return id;
}
}
return null;
}
/**
* Keeps a parental-locked Stalker genre off the screen when reached by URL.
* Stalker routes carry the portal's own genre id, which is what the lock
* store keys on, so no lookup is needed. A locked genre prompts for the PIN;
* a refusal redirects to the section root.
*/
export function parentalLockStalkerCategoryGuard(
section: ParentalLockStalkerCategoryType
): CanActivateFn {
return async (route): Promise<boolean | UrlTree> => {
const parentalLock = inject(ParentalLockService);
const router = inject(Router);
await parentalLock.initialize();
if (!parentalLock.active()) {
return true;
}
const playlistId = findPlaylistId(route);
const categoryId = route.paramMap.get('categoryId');
if (
!playlistId ||
!categoryId ||
!parentalLock.isStalkerCategoryLocked(
playlistId,
section,
categoryId
)
) {
return true;
}
if (await parentalLock.requestUnlock()) {
return true;
}
return router.createUrlTree([
'/workspace',
'stalker',
playlistId,
section,
]);
};
}
@@ -1,4 +1,5 @@
import { Route } from '@angular/router';
import { parentalLockStalkerCategoryGuard } from './parental-lock-category.guard';
import { PORTAL_CATALOG_DETAIL_COMPONENT } from '@iptvnator/portal/shared/util';
import { StalkerCatalogDetailComponent } from './stalker-catalog-detail/stalker-catalog-detail.component';
import { provideStalkerCatalogFacade } from './stalker-catalog-facade.service';
@@ -77,6 +78,9 @@ export function createStalkerRoutes(): Route[] {
},
{
path: ':categoryId',
canActivate: [
parentalLockStalkerCategoryGuard('vod'),
],
data: {
api: 'stalker',
contentType: 'vod',
@@ -113,6 +117,9 @@ export function createStalkerRoutes(): Route[] {
},
{
path: ':categoryId',
canActivate: [
parentalLockStalkerCategoryGuard('series'),
],
data: {
api: 'stalker',
contentType: 'series',
@@ -56,6 +56,26 @@ describe('ElectronXtreamDataSource (DB-first strategy)', () => {
);
});
it('keeps a complete cache whose every category the parental lock withholds', async () => {
// The filtered read returns nothing, but rows exist: refetching
// from the provider would be wasted work and, for content, a
// full re-import.
harness.dbService.getXtreamImportStatus.mockResolvedValue(
'completed'
);
harness.dbService.getXtreamCategories.mockResolvedValue([]);
harness.dbService.hasXtreamCategories.mockResolvedValue(true);
const result = await harness.dataSource.getCategories(
playlistId,
credentials,
'live'
);
expect(result).toEqual([]);
expect(harness.apiService.getCategories).not.toHaveBeenCalled();
});
it('fetches from the API and caches to DB when the cache is cold', async () => {
const remoteCategories = [
{ category_id: '10', category_name: 'News' },
@@ -189,7 +189,14 @@ export class ElectronXtreamDataSource implements IXtreamDataSource {
playlistId,
dbType
);
if (importStatus === 'completed' && cached.length > 0) {
// The read is filtered by the parental lock, so an empty result is
// not proof of a cold cache: a type whose every category is locked
// is complete and must not be refetched from the provider.
if (
importStatus === 'completed' &&
(cached.length > 0 ||
(await this.dbService.hasXtreamCategories(playlistId, dbType)))
) {
return cached;
}
@@ -334,7 +341,13 @@ export class ElectronXtreamDataSource implements IXtreamDataSource {
// Fetch from DB directly — avoids a separate 'has' round-trip.
// An empty result means the cache is cold; proceed to fetch from API.
const cached = await this.dbService.getXtreamContent(playlistId, type);
if (importStatus === 'completed' && cached.length > 0) {
// Filtered by the parental lock like the category read: empty is not
// cold while unfiltered rows exist.
if (
importStatus === 'completed' &&
(cached.length > 0 ||
(await this.dbService.hasXtreamContent(playlistId, type)))
) {
return cached;
}
@@ -5,7 +5,7 @@ import {
XtreamApiService,
XtreamCredentials,
} from '../services/xtream-api.service';
import { PlaylistsService } from '@iptvnator/services';
import { ParentalLockService, PlaylistsService } from '@iptvnator/services';
import { of } from 'rxjs';
describe('PwaXtreamDataSource', () => {
@@ -17,6 +17,10 @@ describe('PwaXtreamDataSource', () => {
getPlaylistById: jest.Mock;
transformPlaylistMeta: jest.Mock;
};
let parentalLock: {
active: jest.Mock<boolean, []>;
lockedXtreamIds: jest.Mock<number[], [string, string]>;
};
const credentials: XtreamCredentials = {
serverUrl: 'http://localhost:3211',
@@ -34,10 +38,15 @@ describe('PwaXtreamDataSource', () => {
getPlaylistById: jest.fn(() => of(undefined)),
transformPlaylistMeta: jest.fn(() => of(null)),
};
parentalLock = {
active: jest.fn(() => false),
lockedXtreamIds: jest.fn(() => []),
};
TestBed.configureTestingModule({
providers: [
PwaXtreamDataSource,
{ provide: ParentalLockService, useValue: parentalLock },
{
provide: XtreamApiService,
useValue: apiService,
@@ -56,6 +65,37 @@ describe('PwaXtreamDataSource', () => {
localStorage.clear();
});
it('withholds locked categories from catalog reads and search while the lock is active', async () => {
apiService.getStreams.mockResolvedValue([
{ stream_id: 1, name: 'Family film', category_id: '5' },
{ stream_id: 2, name: 'Family after dark', category_id: '9' },
]);
parentalLock.active.mockReturnValue(true);
parentalLock.lockedXtreamIds.mockImplementation((_id, type) =>
type === 'movies' ? [9] : []
);
const content = await dataSource.getContent(
'playlist-1',
credentials,
'movie'
);
const found = await dataSource.searchContent('playlist-1', 'family', [
'movie',
]);
expect(content.map((item) => item.name)).toEqual(['Family film']);
expect(found.map((item) => item.name)).toEqual(['Family film']);
parentalLock.active.mockReturnValue(false);
const unlocked = await dataSource.searchContent(
'playlist-1',
'family',
['movie']
);
expect(unlocked).toHaveLength(2);
});
it('reports remote loading phases for API fetches but stays silent on cache hits', async () => {
apiService.getStreams.mockResolvedValue([
{ stream_id: 1, name: 'News' },
@@ -612,7 +612,13 @@ export class PwaXtreamDataSource implements IXtreamDataSource {
for (const type of types) {
const cacheKey = `${playlistId}-${type}-content`;
const content = this.contentCache.get(cacheKey) || [];
// Same lock boundary as the catalog read: a search must not
// surface rows the category list withholds.
const content = this.withoutLockedContent(
playlistId,
type as StreamType,
this.contentCache.get(cacheKey) || []
);
const filtered = content.filter((item) => {
const title =
@@ -0,0 +1,122 @@
import { TestBed } from '@angular/core/testing';
import {
ActivatedRouteSnapshot,
convertToParamMap,
Router,
RouterStateSnapshot,
UrlTree,
} from '@angular/router';
import {
DatabaseService,
ParentalLockService,
RuntimeCapabilitiesService,
} from '@iptvnator/services';
import { parentalLockXtreamCategoryGuard } from './parental-lock-category.guard';
function createRoute(playlistId: string, categoryId: string) {
const parent = {
paramMap: convertToParamMap({ id: playlistId }),
} as ActivatedRouteSnapshot;
const route = {
paramMap: convertToParamMap({ categoryId }),
} as ActivatedRouteSnapshot;
Object.defineProperty(route, 'pathFromRoot', {
value: [parent, route],
});
return route;
}
describe('parentalLockXtreamCategoryGuard', () => {
let parentalLock: {
initialize: jest.Mock;
active: jest.Mock;
isXtreamCategoryLocked: jest.Mock;
requestUnlock: jest.Mock;
};
let databaseService: { getAllXtreamCategories: jest.Mock };
let runtime: { supportsXtreamSqliteDataSource: boolean };
let router: { createUrlTree: jest.Mock };
beforeEach(() => {
parentalLock = {
initialize: jest.fn().mockResolvedValue(undefined),
active: jest.fn(() => true),
isXtreamCategoryLocked: jest.fn(() => false),
requestUnlock: jest.fn().mockResolvedValue(false),
};
databaseService = {
getAllXtreamCategories: jest
.fn()
.mockResolvedValue([
{ id: 12, xtream_id: 900, type: 'movies' },
]),
};
runtime = { supportsXtreamSqliteDataSource: true };
router = { createUrlTree: jest.fn(() => ({}) as UrlTree) };
TestBed.configureTestingModule({
providers: [
{ provide: ParentalLockService, useValue: parentalLock },
{ provide: DatabaseService, useValue: databaseService },
{ provide: RuntimeCapabilitiesService, useValue: runtime },
{ provide: Router, useValue: router },
],
});
});
function run(section: 'live' | 'vod' | 'series', categoryId: string) {
return TestBed.runInInjectionContext(() =>
parentalLockXtreamCategoryGuard(section)(
createRoute('playlist-1', categoryId),
{} as RouterStateSnapshot
)
);
}
it('passes through while the lock is inactive without touching the database', async () => {
parentalLock.active.mockReturnValue(false);
await expect(run('vod', '12')).resolves.toBe(true);
expect(databaseService.getAllXtreamCategories).not.toHaveBeenCalled();
});
it('maps the Electron row id to the provider id and redirects a refused locked category', async () => {
parentalLock.isXtreamCategoryLocked.mockReturnValue(true);
const result = await run('vod', '12');
expect(parentalLock.isXtreamCategoryLocked).toHaveBeenCalledWith(
'playlist-1',
'movies',
900
);
expect(parentalLock.requestUnlock).toHaveBeenCalled();
expect(router.createUrlTree).toHaveBeenCalledWith([
'/workspace',
'xtreams',
'playlist-1',
'vod',
]);
expect(result).not.toBe(true);
});
it('lets an entered PIN through', async () => {
parentalLock.isXtreamCategoryLocked.mockReturnValue(true);
parentalLock.requestUnlock.mockResolvedValue(true);
await expect(run('series', '12')).resolves.toBe(true);
expect(router.createUrlTree).not.toHaveBeenCalled();
});
it('uses the route id as the provider id in the PWA', async () => {
runtime.supportsXtreamSqliteDataSource = false;
await run('live', '77');
expect(databaseService.getAllXtreamCategories).not.toHaveBeenCalled();
expect(parentalLock.isXtreamCategoryLocked).toHaveBeenCalledWith(
'playlist-1',
'live',
77
);
});
});
@@ -0,0 +1,100 @@
import { inject } from '@angular/core';
import {
ActivatedRouteSnapshot,
CanActivateFn,
Router,
UrlTree,
} from '@angular/router';
import {
DatabaseService,
ParentalLockService,
RuntimeCapabilitiesService,
} from '@iptvnator/services';
import { ParentalLockXtreamCategoryType } from '@iptvnator/shared/interfaces';
type XtreamCategorySection = 'live' | 'vod' | 'series';
const CATEGORY_TYPE_BY_SECTION: Record<
XtreamCategorySection,
ParentalLockXtreamCategoryType
> = {
live: 'live',
vod: 'movies',
series: 'series',
};
function findPlaylistId(route: ActivatedRouteSnapshot): string | null {
for (const snapshot of route.pathFromRoot) {
const id = snapshot.paramMap.get('id');
if (id) {
return id;
}
}
return null;
}
/**
* Keeps a parental-locked Xtream category off the screen even when it is
* reached by URL — a bookmark, a typed address or a stale in-app link — and
* not through the (already filtered) category rail. The lock is keyed by the
* provider category id; in Electron the route carries the SQLite row id, so
* the guard maps it through the unfiltered category read (which only the
* guard sees). A locked category prompts for the PIN; a refusal redirects
* to the section root instead of rendering the category or its detail.
*/
export function parentalLockXtreamCategoryGuard(
section: XtreamCategorySection
): CanActivateFn {
return async (route): Promise<boolean | UrlTree> => {
const parentalLock = inject(ParentalLockService);
const router = inject(Router);
const databaseService = inject(DatabaseService);
const runtime = inject(RuntimeCapabilitiesService);
await parentalLock.initialize();
if (!parentalLock.active()) {
return true;
}
const playlistId = findPlaylistId(route);
const rawCategoryId = Number(route.paramMap.get('categoryId'));
if (!playlistId || !Number.isFinite(rawCategoryId)) {
return true;
}
const categoryType = CATEGORY_TYPE_BY_SECTION[section];
let xtreamId = rawCategoryId;
if (runtime.supportsXtreamSqliteDataSource) {
const rows = await databaseService.getAllXtreamCategories(
playlistId,
categoryType
);
const row = rows.find(
(candidate) => candidate.id === rawCategoryId
);
if (!row) {
return true;
}
xtreamId = row.xtream_id;
}
if (
!parentalLock.isXtreamCategoryLocked(
playlistId,
categoryType,
xtreamId
)
) {
return true;
}
if (await parentalLock.requestUnlock()) {
return true;
}
return router.createUrlTree([
'/workspace',
'xtreams',
playlistId,
section,
]);
};
}
@@ -1,4 +1,5 @@
import { Route } from '@angular/router';
import { parentalLockXtreamCategoryGuard } from './parental-lock-category.guard';
import { provideXtreamCatalogFacade } from './xtream-catalog-facade.service';
import { provideXtreamWorkspaceRouteSession } from './xtream-workspace-route-session.service';
@@ -85,6 +86,9 @@ export function createXtreamRoutes(): Route[] {
},
{
path: 'live/:categoryId',
canActivate: [
parentalLockXtreamCategoryGuard('live'),
],
loadComponent: loadLiveStreamLayoutComponent,
},
{
@@ -98,11 +102,17 @@ export function createXtreamRoutes(): Route[] {
},
{
path: ':categoryId',
canActivate: [
parentalLockXtreamCategoryGuard('vod'),
],
loadComponent:
loadCategoryContentViewComponent,
},
{
path: ':categoryId/:vodId',
canActivate: [
parentalLockXtreamCategoryGuard('vod'),
],
loadComponent: loadVodDetailsRouteComponent,
},
],
@@ -118,11 +128,21 @@ export function createXtreamRoutes(): Route[] {
},
{
path: ':categoryId',
canActivate: [
parentalLockXtreamCategoryGuard(
'series'
),
],
loadComponent:
loadCategoryContentViewComponent,
},
{
path: ':categoryId/:serialId',
canActivate: [
parentalLockXtreamCategoryGuard(
'series'
),
],
loadComponent: loadSerialDetailsComponent,
},
],