mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-11 02:46:16 -08:00
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:
1 parent
299a162416
commit
ba5a9c0745
13 files changed
+600
-7
No files matched your search
@@ -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
|
||||
|
||||
+50
@@ -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(
|
||||
|
||||
+50
@@ -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',
|
||||
|
||||
+20
@@ -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,
|
||||
},
|
||||
],
|
||||
|
||||
Reference in new issue
Block a user