Merge remote-tracking branch 'origin/master' into claude/channel-history-playback-check-0b3bd9

# Conflicts:
#	libs/portal/xtream/feature/src/lib/serial-details/serial-details-playback.service.ts
This commit is contained in:
4gray committed 2026-09-27 20:55:48 +02:00
commit 02ed4e589c
378 files changed
+16812 -1098

No files matched your search

@@ -7,8 +7,11 @@ import { SettingsStore, EpgSourceSettingsService } from '@iptvnator/services';
import { ELECTRON_BRIDGE_SECURITY_ERROR_CODES } from '@iptvnator/shared/interfaces';
import { EpgProgressService } from './epg-progress.service';
/** Spec-only double: capability flags are read-only getters on the real class. */
type WritablePartial<T> = { -readonly [K in keyof T]?: T[K] };
describe('EpgProgressService', () => {
let epgBridge: Partial<EpgRuntimeBridgeService>;
let epgBridge: WritablePartial<EpgRuntimeBridgeService>;
let sources: { waitForReconciliation: jest.Mock };
let settingsStore: {
getSettings: jest.Mock;
@@ -2,9 +2,12 @@ import { TestBed } from '@angular/core/testing';
import { RuntimeCapabilitiesService } from '@iptvnator/services';
import { EpgRuntimeBridgeService } from './epg-runtime-bridge.service';
/** Spec-only double: capability flags are read-only getters on the real class. */
type WritablePartial<T> = { -readonly [K in keyof T]?: T[K] };
describe('EpgRuntimeBridgeService', () => {
let service: EpgRuntimeBridgeService;
let runtimeCapabilities: Partial<RuntimeCapabilitiesService>;
let runtimeCapabilities: WritablePartial<RuntimeCapabilitiesService>;
const originalElectron = window.electron;
beforeEach(() => {
@@ -7,12 +7,16 @@ import {
PlaylistsService,
SettingsStore,
} from '@iptvnator/services';
import { EpgProgram } from '@iptvnator/shared/interfaces';
import { EpgRuntimeBridgeService } from './epg-runtime-bridge.service';
import { EpgService } from './epg.service';
/** Spec-only double: capability flags are read-only getters on the real class. */
type WritablePartial<T> = { -readonly [K in keyof T]?: T[K] };
describe('EpgService', () => {
let service: EpgService;
let epgBridge: Partial<EpgRuntimeBridgeService>;
let epgBridge: WritablePartial<EpgRuntimeBridgeService>;
let snackBar: { open: jest.Mock };
let settingsStore: {
loadSettings: jest.Mock;
@@ -164,7 +168,7 @@ describe('EpgService', () => {
new Promise((resolve) => {
complete = resolve;
}),
} as typeof window.electron;
} as Partial<typeof window.electron> as typeof window.electron;
try {
const sources = TestBed.inject(EpgSourceSettingsService);
const reconciliation = sources.synchronize([
@@ -457,16 +461,7 @@ describe('EpgService', () => {
it('deduplicates concurrent scoped current program lookups for the same source scope', async () => {
epgBridge.supportsProgramLookup = true;
let resolvePrograms:
| ((
programs: {
channel: string;
start: string;
stop: string;
title: string;
}[]
) => void)
| undefined;
let resolvePrograms: ((programs: EpgProgram[]) => void) | undefined;
epgBridge.getChannelPrograms = jest.fn(
() =>
new Promise((resolve) => {
@@ -495,6 +490,8 @@ describe('EpgService', () => {
start: '2026-05-23T10:00:00.000Z',
stop: '2026-05-23T11:00:00.000Z',
title: 'Playlist Guide Bulletin',
desc: null,
category: null,
},
]);
@@ -928,7 +925,7 @@ describe('EpgService', () => {
epgBridge.supportsProgramLookup = true;
epgBridge.supportsCurrentProgramBatch = true;
const batchResolvers: Array<
(programs: Record<string, unknown>) => void
(programs: Record<string, EpgProgram | null>) => void
> = [];
epgBridge.getCurrentProgramsBatch = jest.fn(
() =>
@@ -957,6 +954,8 @@ describe('EpgService', () => {
start: '2026-05-23T10:00:00.000Z',
stop: '2026-05-23T11:00:00.000Z',
title: 'Playlist Guide Bulletin',
desc: null,
category: null,
},
})
);
@@ -981,7 +980,7 @@ describe('EpgService', () => {
epgBridge.supportsProgramLookup = true;
epgBridge.supportsCurrentProgramBatch = true;
let resolveBatch:
((programs: Record<string, unknown>) => void) | undefined;
((programs: Record<string, EpgProgram | null>) => void) | undefined;
epgBridge.getCurrentProgramsBatch = jest.fn(
() =>
new Promise((resolve) => {
@@ -1010,12 +1009,16 @@ describe('EpgService', () => {
start: '2026-05-23T10:00:00.000Z',
stop: '2026-05-23T11:00:00.000Z',
title: 'Playlist Guide Bulletin',
desc: null,
category: null,
},
'guide-sports': {
channel: 'guide-sports',
start: '2026-05-23T10:00:00.000Z',
stop: '2026-05-23T11:00:00.000Z',
title: 'Playlist Sports Bulletin',
desc: null,
category: null,
},
});
+3 -3
View File
@@ -3,12 +3,12 @@
"compilerOptions": {
"emitDecoratorMetadata": false,
"outDir": "../../../dist/out-tsc",
"module": "commonjs",
"module": "preserve",
"target": "es2016",
"types": ["jest", "node"],
"moduleResolution": "node10"
"moduleResolution": "bundler"
},
"files": ["src/test-setup.ts"],
"files": ["src/test-setup.ts", "../../../global.d.ts"],
"include": [
"jest.config.ts",
"src/**/*.test.ts",
@@ -127,7 +127,7 @@ describe('playlistReducers', () => {
stalkerTimeslot: 3,
stalkerAccountInfo: {
login: 'subscriber',
status: 'active',
status: 1,
},
},
},
@@ -153,7 +153,7 @@ describe('playlistReducers', () => {
stalkerTimeslot: 3,
stalkerAccountInfo: {
login: 'subscriber',
status: 'active',
status: 1,
},
})
);
@@ -158,7 +158,7 @@ describe('HLS playback evidence', () => {
networkDetails: {
responseURL: `https://provider.example/xhr?token=${secret}`,
responseText: secret,
},
} satisfies Partial<XMLHttpRequest> as XMLHttpRequest,
})
);
const serialized = JSON.stringify(evidence);
@@ -12,6 +12,14 @@ import {
import { PlaylistType } from '@iptvnator/playlist/shared/ui';
import { AddPlaylistDialogComponent } from './add-playlist-dialog.component';
type ChildSignalKey =
| 'urlUpload'
| 'fileUpload'
| 'textImport'
| 'xtreamImport'
| 'stalkerImport'
| 'autoImport';
describe('AddPlaylistDialogComponent', () => {
let component: AddPlaylistDialogComponent;
let dataService: { sendIpcEvent: jest.Mock };
@@ -67,8 +75,20 @@ describe('AddPlaylistDialogComponent', () => {
);
});
/**
* Replaces a read-only `viewChild` signal with a stub that resolves to a
* minimal structural double of the child component, or to `undefined`
* while the child is not rendered yet.
*/
function stubChild(key: ChildSignalKey, child: object | undefined): void {
Object.defineProperty(component, key, {
value: jest.fn(() => child),
configurable: true,
});
}
it('sends a trimmed custom title for URL playlists', () => {
(component as { urlUpload: jest.Mock }).urlUpload = jest.fn(() => ({
stubChild('urlUpload', {
form: {
getRawValue: () => ({
playlistName: ' My Playlist ',
@@ -76,7 +96,7 @@ describe('AddPlaylistDialogComponent', () => {
playlistUrl: ' https://example.com/list.m3u ',
}),
},
}));
});
component.submitUrlPlaylist();
@@ -92,7 +112,7 @@ describe('AddPlaylistDialogComponent', () => {
});
it('omits the title when the optional name is blank', () => {
(component as { urlUpload: jest.Mock }).urlUpload = jest.fn(() => ({
stubChild('urlUpload', {
form: {
getRawValue: () => ({
playlistName: ' ',
@@ -100,7 +120,7 @@ describe('AddPlaylistDialogComponent', () => {
playlistUrl: 'https://example.com/list.m3u',
}),
},
}));
});
component.submitUrlPlaylist();
@@ -167,10 +187,7 @@ describe('AddPlaylistDialogComponent', () => {
'clears the current $type import surface',
({ type, childAccessor, clearMethod }) => {
const clear = jest.fn();
(component as unknown as Record<string, jest.Mock>)[childAccessor] =
jest.fn(() => ({
[clearMethod]: clear,
}));
stubChild(childAccessor, { [clearMethod]: clear });
selectType(type);
component.clearCurrentForm();
@@ -180,10 +197,10 @@ describe('AddPlaylistDialogComponent', () => {
);
it('disables clear when a file upload has no selection', () => {
(component as { fileUpload: jest.Mock }).fileUpload = jest.fn(() => ({
stubChild('fileUpload', {
isImporting: () => false,
selectedFile: () => null,
}));
});
selectType('file');
expect(component.isClearDisabled()).toBeTruthy();
@@ -260,9 +277,7 @@ describe('AddPlaylistDialogComponent', () => {
it('prefills the xtream form once the child exists and clears the pending candidate', () => {
const patchValue = jest.fn();
(component as { xtreamImport: jest.Mock }).xtreamImport = jest.fn(
() => ({ form: { patchValue } })
);
stubChild('xtreamImport', { form: { patchValue } });
component.onCandidateSelected({
kind: 'xtream',
@@ -288,9 +303,7 @@ describe('AddPlaylistDialogComponent', () => {
it('drops the candidate when the user switches to another method first', () => {
const patchValue = jest.fn();
(component as { xtreamImport: jest.Mock }).xtreamImport = jest.fn(
() => ({ form: { patchValue } })
);
stubChild('xtreamImport', { form: { patchValue } });
component.onCandidateSelected({
kind: 'xtream',
@@ -309,9 +322,7 @@ describe('AddPlaylistDialogComponent', () => {
});
it('keeps the candidate pending while the target form does not exist yet', () => {
(component as { xtreamImport: jest.Mock }).xtreamImport = jest.fn(
() => undefined
);
stubChild('xtreamImport', undefined);
const patchValue = jest.fn();
component.onCandidateSelected({
@@ -322,9 +333,7 @@ describe('AddPlaylistDialogComponent', () => {
applyPrefill();
// Child appears on a later change-detection pass.
(component as { xtreamImport: jest.Mock }).xtreamImport = jest.fn(
() => ({ form: { patchValue } })
);
stubChild('xtreamImport', { form: { patchValue } });
applyPrefill();
expect(patchValue).toHaveBeenCalledWith(
@@ -334,8 +343,7 @@ describe('AddPlaylistDialogComponent', () => {
it('prefills the stalker form including identity fields', () => {
const patchValue = jest.fn();
(component as { stalkerImport: jest.Mock }).stalkerImport =
jest.fn(() => ({ form: { patchValue } }));
stubChild('stalkerImport', { form: { patchValue } });
component.onCandidateSelected({
kind: 'stalker',
@@ -369,9 +377,9 @@ describe('AddPlaylistDialogComponent', () => {
it('prefills the URL form for an m3u-url candidate', () => {
const patchValue = jest.fn();
(component as { urlUpload: jest.Mock }).urlUpload = jest.fn(() => ({
stubChild('urlUpload', {
form: { patchValue },
}));
});
component.onCandidateSelected({
kind: 'm3u-url',
@@ -389,9 +397,7 @@ describe('AddPlaylistDialogComponent', () => {
it('prefills the raw-text form for an m3u-text candidate', () => {
const patchValue = jest.fn();
(component as { textImport: jest.Mock }).textImport = jest.fn(
() => ({ textForm: { patchValue } })
);
stubChild('textImport', { textForm: { patchValue } });
component.onCandidateSelected({
kind: 'm3u-text',
@@ -3,12 +3,12 @@
"compilerOptions": {
"emitDecoratorMetadata": false,
"outDir": "../../../../dist/out-tsc",
"module": "commonjs",
"module": "preserve",
"target": "es2016",
"types": ["jest", "node"],
"moduleResolution": "node10"
"moduleResolution": "bundler"
},
"files": ["src/test-setup.ts"],
"files": ["src/test-setup.ts", "../../../../global.d.ts"],
"include": [
"jest.config.ts",
"src/**/*.test.ts",
@@ -11,7 +11,10 @@ import { M3uEpgGuideSourceService } from './m3u-epg-guide-source.service';
function makeChannel(
id: string,
overrides: Partial<Channel> & { tvgId?: string; group?: string } = {}
overrides: Omit<Partial<Channel>, 'group'> & {
tvgId?: string;
group?: string;
} = {}
): Channel {
return {
id,
@@ -162,13 +162,16 @@ describe('VideoPlayerComponent', () => {
// Mirrors the real capability check: every
// remote-control bridge method must be present.
get supportsRemoteControl() {
const bridge = window.electron as
Record<string, unknown> | undefined;
return [
'updateRemoteControlStatus',
'onChannelChange',
'onRemoteControlCommand',
].every(
const bridge:
Partial<typeof window.electron> | undefined =
window.electron;
return (
[
'updateRemoteControlStatus',
'onChannelChange',
'onRemoteControlCommand',
] as const
).every(
(method) =>
typeof bridge?.[method] === 'function'
);
@@ -582,7 +585,7 @@ describe('VideoPlayerComponent', () => {
const updateRemoteControlStatus = jest.fn();
window.electron = {
updateRemoteControlStatus,
} as typeof window.electron;
} as unknown as typeof window.electron;
fixture = TestBed.createComponent(VideoPlayerComponent);
component = fixture.componentInstance;
@@ -687,6 +690,7 @@ describe('VideoPlayerComponent', () => {
streamUrl: sampleChannel.url,
startedAt: '2026-08-08T00:00:00.000Z',
updatedAt: '2026-08-08T00:00:00.000Z',
canClose: true,
});
fixture.detectChanges();
@@ -114,6 +114,7 @@ import { createPlaybackSessionKey } from '@iptvnator/playback/util';
import { ChannelListLoadingStateComponent } from '@iptvnator/ui/components';
import {
DataService,
ParentalLockService,
PlaylistsService,
RecordingsService,
RuntimeCapabilitiesService,
@@ -243,6 +244,7 @@ export class VideoPlayerComponent
private readonly router = inject(Router);
private readonly runtime = inject(RuntimeCapabilitiesService);
private readonly settingsStore = inject(SettingsStore);
private readonly parentalLock = inject(ParentalLockService);
private readonly storage = inject(StorageMap);
private readonly store = inject(Store);
private readonly epgService = inject(EpgService);
@@ -1477,8 +1479,19 @@ export class VideoPlayerComponent
)
)
.subscribe((channel) => {
// The number indexes the full list; a channel of a locked
// group is not zapped to (the enforcement service would
// reset it anyway, after playback had started).
const playlistId = this.activePlaylistMeta()?._id;
if (
channel &&
!(
playlistId &&
this.parentalLock.isM3uGroupLocked(
playlistId,
channel.group?.title ?? ''
)
) &&
(!this.isLivePlayerFullscreen() ||
this.keepsInlinePlayer(channel))
) {
@@ -451,7 +451,11 @@ describe('PlaylistRefreshActionService', () => {
const originalSetItem = Storage.prototype.setItem;
const setItemSpy = jest
.spyOn(Storage.prototype, 'setItem')
.mockImplementation(function (key: string, value: string) {
.mockImplementation(function (
this: Storage,
key: string,
value: string
) {
executionOrder.push('setItem');
return originalSetItem.call(this, key, value);
});
@@ -29,7 +29,6 @@ function createPlaylist(
overrides: Partial<PlaylistMeta> & { _id: string }
): PlaylistMeta {
return {
_id: overrides._id,
title: overrides.title ?? overrides.filename ?? overrides._id,
filename: overrides.filename,
count: overrides.count ?? 0,
@@ -73,6 +73,7 @@ describe('PlaylistInfoComponent', () => {
autoRefresh: false,
url: 'https://example.com/playlist.m3u',
} as Playlist & { id: string };
const metaBase = { count: 1, importDate: '2026-04-01', autoRefresh: false };
beforeEach(async () => {
playlistsService = {
@@ -213,6 +214,7 @@ describe('PlaylistInfoComponent', () => {
createComponent();
const updatedPlaylist = {
...metaBase,
_id: 'playlist-1',
title: 'Updated Xtream',
serverUrl: 'http://new.example:8080',
@@ -251,6 +253,7 @@ describe('PlaylistInfoComponent', () => {
createComponent();
await component.saveChanges({
...metaBase,
_id: 'playlist-1',
title: 'Updated Xtream',
serverUrl:
@@ -262,6 +265,7 @@ describe('PlaylistInfoComponent', () => {
expect(store.dispatch).toHaveBeenCalledWith(
PlaylistActions.updatePlaylistMeta({
playlist: {
...metaBase,
_id: 'playlist-1',
title: 'Updated Xtream',
serverUrl: 'http://new.example:8080/live',
@@ -289,6 +293,7 @@ describe('PlaylistInfoComponent', () => {
createComponent();
await component.saveChanges({
...metaBase,
_id: 'playlist-1',
title: 'Updated Xtream',
serverUrl:
@@ -314,7 +319,7 @@ describe('PlaylistInfoComponent', () => {
window.electron = {
saveFileDialog: jest.fn().mockResolvedValue('/tmp/export.m3u8'),
writeFile: jest.fn().mockResolvedValue({ success: true }),
} as typeof window.electron;
} as Partial<typeof window.electron> as typeof window.electron;
createComponent();
await component.exportPlaylist();
@@ -491,7 +491,11 @@ describe('RecentPlaylistsComponent busy state', () => {
const originalSetItem = Storage.prototype.setItem;
const setItemSpy = jest
.spyOn(Storage.prototype, 'setItem')
.mockImplementation(function (key: string, value: string) {
.mockImplementation(function (
this: Storage,
key: string,
value: string
) {
executionOrder.push('setItem');
return originalSetItem.call(this, key, value);
});
+3 -3
View File
@@ -3,12 +3,12 @@
"compilerOptions": {
"emitDecoratorMetadata": false,
"outDir": "../../../../dist/out-tsc",
"module": "commonjs",
"module": "preserve",
"target": "es2016",
"types": ["jest", "node"],
"moduleResolution": "node10"
"moduleResolution": "bundler"
},
"files": ["src/test-setup.ts"],
"files": ["src/test-setup.ts", "../../../../global.d.ts"],
"include": [
"jest.config.ts",
"src/**/*.test.ts",
@@ -22,11 +22,11 @@ function createPlaylist(
overrides: Partial<PlaylistMeta> & { _id: string }
): PlaylistMeta {
return {
_id: overrides._id,
title: overrides.title ?? overrides.filename ?? overrides._id,
count: overrides.count ?? 0,
importDate:
overrides.importDate ?? new Date('2026-04-05T10:00:00.000Z').toISOString(),
overrides.importDate ??
new Date('2026-04-05T10:00:00.000Z').toISOString(),
autoRefresh: overrides.autoRefresh ?? false,
...overrides,
} as PlaylistMeta;
@@ -392,24 +392,25 @@ describe('PlaylistContextFacade', () => {
playlistId: xtreamA._id,
section: 'recently-added',
});
expect(JSON.parse(localStorage.getItem(LAST_SECTION_STORAGE_KEY) ?? '{}'))
.toEqual({
playlists: {
[m3uA._id]: {
provider: 'playlists',
section: 'favorites',
updatedAt: expect.any(Number),
},
[xtreamA._id]: {
provider: 'xtreams',
section: 'recently-added',
updatedAt: expect.any(Number),
},
expect(
JSON.parse(localStorage.getItem(LAST_SECTION_STORAGE_KEY) ?? '{}')
).toEqual({
playlists: {
[m3uA._id]: {
provider: 'playlists',
section: 'favorites',
updatedAt: expect.any(Number),
},
providers: {
playlists: 'favorites',
xtreams: 'recently-added',
[xtreamA._id]: {
provider: 'xtreams',
section: 'recently-added',
updatedAt: expect.any(Number),
},
});
},
providers: {
playlists: 'favorites',
xtreams: 'recently-added',
},
});
});
});
+3 -3
View File
@@ -3,12 +3,12 @@
"compilerOptions": {
"emitDecoratorMetadata": false,
"outDir": "../../../../dist/out-tsc",
"module": "commonjs",
"module": "preserve",
"target": "es2016",
"types": ["jest", "node"],
"moduleResolution": "node10"
"moduleResolution": "bundler"
},
"files": ["src/test-setup.ts"],
"files": ["src/test-setup.ts", "../../../../global.d.ts"],
"include": [
"jest.config.ts",
"src/**/*.test.ts",
@@ -3,12 +3,12 @@
"compilerOptions": {
"emitDecoratorMetadata": false,
"outDir": "../../../../dist/out-tsc",
"module": "commonjs",
"module": "preserve",
"target": "es2016",
"types": ["jest", "node"],
"moduleResolution": "node10"
"moduleResolution": "bundler"
},
"files": ["src/test-setup.ts"],
"files": ["src/test-setup.ts", "../../../../global.d.ts"],
"include": [
"jest.config.ts",
"src/**/*.test.ts",
@@ -381,10 +381,10 @@ describe('DownloadLibraryNavigationService', () => {
);
});
it.each([
it.each<readonly [string, Partial<StalkerPortalItem>]>([
['is_series flag', { is_series: '1' }],
['embedded episode list', { series: [1, 2] }],
] as const)(
])(
'opens a recovered Stalker episode with an %s through the VOD-series detail route',
async (_label, vodSeriesMetadata) => {
playlists.getPlaylistById.mockReturnValue(of(STALKER_PLAYLIST));
@@ -510,9 +510,9 @@ describe('DownloadQueueComponent', () => {
expect(row(3).querySelector('.download-queue__progress')).toBeNull();
expect(
Array.from(
fixture.nativeElement.querySelectorAll<HTMLElement>(
'.download-queue__progress'
)
(
fixture.nativeElement as HTMLElement
).querySelectorAll<HTMLElement>('.download-queue__progress')
).map((bar) => bar.getAttribute('aria-label'))
).toEqual([
'Download progress for Download 1',
@@ -23,7 +23,7 @@ import {
PORTAL_SHELL_ACTIONS,
PortalCollectionContextService,
} from '@iptvnator/portal/shared/util';
import type { Playlist } from '@iptvnator/shared/interfaces';
import type { Playlist, XtreamCategory } from '@iptvnator/shared/interfaces';
import { DialogService } from '@iptvnator/ui/components';
import { BehaviorSubject, type Observable, Subject } from 'rxjs';
import { DownloadLibraryNavigationService } from './download-library-navigation.service';
@@ -47,6 +47,8 @@ interface ConfirmConfig {
}
interface ExpectedDownloadsComponent {
readonly activeCount: () => number;
readonly categories: () => readonly XtreamCategory[];
readonly model: () => {
readonly active: readonly { readonly item: DownloadItem }[];
readonly activeCount: number;
@@ -780,7 +782,9 @@ describe('DownloadsComponent', () => {
downloads.set([item]);
fixture.detectChanges();
const attention = fixture.nativeElement.querySelector<HTMLElement>(
const attention = (
fixture.nativeElement as HTMLElement
).querySelector<HTMLElement>(
'[data-test-id="downloads-attention-section"]'
);
const recover = attention?.querySelector<HTMLButtonElement>(
@@ -12,7 +12,10 @@ import {
import type { DownloadMetadataSnapshot } from '@iptvnator/shared/interfaces';
import type { WorkspaceNavigationTarget } from '@iptvnator/portal/shared/util';
import { BehaviorSubject } from 'rxjs';
import type { DownloadActionResult } from '../download-actions';
import type {
DownloadActionResult,
DownloadItemAction,
} from '../download-actions';
import { DownloadLibraryNavigationService } from '../download-library-navigation.service';
import { DownloadManagerActionsService } from '../download-manager-actions.service';
import { DownloadOfflineDetailComponent } from './download-offline-detail.component';
@@ -67,7 +70,10 @@ interface DownloadsFake {
interface ActionsFake {
readonly pendingIds: ReturnType<typeof signal<ReadonlySet<number>>>;
readonly run: jest.Mock<Promise<DownloadActionResult>, [unknown]>;
readonly run: jest.Mock<
Promise<DownloadActionResult>,
[DownloadItemAction]
>;
readonly showActionError: jest.Mock<void, []>;
}
@@ -3,12 +3,12 @@
"compilerOptions": {
"emitDecoratorMetadata": false,
"outDir": "../../../../dist/out-tsc",
"module": "commonjs",
"module": "preserve",
"target": "es2016",
"types": ["jest", "node"],
"moduleResolution": "node10"
"moduleResolution": "bundler"
},
"files": ["src/test-setup.ts"],
"files": ["src/test-setup.ts", "../../../../global.d.ts"],
"include": [
"jest.config.ts",
"src/**/*.test.ts",
@@ -135,10 +135,15 @@ describe('StreamResolverService EPG display offset', () => {
TestBed.inject(PlaylistsService).getPlaylistById = jest.fn(() =>
of({
_id: 'stalker-1',
title: 'Stalker Portal',
importDate: '2026-01-01T00:00:00.000Z',
lastUsage: '2026-01-01T00:00:00.000Z',
count: 0,
autoRefresh: false,
portalUrl: 'https://stalker.example.com',
macAddress: '00:11:22:33:44:55',
isFullStalkerPortal: false,
} satisfies Partial<Playlist>)
} satisfies Playlist)
);
const nowSeconds = Math.floor(Date.now() / 1000);
const entry = (id: string, name: string, startOffsetMin: number) => ({
@@ -192,10 +197,15 @@ describe('StreamResolverService EPG display offset', () => {
TestBed.inject(PlaylistsService).getPlaylistById = jest.fn(() =>
of({
_id: 'stalker-1',
title: 'Stalker Portal',
importDate: '2026-01-01T00:00:00.000Z',
lastUsage: '2026-01-01T00:00:00.000Z',
count: 0,
autoRefresh: false,
portalUrl: 'https://stalker.example.com',
macAddress: '00:11:22:33:44:55',
isFullStalkerPortal: false,
} satisfies Partial<Playlist>)
} satisfies Playlist)
);
const nowMs = Date.now();
const entry = (id: string, name: string, startOffsetMin: number) => ({
@@ -322,10 +332,15 @@ describe('StreamResolverService EPG display offset', () => {
epgOffsetMinutes = 0;
return of({
_id: 'xtream-1',
title: 'Xtream Playlist',
importDate: '2026-01-01T00:00:00.000Z',
lastUsage: '2026-01-01T00:00:00.000Z',
count: 0,
autoRefresh: false,
serverUrl: 'https://xtream.example.com',
username: 'user',
password: 'pass',
} satisfies Partial<Playlist>);
} satisfies Playlist);
});
xtreamApi.getFullEpg.mockResolvedValue([
listing('Really on air', -75, 60),
@@ -33,7 +33,11 @@ describe('StreamResolverService', () => {
ensureToken: jest.Mock;
makeAuthenticatedRequest: jest.Mock;
};
let epgBridge: Partial<EpgRuntimeBridgeService>;
let epgBridge: {
-readonly [
K in keyof EpgRuntimeBridgeService
]?: EpgRuntimeBridgeService[K];
};
beforeEach(() => {
playlistsService = {
@@ -710,7 +710,7 @@ export class StreamResolverService {
// epg_channel_id column but is not declared on the TS interface.
const epgKey = (
item as unknown as Record<string, string | undefined | null>
).epgChannelId?.trim();
)['epgChannelId']?.trim();
if (this.supportsProgramLookup && epgKey) {
const uploaded = await this.epgBridge
.getChannelPrograms(epgKey)
@@ -995,7 +995,7 @@ export class StreamResolverService {
string,
string | undefined | null
>
).epgChannelId?.trim();
)['epgChannelId']?.trim();
if (this.supportsProgramLookup && epgChannelKey) {
currentItem = await this.findCurrentInXmltv(
epgChannelKey,
@@ -126,7 +126,7 @@ describe('UnifiedFavoritesDataService', () => {
dbRemoveRecentItemsBatch: jest.fn(),
};
Object.defineProperty(window, 'electron', {
value: electronApi as Window['electron'],
value: electronApi as unknown as Window['electron'],
configurable: true,
});
@@ -177,11 +177,17 @@ describe('UnifiedFavoritesDataService', () => {
{
_id: 'm3u-1',
title: 'M3U List',
count: 0,
importDate: '2026-01-01T00:00:00.000Z',
autoRefresh: false,
favorites: ['https://example.com/2.m3u8', 'channel-1'],
},
{
_id: 'stalker-1',
title: 'Stalker List',
count: 0,
importDate: '2026-01-01T00:00:00.000Z',
autoRefresh: false,
macAddress: '00:11:22:33:44:55',
favorites: stalkerFavorites,
},
@@ -45,6 +45,9 @@ describe('UnifiedRecentDataService', () => {
const playlistMeta = {
_id: 'm3u-1',
title: 'M3U List',
count: 0,
importDate: '2026-01-01T00:00:00.000Z',
autoRefresh: false,
recentlyViewed: [
{
source: 'm3u',
@@ -225,7 +228,7 @@ describe('UnifiedRecentDataService', () => {
expect(playlistsService.addM3uRecentlyViewed).toHaveBeenCalledWith(
'm3u-1',
expect.objectContaining<M3uRecentlyViewedItem>({
expect.objectContaining<Partial<M3uRecentlyViewedItem>>({
source: 'm3u',
url: 'https://example.com/1.m3u8',
channel_id: 'channel-1',
@@ -1,5 +1,23 @@
import { signal } from '@angular/core';
import { TestBed } from '@angular/core/testing';
import { ParentalLockService } from '@iptvnator/services';
import { VodSourceDiscoveryService } from './vod-source-discovery.service';
const withholdsEverything = signal(false);
function createService(): VodSourceDiscoveryService {
TestBed.resetTestingModule();
TestBed.configureTestingModule({
providers: [
{
provide: ParentalLockService,
useValue: { withholdsEverything },
},
],
});
return TestBed.inject(VodSourceDiscoveryService);
}
/**
* Discovery talks to a foreign playlist, and Xtream carries the account in the
* URL — so anything it fails on has to go through the redacting logger before
@@ -17,7 +35,7 @@ describe('VodSourceDiscoveryService — failure logging', () => {
warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {
/* captured */
});
service = new VodSourceDiscoveryService();
service = createService();
});
afterEach(() => {
@@ -85,7 +103,7 @@ describe('VodSourceDiscoveryService — candidate mapping', () => {
row(4, undefined),
]),
};
const service = new VodSourceDiscoveryService();
const service = createService();
const result = await service.discover({
title: 'Dune',
@@ -111,3 +129,24 @@ describe('VodSourceDiscoveryService — candidate mapping', () => {
};
}
});
describe('VodSourceDiscoveryService — parental lock', () => {
afterEach(() => {
withholdsEverything.set(false);
delete (window as { electron?: unknown }).electron;
});
it('asks the worker nothing while the lock withholds everything', async () => {
const dbFindTitleSources = jest.fn().mockResolvedValue([]);
(window as { electron?: unknown }).electron = { dbFindTitleSources };
withholdsEverything.set(true);
const result = await createService().discover({
title: 'Dune',
currentPlaylistId: 'playlist-0',
});
expect(result.sources).toEqual([]);
expect(dbFindTitleSources).not.toHaveBeenCalled();
});
});
@@ -1,4 +1,5 @@
import { Injectable } from '@angular/core';
import { inject, Injectable } from '@angular/core';
import { ParentalLockService } from '@iptvnator/services';
import {
unambiguousCategoryLanguage,
type VodSourceCandidate,
@@ -43,6 +44,7 @@ export interface VodSourceDiscoveryResult {
@Injectable({ providedIn: 'root' })
export class VodSourceDiscoveryService {
private readonly logger = createLogger('VodSourceDiscovery');
private readonly parentalLock = inject(ParentalLockService);
get isAvailable(): boolean {
return (
@@ -59,7 +61,14 @@ export class VodSourceDiscoveryService {
matchKind: 'title-year',
};
if (!this.isAvailable || !request.title?.trim()) {
// The worker withholds locked rows through its own lock state and
// index; while the lock withholds everything (store unreadable, or
// a bridge without the worker filter) neither can be trusted.
if (
!this.isAvailable ||
!request.title?.trim() ||
this.parentalLock.withholdsEverything()
) {
return empty;
}
@@ -13,6 +13,10 @@ const active: SourceHealthResult = {
};
const playlist = (id: string, host = id): PlaylistMeta => ({
_id: id,
title: id,
count: 0,
importDate: '2026-01-01T00:00:00.000Z',
autoRefresh: false,
url: `https://${host}.test/list`,
});
@@ -3,12 +3,12 @@
"compilerOptions": {
"emitDecoratorMetadata": false,
"outDir": "../../../../dist/out-tsc",
"module": "commonjs",
"module": "preserve",
"target": "es2016",
"types": ["jest", "node"],
"moduleResolution": "node10"
"moduleResolution": "bundler"
},
"files": ["src/test-setup.ts"],
"files": ["src/test-setup.ts", "../../../../global.d.ts"],
"include": [
"jest.config.ts",
"src/**/*.test.ts",
@@ -49,7 +49,8 @@ export class CategoryViewComponent {
container.querySelectorAll('[data-category-id]')
) as HTMLElement[];
const selected = candidates.find(
(el) => el.dataset.categoryId === String(selectedCategory)
(el) =>
el.dataset['categoryId'] === String(selectedCategory)
);
if (!selected) {
return;
@@ -208,7 +208,7 @@ describe('UnifiedCollectionPageComponent', () => {
params: {},
},
paramMap: workspaceParamMap$.asObservable(),
} as ActivatedRoute;
} as unknown as ActivatedRoute;
route = {
snapshot: {
@@ -222,7 +222,7 @@ describe('UnifiedCollectionPageComponent', () => {
paramMap: routeParamMap$.asObservable(),
queryParamMap: routeQueryParamMap$.asObservable(),
pathFromRoot: [],
} as ActivatedRoute & {
} as unknown as ActivatedRoute & {
snapshot: {
paramMap: ReturnType<typeof convertToParamMap>;
queryParamMap: ReturnType<typeof convertToParamMap>;
@@ -607,6 +607,7 @@ describe('UnifiedLiveTabComponent', () => {
code: 'network-error',
player: 'videojs',
source: 'hls',
sourceUrl: 'https://example.com/xtream.m3u8',
container: '',
mimeType: '',
videoCodecs: [],
+3 -3
View File
@@ -3,12 +3,12 @@
"compilerOptions": {
"emitDecoratorMetadata": false,
"outDir": "../../../../dist/out-tsc",
"module": "commonjs",
"module": "preserve",
"target": "es2016",
"types": ["jest", "node"],
"moduleResolution": "node10"
"moduleResolution": "bundler"
},
"files": ["src/test-setup.ts"],
"files": ["src/test-setup.ts", "../../../../global.d.ts"],
"include": [
"jest.config.ts",
"src/**/*.test.ts",
@@ -33,7 +33,7 @@ describe('LivePanelsController', () => {
async function settle(): Promise<void> {
TestBed.flushEffects();
await new Promise((resolve) => queueMicrotask(resolve));
await new Promise<void>((resolve) => queueMicrotask(() => resolve()));
}
beforeEach(() => {
@@ -4,7 +4,15 @@ import {
runInInjectionContext,
} from '@angular/core';
import { TestBed } from '@angular/core/testing';
import { ActivatedRoute, convertToParamMap } from '@angular/router';
import {
ActivatedRoute,
ActivatedRouteSnapshot,
convertToParamMap,
} from '@angular/router';
type WritableSnapshot = {
-readonly [K in keyof ActivatedRouteSnapshot]: ActivatedRouteSnapshot[K];
};
import { BehaviorSubject } from 'rxjs';
import {
extractPortalPlaylistId,
@@ -36,7 +44,7 @@ describe('portal-route.utils', () => {
layout: 'workspace',
},
},
} as ActivatedRoute;
} as unknown as ActivatedRoute;
const childRoute = {
snapshot: {
data: {},
@@ -257,10 +265,11 @@ describe('portal-route.utils', () => {
expect(playlistId()).toBe('playlist-1');
route.snapshot.paramMap = convertToParamMap({
const snapshot = route.snapshot as WritableSnapshot;
snapshot.paramMap = convertToParamMap({
id: 'playlist-2',
});
route.snapshot.params = {
snapshot.params = {
id: 'playlist-2',
};
paramMap$.next(
@@ -296,7 +305,7 @@ describe('portal-route.utils', () => {
},
},
paramMap: parentParamMap$.asObservable(),
} as ActivatedRoute;
} as unknown as ActivatedRoute;
const route = {
snapshot: {
data: {},
@@ -316,10 +325,11 @@ describe('portal-route.utils', () => {
expect(playlistId()).toBe('playlist-1');
(parentRoute.snapshot as ActivatedRoute['snapshot']).paramMap =
convertToParamMap({
(parentRoute.snapshot as WritableSnapshot).paramMap = convertToParamMap(
{
id: 'playlist-2',
});
}
);
(parentRoute.snapshot as ActivatedRoute['snapshot']).params = {
id: 'playlist-2',
};
@@ -105,7 +105,9 @@ describe('createVodWatchedToggle', () => {
applyPosition: (next) => position.set(next),
playingNow,
notify: (kind) => feedback.push(kind),
onPersisted: (playlistId) => persisted.push(playlistId),
onPersisted: (playlistId) => {
persisted.push(playlistId);
},
logger,
});
const target = (stillCurrent = () => true) => ({
+7
View File
@@ -0,0 +1,7 @@
// Dependency-free entry for the DI tokens the web app's root shell and the
// eager Xtream data layer provide or inject. The main barrel would put every
// portal helper that lazy routes use onto the renderer's initial path.
export * from './lib/portal-external-playback';
export * from './lib/portal-playback-positions';
export * from './lib/portal-player';
export * from './lib/portal-shell-actions';
+3 -3
View File
@@ -3,12 +3,12 @@
"compilerOptions": {
"emitDecoratorMetadata": false,
"outDir": "../../../../dist/out-tsc",
"module": "commonjs",
"module": "preserve",
"target": "es2016",
"types": ["jest", "node"],
"moduleResolution": "node10"
"moduleResolution": "bundler"
},
"files": ["src/test-setup.ts"],
"files": ["src/test-setup.ts", "../../../../global.d.ts"],
"include": [
"jest.config.ts",
"src/**/*.test.ts",
@@ -1,4 +1,7 @@
import { PlaylistMeta } from '@iptvnator/shared/interfaces';
import {
PlaylistMeta,
PlaylistUpdateState,
} from '@iptvnator/shared/interfaces';
import {
STALKER_MAG_USER_AGENT,
STALKER_STREAM_USER_AGENT,
@@ -16,8 +19,8 @@ function createPlaylist(overrides: Partial<PlaylistMeta> = {}): PlaylistMeta {
url: '',
importDate: '',
filePath: '',
updateDate: '',
updateState: '',
updateDate: 0,
updateState: PlaylistUpdateState.NOT_UPDATED,
position: 0,
autoRefresh: false,
favorites: [],
@@ -25,7 +25,7 @@ const MISCLASSIFIED = {
portalUrl: 'http://ministra.example/server/load.php',
macAddress: '00:1A:79:AA:BB:CC',
isFullStalkerPortal: false,
} as PlaylistMeta;
} as PlaylistMeta & { portalUrl: string };
async function flushMicrotasks(): Promise<void> {
for (let i = 0; i < 4; i += 1) {
@@ -470,9 +470,9 @@ describe('StalkerSessionService identity payloads', () => {
expect(payload.params).not.toHaveProperty('device_id2');
expect(payload.params).not.toHaveProperty('signature');
expect(payload.params).not.toHaveProperty('signature2');
expect(JSON.parse(String(payload.params.metrics))).not.toHaveProperty(
'sn'
);
expect(
JSON.parse(String(payload.params['metrics']))
).not.toHaveProperty('sn');
});
it('sends provided SN, device IDs, and signatures exactly in get_profile', async () => {
@@ -505,7 +505,7 @@ describe('StalkerSessionService identity payloads', () => {
signature2: 'SIGNATURE-2',
})
);
expect(JSON.parse(String(payload.params.metrics))).toEqual(
expect(JSON.parse(String(payload.params['metrics']))).toEqual(
expect.objectContaining({
sn: 'CUSTOMSN123',
})
@@ -112,7 +112,9 @@ describe('stalker-vod.utils regressions', () => {
cmd: '/media/file_42.mpg',
})
);
expect(state.vodDetailsItem?.data.id).toBe('42');
expect(state.vodDetailsItem?.data).toEqual(
expect.objectContaining({ id: '42' })
);
});
it('favorite toggle uses completion callback path without delayed state update', () => {
@@ -8,8 +8,10 @@ import { DataService, PlaylistsService } from '@iptvnator/services';
import { StalkerSessionService } from './stalker-session.service';
import { StalkerStore } from './stalker.store';
type StalkerStoreMember = keyof InstanceType<typeof StalkerStore>;
describe('StalkerStore API compatibility smoke', () => {
let store: StalkerStore;
let store: InstanceType<typeof StalkerStore>;
beforeEach(() => {
TestBed.configureTestingModule({
@@ -25,7 +27,6 @@ describe('StalkerStore API compatibility smoke', () => {
{
provide: StalkerSessionService,
useValue: {
ensureToken: jest.fn(),
makeAuthenticatedRequest: jest.fn(),
ensureToken: jest.fn().mockResolvedValue({
token: null,
@@ -75,7 +76,7 @@ describe('StalkerStore API compatibility smoke', () => {
});
it('exposes compatibility state signals', () => {
const expectedSignals = [
const expectedSignals: StalkerStoreMember[] = [
'selectedContentType',
'selectedCategoryId',
'selectedVodId',
@@ -102,7 +103,7 @@ describe('StalkerStore API compatibility smoke', () => {
});
it('exposes compatibility computed selectors', () => {
const expectedComputed = [
const expectedComputed: StalkerStoreMember[] = [
// getTotalPages was removed with catalog pagination — the grid
// appends portal pages and pages have no UI representation left.
'hasMoreContent',
@@ -126,7 +127,7 @@ describe('StalkerStore API compatibility smoke', () => {
});
it('exposes compatibility methods and internal resources', () => {
const expectedMethods = [
const expectedMethods: StalkerStoreMember[] = [
'setSelectedContentType',
'setSelectedCategory',
'setSelectedSerialId',
@@ -2,7 +2,7 @@ import { signal } from '@angular/core';
import { TestBed } from '@angular/core/testing';
import { patchState, signalStore, withMethods, withState } from '@ngrx/signals';
import { TranslateService } from '@ngx-translate/core';
import { DataService } from '@iptvnator/services';
import { DataService, ParentalLockService } from '@iptvnator/services';
import {
CONNECTIVITY_GUARD_RESET,
PlaylistMeta,
@@ -34,6 +34,7 @@ const PLAYLIST = {
} as PlaylistMeta;
const TestContentStore = signalStore(
{ protectedState: false },
withState({
currentPlaylist: undefined as PlaylistMeta | undefined,
selectedContentType: 'vod' as 'vod' | 'series' | 'itv' | 'radio',
@@ -58,6 +59,11 @@ const TestContentStore = signalStore(
withStalkerContent()
);
/** Request payload the content feature sends through `sendIpcEvent`. */
interface StalkerIpcRequest {
params?: { action?: string; p?: number; search?: string };
}
function createDeferred<T>() {
let resolve!: (value: T) => void;
let reject!: (reason?: unknown) => void;
@@ -136,18 +142,34 @@ function createItvCacheMock(
describe('withStalkerContent failure states', () => {
let store: InstanceType<typeof TestContentStore>;
let dataService: {
sendIpcEvent: jest.Mock<Promise<unknown>, unknown[]>;
sendIpcEvent: jest.Mock<
Promise<unknown>,
[event: string, request: StalkerIpcRequest]
>;
};
let parentalLock: {
active: jest.Mock<boolean, []>;
version: ReturnType<typeof signal<number>>;
lockedStalkerIds: jest.Mock<string[], [string, string]>;
};
beforeEach(() => {
dataService = {
sendIpcEvent: jest.fn(),
};
parentalLock = {
active: jest.fn(() => false),
version: signal(0),
lockedStalkerIds: jest.fn(
(_playlistId: string, _type: string): string[] => []
),
};
TestBed.configureTestingModule({
providers: [
TestContentStore,
{ provide: DataService, useValue: dataService },
{ provide: ParentalLockService, useValue: parentalLock },
{
provide: StalkerItvCacheService,
useValue: createItvCacheMock(),
@@ -176,11 +198,10 @@ describe('withStalkerContent failure states', () => {
it('keeps ITV pages unfiltered while independent local searches change', async () => {
const pending =
createDeferred<ReturnType<typeof createContentResponse>>();
dataService.sendIpcEvent.mockImplementation(
(_event, request: { params: { action: string } }) =>
request.params.action === 'get_genres'
? Promise.resolve({ js: [] })
: pending.promise
dataService.sendIpcEvent.mockImplementation((_event, request) =>
request.params?.action === 'get_genres'
? Promise.resolve({ js: [] })
: pending.promise
);
store.setSelectedContentType('itv');
store.setCategories('itv', [
@@ -192,15 +213,10 @@ describe('withStalkerContent failure states', () => {
await flushResources();
const orderedCalls = () =>
dataService.sendIpcEvent.mock.calls.filter(
(call) =>
(call[1] as { params: { action: string } }).params
.action === 'get_ordered_list'
(call) => call[1].params?.action === 'get_ordered_list'
);
await waitForCondition(() => orderedCalls().length > 0);
expect(
(orderedCalls()[0][1] as { params: { search?: string } }).params
.search
).toBeUndefined();
expect(orderedCalls()[0][1].params?.search).toBeUndefined();
patchState(store, { searchPhrase: 'changed' });
await flushResources();
pending.resolve(createContentResponse('Panel match'));
@@ -590,6 +606,149 @@ describe('withStalkerContent failure states', () => {
expect(store.totalCount()).toBe(2);
});
it('pages past a VOD page made only of parental-locked rows', async () => {
parentalLock.active.mockReturnValue(true);
parentalLock.lockedStalkerIds.mockReturnValue(['9']);
dataService.sendIpcEvent.mockImplementation(
(_event: unknown, payload: { params?: { p?: number } }) => {
const page = Number(payload.params?.p ?? 1);
// Page 1: one visible film. Page 2: locked rows only. Page 3:
// the visible film paging must still reach.
const data =
page === 1
? [{ id: 'movie-1', name: 'One', category_id: '5' }]
: page === 2
? [
{ id: 'adult-1', name: 'A', category_id: '9' },
{ id: 'adult-2', name: 'B', category_id: '9' },
]
: [
{
id: 'movie-3',
name: 'Three',
category_id: '5',
},
];
return Promise.resolve({ js: { data, total_items: 4 } });
}
);
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 === 1);
expect(store.hasMoreContent()).toBe(true);
// The append lands on the fully withheld page; the loader must ask
// for the next one by itself instead of ending the list.
store.setPage(1);
await waitForCondition(
() => store.getPaginatedContent().length === 2,
60
);
expect(store.getPaginatedContent().map((item) => item.id)).toEqual([
'movie-1',
'movie-3',
]);
// Both withheld ids are subtracted from the portal's total.
expect(store.totalCount()).toBe(2);
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('takes page-one rows of a newly locked genre off screen before the reload answers', async () => {
let hangReload = false;
dataService.sendIpcEvent.mockImplementation(() =>
hangReload
? new Promise(() => undefined)
: Promise.resolve({
js: {
data: [
{ id: 'movie-1', name: 'One', category_id: '5' },
{ id: 'adult-1', name: 'A', category_id: '9' },
],
total_items: 2,
},
})
);
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);
// Lock now while the replacement request hangs.
hangReload = true;
parentalLock.active.mockReturnValue(true);
parentalLock.lockedStalkerIds.mockReturnValue(['9']);
parentalLock.version.set(1);
// Page 1 blanks the grid synchronously before its request, so the
// locked row is gone even though the reload never answers.
await waitForCondition(() => store.getPaginatedContent().length === 0);
expect(dataService.sendIpcEvent).toHaveBeenCalledTimes(2);
});
it('keeps accumulated pages when an append fails and retries the same page', async () => {
let failPageTwo = true;
dataService.sendIpcEvent.mockImplementation(
@@ -810,7 +969,10 @@ describe('withStalkerContent full ITV channel list cache', () => {
let store: InstanceType<typeof TestContentStore>;
let dataService: {
sendIpcEvent: jest.Mock<Promise<unknown>, unknown[]>;
sendIpcEvent: jest.Mock<
Promise<unknown>,
[event: string, request: StalkerIpcRequest]
>;
};
let itvCache: ReturnType<typeof createItvCacheMock>;
@@ -1041,19 +1203,18 @@ describe('withStalkerContent full ITV channel list cache', () => {
it('keeps censored pagination after a delayed cache replays the current page', async () => {
setup(null);
dataService.sendIpcEvent.mockImplementation(
(_event, payload: { params: { p: number } }) =>
Promise.resolve({
js: {
data: [
{
id: String(payload.params.p),
name: `Hidden ${payload.params.p}`,
},
],
total_items: 3,
},
})
dataService.sendIpcEvent.mockImplementation((_event, payload) =>
Promise.resolve({
js: {
data: [
{
id: String(payload.params?.p),
name: `Hidden ${payload.params?.p}`,
},
],
total_items: 3,
},
})
);
enterItvCategory('1099');
await waitForCondition(() => store.itvChannels().length === 1);
@@ -9,7 +9,13 @@ import {
} from '@ngrx/signals';
import { TranslateService } from '@ngx-translate/core';
import { createLogger } from '@iptvnator/portal/shared/util';
import { DataService, resetHostConnectivityGuard } from '@iptvnator/services';
import {
DataService,
ParentalLockService,
resetHostConnectivityGuard,
} from '@iptvnator/services';
import type { PlaylistMeta } from '@iptvnator/shared/interfaces';
import { ALL_CATEGORIES_WITHHELD } from '@iptvnator/shared/interfaces';
import {
StalkerCategoryItem,
StalkerContentItem,
@@ -31,8 +37,29 @@ import {
filterItvChannelsByGenre,
toStalkerContentItem,
toStalkerItvChannel,
stalkerWithheldRowKey,
withoutWithheldStalkerItems,
} from '../utils';
/**
* Genre ids of the given section that the parental lock currently withholds
* for this portal; empty while unlocked or off.
*/
function withheldStalkerCategoryIds(
parentalLock: ParentalLockService,
playlist: PlaylistMeta | undefined,
contentType: StalkerContentType
): ReadonlySet<string> {
const playlistId = playlist?._id;
if (!playlistId || !parentalLock.active()) {
return new Set();
}
if (parentalLock.withholdsEverything?.()) {
return ALL_CATEGORIES_WITHHELD;
}
return new Set(parentalLock.lockedStalkerIds(playlistId, contentType));
}
/**
* Content/categories/channels feature state.
*/
@@ -250,7 +277,8 @@ export function withStalkerContent() {
stalkerSession = inject(StalkerSessionService),
portalRepair = inject(StalkerPortalRepairService),
translateService = inject(TranslateService),
itvCache = inject(StalkerItvCacheService)
itvCache = inject(StalkerItvCacheService),
parentalLock = inject(ParentalLockService)
) => {
const storeContext = store as typeof store &
StalkerContentResourceStoreContract;
@@ -261,6 +289,13 @@ export function withStalkerContent() {
};
let lastLivePageKey = '';
// Withheld (parental-locked) row ids seen while accumulating
// the current list. A page that adds only withheld ids still
// counts as progress, so paging continues past it; a page
// 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: () => ({
@@ -412,6 +447,10 @@ export function withStalkerContent() {
(category) =>
String(category.category_id) !== '*'
).length,
// Lock/unlock re-fires the loader: rows of a
// locked genre are dropped at patch time, so the
// list must be rebuilt when they become visible.
parentalLockVersion: parentalLock.version(),
}),
loader: async ({
params,
@@ -457,15 +496,74 @@ export function withStalkerContent() {
}
const categoryParam = params.category || '*';
const withheldCategoryIds =
withheldStalkerCategoryIds(
parentalLock,
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 =
itvCache.getChannels(playlist);
const channels =
cachedChannels !== null
? filterItvChannelsByGenre(
cachedChannels,
categoryParam
? withoutWithheldStalkerItems(
filterItvChannelsByGenre(
cachedChannels,
categoryParam
),
params.contentType,
withheldCategoryIds
)
: null;
// Serve from the cache only when it actually
@@ -522,6 +620,8 @@ export function withStalkerContent() {
params.pageIndex ===
storeContext.page() + 1 &&
paramsPlaylistKey === currentPlaylistKey &&
params.parentalLockVersion ===
parentalLock.version() &&
// A legacy paged response must not overwrite
// the full cached list that a re-fired
// loader served in the meantime. Scoped
@@ -609,12 +709,70 @@ export function withStalkerContent() {
return [];
}
const newItems = response.js.data.map((item) =>
const rawItems = response.js.data.map((item) =>
toStalkerContentItem(
item,
playlist.portalUrl ?? ''
)
);
const newItems = withoutWithheldStalkerItems(
rawItems,
params.contentType,
withheldCategoryIds
);
const listKey = JSON.stringify([
paramsPlaylistKey,
params.contentType,
params.category,
params.search,
]);
if (
params.pageIndex === 1 ||
withheldSeenKey !== listKey
) {
withheldSeenKey = listKey;
withheldSeenIds.clear();
}
let newWithheldCount = 0;
if (newItems.length < rawItems.length) {
const kept = new Set(newItems);
for (const item of rawItems) {
if (kept.has(item)) {
continue;
}
const id = stalkerWithheldRowKey(item);
if (!withheldSeenIds.has(id)) {
withheldSeenIds.add(id);
newWithheldCount += 1;
}
}
}
// A page made only of withheld rows would
// leave the list unchanged; request the next
// one so unlocked rows further on still load.
const skipWithheldPage = (hasMore: boolean) => {
if (
!hasMore ||
newItems.length > 0 ||
newWithheldCount === 0
) {
return;
}
queueMicrotask(() => {
if (isCurrentRequest()) {
// `page` belongs to the selection
// feature; the facade composes
// its setter ahead of this one.
(
store as unknown as {
setPage?: (
page: number
) => void;
}
).setPage?.(params.pageIndex);
}
});
};
if (
params.contentType === 'itv' ||
@@ -645,6 +803,16 @@ export function withStalkerContent() {
const replay =
livePageKey === lastLivePageKey;
lastLivePageKey = livePageKey;
const hasMoreChannels =
rawItems.length > 0 &&
(params.pageIndex === 1 ||
replay ||
newWithheldCount > 0 ||
nextChannels.length >
existingChannels.length) &&
nextChannels.length +
withheldSeenIds.size <
(response.js.total_items ?? 0);
patchState(store, {
totalCount:
response.js.total_items ?? 0,
@@ -662,15 +830,9 @@ export function withStalkerContent() {
},
}
: { radioChannels: nextChannels }),
hasMoreChannels:
channels.length > 0 &&
(params.pageIndex === 1 ||
replay ||
nextChannels.length >
existingChannels.length) &&
nextChannels.length <
(response.js.total_items ?? 0),
hasMoreChannels,
});
skipWithheldPage(hasMoreChannels);
} else {
// VOD/series pages accumulate into one
// continuous list for the infinite-scroll
@@ -694,18 +856,30 @@ export function withStalkerContent() {
// end on every scroll crossing.
const appendStalled =
params.pageIndex > 1 &&
newWithheldCount === 0 &&
nextContent.length <=
previousContent.length;
// Withheld rows count against the portal's
// total, or the grid would keep asking for
// pages the lock will never let it show.
const totalCount = appendStalled
? nextContent.length
: Math.max(
0,
(response.js.total_items ?? 0) -
withheldSeenIds.size
);
patchState(store, {
totalCount: appendStalled
? nextContent.length
: (response.js.total_items ?? 0),
totalCount,
paginatedContent: nextContent,
contentError: null,
appendError: null,
hasMoreChannels: false,
});
skipWithheldPage(
nextContent.length < totalCount
);
return nextContent;
}
@@ -744,16 +918,40 @@ export function withStalkerContent() {
const storeContext = store as typeof store &
StalkerContentResourceStoreContract;
const itvCache = inject(StalkerItvCacheService);
const parentalLock = inject(ParentalLockService);
/**
* The whole portal's ITV channel list (all categories) when
* cached. `versionFor` establishes the reactive dependency so this
* cached, minus the genres the parental lock withholds.
* `versionFor` establishes the reactive dependency so this
* recomputes when the list becomes ready or is refreshed.
*/
const itvFullChannelList = computed(() => {
const playlist = storeContext.currentPlaylist();
itvCache.versionFor(playlist);
return itvCache.getChannels(playlist) ?? [];
parentalLock.version();
return withoutWithheldStalkerItems(
itvCache.getChannels(playlist) ?? [],
'itv',
withheldStalkerCategoryIds(parentalLock, playlist, 'itv')
);
});
/** The selected section's genres with the withheld ones removed. */
const visibleCategoryResource = computed(() => {
parentalLock.version();
const contentType = storeContext.selectedContentType();
const withheld = withheldStalkerCategoryIds(
parentalLock,
storeContext.currentPlaylist(),
contentType
);
const categories = getCategoriesByType(store, contentType);
return withheld.size === 0
? categories
: categories.filter(
(category) =>
!withheld.has(String(category.category_id))
);
});
/**
@@ -909,7 +1107,9 @@ export function withStalkerContent() {
storeContext.getContentResource.isLoading()
),
isPaginatedContentFailed: computed(() => store.contentError()),
getCategoryResource: computed(() =>
getCategoryResource: visibleCategoryResource,
/** Every genre of the section, locked ones included (lock dialog). */
getAllCategoriesForSelectedType: computed(() =>
getCategoriesByType(
store,
storeContext.selectedContentType()
@@ -179,7 +179,7 @@ describe('withStalkerFavorites', () => {
playlist: {
_id: 'portal-1',
favorites: [],
} as PlaylistMeta,
} as Partial<PlaylistMeta> as PlaylistMeta,
})
);
expect(snackBar.open).toHaveBeenCalledWith(
@@ -1,7 +1,7 @@
import { TestBed } from '@angular/core/testing';
import { signalStore } from '@ngrx/signals';
import { DataService, RuntimeCapabilitiesService } from '@iptvnator/services';
import { PlaylistMeta } from '@iptvnator/shared/interfaces';
import { ElectronBridgeApi, PlaylistMeta } from '@iptvnator/shared/interfaces';
import { StalkerPortalRepairService } from '../../stalker-portal-repair.service';
import { StalkerSessionService } from '../../stalker-session.service';
import { withStalkerPortal } from './with-stalker-portal.feature';
@@ -48,7 +48,7 @@ describe('withStalkerPortal', () => {
value: {
dbCreatePlaylist,
dbGetPlaylist,
} as Window['electron'],
} satisfies Partial<ElectronBridgeApi>,
configurable: true,
});
@@ -138,5 +138,4 @@ describe('withStalkerPortal', () => {
expect(dbGetPlaylist).not.toHaveBeenCalled();
expect(dbCreatePlaylist).not.toHaveBeenCalled();
});
});
@@ -174,7 +174,7 @@ describe('withStalkerRecent', () => {
playlist: {
_id: 'portal-1',
recentlyViewed: [],
} as PlaylistMeta,
} as Partial<PlaylistMeta> as PlaylistMeta,
})
);
expect(onComplete).toHaveBeenCalled();
@@ -30,6 +30,7 @@ const PLAYLIST = {
} as PlaylistMeta;
const TestSeriesStore = signalStore(
{ protectedState: false },
withState({
currentPlaylist: undefined as PlaylistMeta | undefined,
}),
@@ -4,4 +4,5 @@ export * from './stalker-playback-command.utils';
export * from './stalker-player-request.utils';
export * from './stalker-request.utils';
export * from './stalker-content-mappers';
export * from './stalker-parental-lock.utils';
export * from './stalker-sorting';
@@ -0,0 +1,79 @@
import {
isStalkerItemWithheld,
stalkerWithheldRowKey,
withoutWithheldStalkerItems,
} from './stalker-parental-lock.utils';
import { ALL_CATEGORIES_WITHHELD } from '@iptvnator/shared/interfaces';
describe('stalker-parental-lock.utils', () => {
const withheld = new Set(['7', '9']);
it('reads the genre from tv_genre_id for live and radio rows', () => {
expect(isStalkerItemWithheld({ tv_genre_id: 7 }, 'itv', withheld)).toBe(
true
);
expect(
isStalkerItemWithheld({ tv_genre_id: '9' }, 'radio', withheld)
).toBe(true);
expect(isStalkerItemWithheld({ tv_genre_id: 8 }, 'itv', withheld)).toBe(
false
);
// A VOD row with a matching category_id is not a live row.
expect(
isStalkerItemWithheld({ category_id: '7' }, 'itv', withheld)
).toBe(false);
});
it('reads category_id for VOD and series rows', () => {
expect(
isStalkerItemWithheld({ category_id: '7' }, 'vod', withheld)
).toBe(true);
expect(
isStalkerItemWithheld({ category_id: 9 }, 'series', withheld)
).toBe(true);
expect(isStalkerItemWithheld({}, 'vod', withheld)).toBe(false);
});
it('returns the same array when nothing is withheld', () => {
const items = [{ category_id: '7' }];
expect(withoutWithheldStalkerItems(items, 'vod', new Set())).toBe(
items
);
expect(withoutWithheldStalkerItems(items, 'vod', withheld)).toEqual([]);
});
});
describe('isStalkerItemWithheld in fail-closed mode', () => {
it('withholds rows without a genre only while everything is withheld', () => {
expect(isStalkerItemWithheld({}, 'itv', ALL_CATEGORIES_WITHHELD)).toBe(
true
);
expect(
isStalkerItemWithheld(
{ name: 'x' } as never,
'vod',
ALL_CATEGORIES_WITHHELD
)
).toBe(true);
expect(isStalkerItemWithheld({}, 'itv', new Set(['9']))).toBe(false);
expect(
withoutWithheldStalkerItems(
[{ tv_genre_id: '1' }, {}],
'itv',
ALL_CATEGORIES_WITHHELD
)
).toEqual([]);
});
});
describe('stalkerWithheldRowKey', () => {
it('keys rows by whichever id field they carry, never by a shared empty string', () => {
expect(stalkerWithheldRowKey({ id: 5 })).toBe('id:5');
expect(stalkerWithheldRowKey({ stream_id: '6' })).toBe('id:6');
expect(stalkerWithheldRowKey({ movie_id: 7 })).toBe('id:7');
expect(stalkerWithheldRowKey({ series_id: 8 })).toBe('id:8');
expect(stalkerWithheldRowKey({ cmd: 'ffmpeg http://a' })).not.toBe(
stalkerWithheldRowKey({ cmd: 'ffmpeg http://b' })
);
});
});
@@ -0,0 +1,69 @@
import { ALL_CATEGORIES_WITHHELD } from '@iptvnator/shared/interfaces';
import type { StalkerContentType } from '../stalker-store.contracts';
/** Minimal item shape the parental lock needs: the category a row belongs to. */
export interface StalkerLockableItem {
tv_genre_id?: string | number;
category_id?: string | number;
id?: string | number;
stream_id?: string | number;
movie_id?: string | number;
series_id?: string | number;
cmd?: string;
name?: string;
}
/**
* The key the paging bookkeeping records a withheld row under. Stalker rows
* carry their id in one of several fields; keying every id-less row under
* `''` would count only the first of them as new, and a page of new locked
* rows would then look like a stalled portal and end paging short of later
* visible matches.
*/
export function stalkerWithheldRowKey(item: StalkerLockableItem): string {
const id = item.id ?? item.stream_id ?? item.movie_id ?? item.series_id;
if (id !== undefined && id !== null && String(id) !== '') {
return `id:${String(id)}`;
}
return `row:${String(item.cmd ?? item.name ?? '')}`;
}
/**
* Whether an item belongs to one of the withheld categories. Live and radio
* rows carry their genre in `tv_genre_id`; VOD and series rows carry
* `category_id`. Only matters for portal-wide lists ("All" and search),
* since a withheld category can never be selected on its own.
*/
export function isStalkerItemWithheld(
item: StalkerLockableItem,
contentType: StalkerContentType,
withheldCategoryIds: ReadonlySet<string>
): boolean {
if (withheldCategoryIds.size === 0) {
return false;
}
const categoryId =
contentType === 'itv' || contentType === 'radio'
? item.tv_genre_id
: item.category_id;
if (categoryId === undefined || categoryId === null) {
// A row without a genre is visible under a normal lock set but not
// in fail-closed mode: while the locks are unknown, "no genre" must
// not become the one row the withheld catalog still shows.
return withheldCategoryIds === ALL_CATEGORIES_WITHHELD;
}
return withheldCategoryIds.has(String(categoryId));
}
export function withoutWithheldStalkerItems<T extends StalkerLockableItem>(
items: T[],
contentType: StalkerContentType,
withheldCategoryIds: ReadonlySet<string>
): T[] {
if (withheldCategoryIds.size === 0) {
return items;
}
return items.filter(
(item) => !isStalkerItemWithheld(item, contentType, withheldCategoryIds)
);
}
@@ -3,12 +3,12 @@
"compilerOptions": {
"emitDecoratorMetadata": false,
"outDir": "../../../../dist/out-tsc",
"module": "commonjs",
"module": "preserve",
"target": "es2016",
"types": ["jest", "node"],
"moduleResolution": "node10"
"moduleResolution": "bundler"
},
"files": ["src/test-setup.ts"],
"files": ["src/test-setup.ts", "../../../../global.d.ts"],
"include": [
"jest.config.ts",
"src/**/*.test.ts",
+1
View File
@@ -4,6 +4,7 @@ export * from './lib/stalker-collection-detail.component';
export * from './lib/stalker-catalog-detail/stalker-catalog-detail.component';
export * from './lib/stalker-favorites-button/stalker-favorites-button.component';
export * from './lib/stalker-catalog-facade.service';
export * from './lib/stalker-category-lock-dialog/stalker-category-lock-dialog.component';
export * from './lib/stalker-feature.routes';
export * from './lib/stalker-live-stream-layout/stalker-live-stream-layout.component';
export * from './lib/stalker-search/stalker-search.component';
@@ -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,
]);
};
}
@@ -61,7 +61,7 @@ export class StalkerActorRouteComponent {
initialValue: this.route.snapshot.params,
});
private readonly personId = computed(() =>
Number(this.routeParams().personId)
Number(this.routeParams()['personId'])
);
readonly profile = signal<ActorProfile | null>(null);
@@ -0,0 +1,105 @@
<h2 mat-dialog-title>{{ 'PARENTAL_LOCK.MANAGE_LOCKS' | translate }}</h2>
<mat-dialog-content>
<p class="lock-dialog__description">
{{ 'PARENTAL_LOCK.MANAGE_LOCKS_DESCRIPTION' | translate }}
</p>
<div class="header-actions">
<div class="selection-info">
{{ 'PARENTAL_LOCK.LOCKED_COUNT' | translate }}:
<strong>{{ lockedCount() }}</strong> / {{ categories().length }}
</div>
<div class="bulk-actions">
@if (censoredCount() > 0) {
<button
mat-stroked-button
type="button"
data-test-id="stalker-lock-censored"
[disabled]="isSaving()"
(click)="lockCensored()"
>
{{ 'PARENTAL_LOCK.LOCK_CENSORED' | translate }}
</button>
}
<button
mat-stroked-button
type="button"
[disabled]="isSaving() || lockedCount() === 0"
(click)="unlockAll()"
>
{{ 'PARENTAL_LOCK.UNLOCK_ALL' | translate }}
</button>
</div>
</div>
<label class="search-inline">
<mat-icon class="search-inline__icon">search</mat-icon>
<input
type="search"
spellcheck="false"
[value]="searchTerm()"
(input)="searchTerm.set($any($event.target).value)"
[placeholder]="'XTREAM.CATEGORY_MANAGEMENT.SEARCH_PLACEHOLDER' | translate"
/>
@if (searchTerm()) {
<button
type="button"
mat-icon-button
class="search-inline__clear"
(click)="clearSearch()"
>
<mat-icon>close</mat-icon>
</button>
}
</label>
<div class="categories-list">
@for (category of filteredCategories(); track category.id) {
<button
type="button"
class="category-item"
[class.category-item--locked]="category.locked"
[attr.aria-pressed]="category.locked"
[attr.data-test-id]="'stalker-category-lock-' + category.id"
(click)="toggle(category)"
>
<mat-icon class="category-item__icon">{{
category.locked ? 'lock' : 'lock_open'
}}</mat-icon>
<span class="category-name">{{ category.name }}</span>
@if (category.censored) {
<span
class="censored-chip"
[matTooltip]="'PARENTAL_LOCK.CENSORED_HINT' | translate"
>18+</span
>
}
</button>
} @empty {
<div class="empty-message">
{{ 'XTREAM.CATEGORY_MANAGEMENT.NO_RESULTS' | translate }}
</div>
}
</div>
</mat-dialog-content>
<mat-dialog-actions align="end">
<button mat-button type="button" (click)="cancel()" [disabled]="isSaving()">
{{ 'CLOSE' | translate }}
</button>
<button
mat-flat-button
color="primary"
type="button"
data-test-id="stalker-category-lock-save"
(click)="save()"
[disabled]="isSaving()"
>
@if (isSaving()) {
<mat-spinner diameter="20" />
} @else {
{{ 'XTREAM.CATEGORY_MANAGEMENT.SAVE' | translate }}
}
</button>
</mat-dialog-actions>
@@ -0,0 +1,161 @@
.mat-mdc-dialog-content {
display: flex;
flex-direction: column;
gap: 10px;
}
.lock-dialog__description {
margin: 0;
font-size: 0.8125rem;
color: var(--mat-sys-on-surface-variant);
}
.header-actions {
display: flex;
justify-content: space-between;
align-items: center;
flex-wrap: wrap;
gap: 12px;
}
.selection-info {
font-size: 0.8125rem;
color: var(--mat-sys-on-surface-variant);
strong {
color: var(--mat-sys-primary);
}
}
.bulk-actions {
display: flex;
gap: 8px;
button {
font-size: 12px;
}
}
.search-inline {
display: flex;
align-items: center;
gap: 8px;
padding: 0 10px;
min-height: 36px;
border-radius: 10px;
border: 1px solid var(--app-search-border, var(--mat-sys-outline-variant));
background: var(--app-search-bg, var(--mat-sys-surface-container));
cursor: text;
&:focus-within {
border-color: var(--mat-sys-primary);
}
&__icon {
font-size: 18px;
width: 18px;
height: 18px;
color: var(--mat-sys-on-surface-variant);
}
input {
flex: 1;
border: none;
outline: none;
background: transparent;
color: var(--mat-sys-on-surface);
font: inherit;
font-size: 0.84rem;
min-width: 0;
&::-webkit-search-cancel-button {
display: none;
}
}
&__clear {
width: 24px;
height: 24px;
min-width: 24px;
padding: 0;
mat-icon {
font-size: 16px;
width: 16px;
height: 16px;
}
}
}
.categories-list {
flex: 1;
min-height: 200px;
max-height: 50vh;
overflow-y: auto;
border: 1px solid var(--mat-sys-outline-variant);
border-radius: 8px;
}
.category-item {
display: flex;
align-items: center;
gap: 8px;
width: 100%;
padding: 0 10px;
min-height: 32px;
border: none;
background: transparent;
color: var(--mat-sys-on-surface);
font: inherit;
font-size: 0.8125rem;
text-align: left;
cursor: pointer;
&:hover {
background-color: var(--mat-sys-surface-container);
}
&:not(:last-child) {
border-bottom: 1px solid var(--mat-sys-surface-container);
}
&__icon {
font-size: 18px;
width: 18px;
height: 18px;
color: var(--mat-sys-on-surface-variant);
}
&--locked .category-item__icon {
color: var(--mat-sys-primary);
}
}
.category-name {
flex: 1;
min-width: 0;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.censored-chip {
font-size: 0.6875rem;
font-weight: 600;
padding: 1px 6px;
border-radius: 999px;
background: color-mix(in srgb, var(--mat-sys-error) 16%, transparent);
color: var(--mat-sys-error);
}
.empty-message {
padding: 24px;
text-align: center;
color: var(--mat-sys-on-surface-variant);
font-size: 0.8125rem;
}
:host ::ng-deep .mat-mdc-dialog-content {
min-width: 440px;
max-width: 600px;
}
@@ -0,0 +1,68 @@
import { signal } from '@angular/core';
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog';
import { MatSnackBar } from '@angular/material/snack-bar';
import { TranslateModule } from '@ngx-translate/core';
import { ParentalLockService } from '@iptvnator/services';
import {
StalkerCategoryLockDialogComponent,
StalkerCategoryLockDialogData,
} from './stalker-category-lock-dialog.component';
describe('StalkerCategoryLockDialogComponent', () => {
let fixture: ComponentFixture<StalkerCategoryLockDialogComponent>;
let component: StalkerCategoryLockDialogComponent;
const dialogRef = { close: jest.fn() };
const parentalLock = {
active: signal(false),
lockedStalkerIds: jest.fn(() => ['2']),
setStalkerLocks: jest.fn(),
};
const data: StalkerCategoryLockDialogData = {
playlistId: 'stalker-1',
contentType: 'itv',
categories: [
{ category_id: '1', category_name: 'News', censored: false },
{ category_id: '2', category_name: 'Adult', censored: true },
] as StalkerCategoryLockDialogData['categories'],
};
beforeEach(async () => {
jest.clearAllMocks();
parentalLock.active.set(false);
parentalLock.setStalkerLocks.mockResolvedValue(true);
await TestBed.configureTestingModule({
imports: [
StalkerCategoryLockDialogComponent,
TranslateModule.forRoot(),
],
providers: [
{ provide: MatDialogRef, useValue: dialogRef },
{ provide: MAT_DIALOG_DATA, useValue: data },
{ provide: ParentalLockService, useValue: parentalLock },
{ provide: MatSnackBar, useValue: { open: jest.fn() } },
],
}).compileComponents();
fixture = TestBed.createComponent(StalkerCategoryLockDialogComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('seeds the draft from the stored locks', () => {
expect(component.categories().map((c) => c.locked)).toEqual([
false,
true,
]);
expect(component.lockedCount()).toBe(1);
});
it('closes without saving when the session relocks while it is open', () => {
expect(dialogRef.close).not.toHaveBeenCalled();
parentalLock.active.set(true);
fixture.detectChanges();
expect(dialogRef.close).toHaveBeenCalledWith(false);
expect(parentalLock.setStalkerLocks).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,170 @@
import {
ChangeDetectionStrategy,
Component,
computed,
effect,
inject,
signal,
} from '@angular/core';
import { MatButtonModule } from '@angular/material/button';
import {
MAT_DIALOG_DATA,
MatDialogModule,
MatDialogRef,
} from '@angular/material/dialog';
import { MatIconModule } from '@angular/material/icon';
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
import { MatSnackBar } from '@angular/material/snack-bar';
import { MatTooltipModule } from '@angular/material/tooltip';
import { TranslatePipe, TranslateService } from '@ngx-translate/core';
import { StalkerCategoryItem } from '@iptvnator/portal/stalker/data-access';
import { ParentalLockService } from '@iptvnator/services';
import { ParentalLockStalkerCategoryType } from '@iptvnator/shared/interfaces';
export interface StalkerCategoryLockDialogData {
playlistId: string;
contentType: ParentalLockStalkerCategoryType;
/** Every genre of the section, the "All" pseudo-category excluded. */
categories: StalkerCategoryItem[];
}
interface LockableCategory {
readonly id: string;
readonly name: string;
readonly censored: boolean;
readonly locked: boolean;
}
/**
* Parental lock picker for a Stalker section's genres. Stalker has no
* hide/show dialog (its genres are never persisted), so this is the only
* category dialog for that portal type; it exists while the lock is on.
*/
@Component({
selector: 'app-stalker-category-lock-dialog',
imports: [
MatButtonModule,
MatDialogModule,
MatIconModule,
MatProgressSpinnerModule,
MatTooltipModule,
TranslatePipe,
],
templateUrl: './stalker-category-lock-dialog.component.html',
styleUrl: './stalker-category-lock-dialog.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class StalkerCategoryLockDialogComponent {
readonly data = inject<StalkerCategoryLockDialogData>(MAT_DIALOG_DATA);
private readonly dialogRef = inject(
MatDialogRef<StalkerCategoryLockDialogComponent, boolean>
);
private readonly parentalLock = inject(ParentalLockService);
private readonly snackBar = inject(MatSnackBar);
private readonly translate = inject(TranslateService);
readonly searchTerm = signal('');
readonly isSaving = signal(false);
readonly categories = signal<LockableCategory[]>(this.buildDraft());
readonly filteredCategories = computed(() => {
const term = this.searchTerm().trim().toLowerCase();
const categories = this.categories();
return term
? categories.filter((category) =>
category.name.toLowerCase().includes(term)
)
: categories;
});
readonly lockedCount = computed(
() => this.categories().filter((category) => category.locked).length
);
readonly censoredCount = computed(
() => this.categories().filter((category) => category.censored).length
);
constructor() {
// Same relock rule as the Xtream editor: the dialog outliving the
// unlocked session would keep the locked names and Save reachable.
effect(() => {
if (this.parentalLock.active()) {
this.dialogRef.close(false);
}
});
}
private buildDraft(): LockableCategory[] {
const locked = new Set(
this.parentalLock.lockedStalkerIds(
this.data.playlistId,
this.data.contentType
)
);
return this.data.categories.map((category) => {
const id = String(category.category_id);
return {
id,
name: category.category_name,
censored: category.censored === true,
locked: locked.has(id),
};
});
}
clearSearch(): void {
this.searchTerm.set('');
}
toggle(category: LockableCategory): void {
this.categories.update((categories) =>
categories.map((current) =>
current.id === category.id
? { ...current, locked: !current.locked }
: current
)
);
}
/** Pre-checks the genres the portal itself flags as adult (`censored`). */
lockCensored(): void {
this.categories.update((categories) =>
categories.map((current) =>
current.censored ? { ...current, locked: true } : current
)
);
}
unlockAll(): void {
this.categories.update((categories) =>
categories.map((current) => ({ ...current, locked: false }))
);
}
async save(): Promise<void> {
this.isSaving.set(true);
try {
const saved = await this.parentalLock.setStalkerLocks(
this.data.playlistId,
this.data.contentType,
this.categories()
.filter((category) => category.locked)
.map((category) => category.id)
);
if (!saved) {
this.snackBar.open(
this.translate.instant('PARENTAL_LOCK.SAVE_FAILED'),
this.translate.instant('CLOSE'),
{ duration: 3000 }
);
return;
}
this.dialogRef.close(true);
} finally {
this.isSaving.set(false);
}
}
cancel(): void {
this.dialogRef.close(false);
}
}
@@ -411,16 +411,7 @@ describe('StalkerCollectionDetailComponent', () => {
});
it('plays regular VOD collection details inline for embedded players instead of using the legacy play wrapper', async () => {
const sourceItem = {
id: '1701',
title: 'Collection Movie',
category_id: 'vod',
cmd: '/media/file_1701.mpg',
info: {
name: 'Collection Movie',
movie_image: 'movie.jpg',
},
};
const sourceItem = createSourceItem();
const playback: ResolvedPortalPlayback = {
streamUrl: 'https://streams.example.test/movie.mp4',
title: 'Collection Movie',
@@ -488,7 +479,10 @@ describe('StalkerCollectionDetailComponent', () => {
fixture.componentRef.setInput('item', itemB);
await settleDetail(fixture);
pendingA.resolve({ streamUrl: 'https://streams.test/a.mp4' });
pendingA.resolve({
streamUrl: 'https://streams.test/a.mp4',
title: 'Movie A',
});
await settleDetail(fixture);
expect(fixture.componentInstance.inlinePlayback()).toBeNull();
@@ -504,11 +498,15 @@ describe('StalkerCollectionDetailComponent', () => {
fixture.componentInstance.onVodPlay(
createStalkerVodItem(itemB.stalkerItem as never, itemB.playlistId)
);
pendingB.resolve({ streamUrl: 'https://streams.test/b.mp4' });
pendingB.resolve({
streamUrl: 'https://streams.test/b.mp4',
title: 'Movie B',
});
await settleDetail(fixture);
expect(fixture.componentInstance.inlinePlayback()).toEqual({
streamUrl: 'https://streams.test/b.mp4',
title: 'Movie B',
});
});
@@ -570,23 +568,21 @@ describe('StalkerCollectionDetailComponent', () => {
stalkerItem: { ...(item.stalkerItem as object) },
});
await settleDetail(fixture);
pending.resolve({ streamUrl: 'https://streams.test/a.mp4' });
pending.resolve({
streamUrl: 'https://streams.test/a.mp4',
title: 'Movie A',
});
await settleDetail(fixture);
expect(fixture.componentInstance.inlinePlayback()).toEqual({
streamUrl: 'https://streams.test/a.mp4',
title: 'Movie A',
});
expect(snackBar.open).not.toHaveBeenCalled();
});
it('marks a collection movie watched under its owning playlist', async () => {
const sourceItem = {
id: '1701',
title: 'Collection Movie',
category_id: 'vod',
cmd: '/media/file_1701.mpg',
info: { name: 'Collection Movie', movie_image: 'movie.jpg' },
};
const sourceItem = createSourceItem();
fixture.componentRef.setInput(
'item',
buildCollectionItem({
@@ -627,13 +623,7 @@ describe('StalkerCollectionDetailComponent', () => {
});
it('blocks the watched toggle while a collection Play is still resolving', async () => {
const sourceItem = {
id: '1701',
title: 'Collection Movie',
category_id: 'vod',
cmd: '/media/file_1701.mpg',
info: { name: 'Collection Movie', movie_image: 'movie.jpg' },
};
const sourceItem = createSourceItem();
let resolve!: (value: ResolvedPortalPlayback) => void;
stalkerStore.resolveVodPlayback.mockReturnValueOnce(
new Promise<ResolvedPortalPlayback>((resolvePromise) => {
@@ -675,13 +665,7 @@ describe('StalkerCollectionDetailComponent', () => {
});
it('does not carry a pending collection start over to the next item', async () => {
const sourceItem = {
id: '1701',
title: 'Collection Movie',
category_id: 'vod',
cmd: '/media/file_1701.mpg',
info: { name: 'Collection Movie', movie_image: 'movie.jpg' },
};
const sourceItem = createSourceItem();
let resolve!: (value: ResolvedPortalPlayback) => void;
stalkerStore.resolveVodPlayback.mockReturnValueOnce(
new Promise<ResolvedPortalPlayback>((resolvePromise) => {
@@ -729,13 +713,7 @@ describe('StalkerCollectionDetailComponent', () => {
});
it('mirrors an external player position into the collection row', async () => {
const sourceItem = {
id: '1701',
title: 'Collection Movie',
category_id: 'vod',
cmd: '/media/file_1701.mpg',
info: { name: 'Collection Movie', movie_image: 'movie.jpg' },
};
const sourceItem = createSourceItem();
playbackPositions.getPlaybackPosition.mockResolvedValueOnce({
playlistId: 'stalker-1',
contentXtreamId: 1701,
@@ -787,13 +765,7 @@ describe('StalkerCollectionDetailComponent', () => {
resolveRead = resolve;
})
);
const sourceItem = {
id: '1701',
title: 'Collection Movie',
category_id: 'vod',
cmd: '/media/file_1701.mpg',
info: { name: 'Collection Movie', movie_image: 'movie.jpg' },
};
const sourceItem = createSourceItem();
fixture.componentRef.setInput(
'item',
buildCollectionItem({
@@ -834,9 +806,6 @@ describe('StalkerCollectionDetailComponent', () => {
});
it('does not load VOD playback position when the playlist id is missing', async () => {
const playlistsService = TestBed.inject(PlaylistsService) as {
getPlaylistById: jest.Mock;
};
playlistsService.getPlaylistById.mockReturnValue(
of({
...playlist,
@@ -947,6 +916,27 @@ function buildVodSource(id: string, title: string) {
};
}
/** Regular (non-series) Stalker VOD item as the collection grid hands it over. */
function createSourceItem() {
return {
id: '1701',
title: 'Collection Movie',
category_id: 'vod',
cmd: '/media/file_1701.mpg',
info: {
name: 'Collection Movie',
movie_image: 'movie.jpg',
description: '',
actors: '',
director: '',
releasedate: '',
genre: '',
rating_imdb: '',
rating_kinopoisk: '',
},
};
}
function deferred<T>() {
let resolve!: (value: T) => void;
let reject!: (reason?: unknown) => void;
@@ -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',
@@ -51,7 +51,21 @@ class StubStalkerSeriesViewComponent {
}
const VOD_ITEM = createStalkerVodItem(
{ id: '42', cmd: '/media/42', info: { name: 'Movie' } },
{
id: '42',
cmd: '/media/42',
info: {
name: 'Movie',
movie_image: '',
description: '',
actors: '',
director: '',
releasedate: '',
genre: '',
rating_imdb: '',
rating_kinopoisk: '',
},
},
'stalker-1'
);
@@ -20,10 +20,7 @@ describe('StalkerItvAllItemsComponent', () => {
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [
StalkerItvAllItemsComponent,
TranslateModule.forRoot(),
],
imports: [StalkerItvAllItemsComponent, TranslateModule.forRoot()],
}).compileComponents();
fixture = TestBed.createComponent(StalkerItvAllItemsComponent);
@@ -18,21 +18,21 @@ const channel = (
describe('StalkerLiveAutoOpen', () => {
let autoOpen: StalkerLiveAutoOpen;
let events: Subject<unknown>;
const selectedCategoryId = signal<string | null>(null);
const searchPhrase = signal('');
const store = {
currentPlaylist: signal<{ _id?: string } | null>({ _id: 'pl-3' }),
selectedContentType: signal<string | null>('itv'),
selectedCategoryId: signal<string | null>(null),
selectedCategoryId,
itvChannelsCategory: signal<string | null>(null),
searchPhrase: signal(''),
searchPhrase,
itvFullChannelList: signal<StalkerItvChannel[]>([]),
itvFullListActive: signal(false),
itvFullListUnsupported: signal(false),
preloadItvChannels: jest.fn(() => Promise.resolve()),
setSearchPhrase: jest.fn((phrase: string) =>
store.searchPhrase.set(phrase)
),
setSearchPhrase: jest.fn((phrase: string) => searchPhrase.set(phrase)),
setSelectedCategory: jest.fn((category: string | null) =>
store.selectedCategoryId.set(category)
selectedCategoryId.set(category)
),
setPage: jest.fn(),
};
@@ -0,0 +1,16 @@
import { isStalkerPlaybackRequestLockCurrent } from './stalker-live-lock-guard';
describe('isStalkerPlaybackRequestLockCurrent', () => {
it('lets an unchanged lock version through', () => {
expect(isStalkerPlaybackRequestLockCurrent(3, 3, true)).toBe(true);
expect(isStalkerPlaybackRequestLockCurrent(3, 3, false)).toBe(true);
});
it('retires a request the session relocked over', () => {
expect(isStalkerPlaybackRequestLockCurrent(3, 4, true)).toBe(false);
});
it('lets a request through when the change was an unlock', () => {
expect(isStalkerPlaybackRequestLockCurrent(3, 4, false)).toBe(true);
});
});
@@ -0,0 +1,15 @@
/**
* Whether a playback request issued under parental-lock `requested` may
* still complete now that the lock is at `current`. The embedded player
* defers selecting the channel until its stream resolves, so the
* enforcement service's clearing of the selection cannot retire the
* request; a relock in between must. An UNLOCK in between is harmless — the
* request completes.
*/
export function isStalkerPlaybackRequestLockCurrent(
requested: number,
current: number,
active: boolean
): boolean {
return requested === current || !active;
}
@@ -22,25 +22,26 @@ describe('StalkerLiveNavigation', () => {
const third = channel('3', 'sports');
const rows = signal([first, second]);
const loading = signal(false);
const selectedCategoryId = signal<string | null>('news');
const searchPhrase = signal('');
const page = signal(0);
const store = {
currentPlaylist: signal({ _id: 'source-a' }),
selectedContentType: signal('itv'),
selectedCategoryId: signal<string | null>('news'),
searchPhrase: signal(''),
selectedCategoryId,
searchPhrase,
selectedItem: signal<StalkerItvChannel | null>(null),
page: signal(0),
page,
hasMoreChannels: signal(false),
getCategoryResource: signal([
{ category_id: 'news' },
{ category_id: 'sports' },
]),
setSearchPhrase: jest.fn((query: string) =>
store.searchPhrase.set(query)
),
setSearchPhrase: jest.fn((query: string) => searchPhrase.set(query)),
setSelectedCategory: jest.fn((category: string) =>
store.selectedCategoryId.set(category)
selectedCategoryId.set(category)
),
setPage: jest.fn((page: number) => store.page.set(page)),
setPage: jest.fn((next: number) => page.set(next)),
};
const router = {
url: '/workspace/stalker/source-a/itv?q=sport&keep=1',
@@ -4,10 +4,10 @@ import { By } from '@angular/platform-browser';
import { MatSnackBar } from '@angular/material/snack-bar';
import { NoopAnimationsModule } from '@angular/platform-browser/animations';
import { PortalEmptyStateComponent } from '@iptvnator/portal/shared/ui';
import type { PlaybackFallbackRequest } from '@iptvnator/playback/util';
import {
LIVE_EPG_PANEL_STATE_STORAGE_KEY,
PORTAL_PLAYER,
ResizableDirective,
} from '@iptvnator/portal/shared/util';
import {
ACTIVE_EPG_FALLBACK_SIZE,
@@ -16,15 +16,20 @@ import {
import { EpgListViewComponent, EpgTimelineComponent } from '@iptvnator/ui/epg';
import { AudioPlayerComponent } from '@iptvnator/ui/playback';
import { TranslatePipe, TranslateService } from '@ngx-translate/core';
import { ChannelListItemComponent } from '@iptvnator/ui/components';
import {
ChannelListItemComponent,
ResizableDirective,
} from '@iptvnator/ui/components';
import { MockPipe } from 'ng-mocks';
import { of } from 'rxjs';
import {
ParentalLockService,
PlaylistsService,
RuntimeCapabilitiesService,
SettingsStore,
} from '@iptvnator/services';
import {
ElectronBridgeApi,
EpgItem,
EpgProgram,
ResolvedPortalPlayback,
@@ -42,6 +47,11 @@ import {
StubWebPlayerViewComponent,
} from './stalker-live-stream-layout.spec-stubs';
/** Installs an intentionally partial Electron bridge double. */
function installElectronBridge(bridge: Partial<ElectronBridgeApi>): void {
window.electron = bridge as ElectronBridgeApi;
}
describe('StalkerLiveStreamLayoutComponent', () => {
let fixture: ComponentFixture<StalkerLiveStreamLayoutComponent>;
let component: StalkerLiveStreamLayoutComponent;
@@ -158,7 +168,7 @@ describe('StalkerLiveStreamLayoutComponent', () => {
fetchChannelEpg: jest.fn(),
ensureBulkItvEpg: jest.fn(),
applyMappedItvEpg: jest.fn().mockResolvedValue(undefined),
hasItvEpgMappingOverride: jest.fn(() => false),
hasItvEpgMappingOverride: jest.fn((_id: string | number) => false),
clearBulkItvEpgCache: jest.fn(() => {
bulkItvEpgByChannel.set({});
bulkItvEpgLoaded.set(false);
@@ -190,13 +200,13 @@ describe('StalkerLiveStreamLayoutComponent', () => {
// 'list' into siblings.
settingsStore.resolvedEpgViewMode.set('timeline');
playlist.set({ _id: 'playlist-1', title: 'Demo Stalker' });
window.electron = {
installElectronBridge({
platform: 'darwin',
setUserAgent: jest.fn().mockResolvedValue(true),
updateRemoteControlStatus: jest.fn(),
onChannelChange: jest.fn(() => jest.fn()),
onRemoteControlCommand: jest.fn(() => jest.fn()),
} as typeof window.electron;
});
fetchChannelEpg = stalkerStore.fetchChannelEpg;
ensureBulkItvEpg = stalkerStore.ensureBulkItvEpg;
@@ -283,6 +293,10 @@ describe('StalkerLiveStreamLayoutComponent', () => {
},
{ provide: PlaylistsService, useValue: playlistService },
{ provide: SettingsStore, useValue: settingsStore },
{
provide: ParentalLockService,
useValue: { version: signal(0), active: signal(false) },
},
{ provide: PORTAL_PLAYER, useValue: portalPlayer },
{
provide: TranslateService,
@@ -439,11 +453,6 @@ describe('StalkerLiveStreamLayoutComponent', () => {
title: 'Alpha TV',
isLive: true,
headers: { Authorization: 'Bearer token' },
contentInfo: {
playlistId: 'playlist-1',
contentXtreamId: 10001,
contentType: 'live',
},
};
const request: PlaybackFallbackRequest = {
player: 'vlc',
@@ -453,6 +462,7 @@ describe('StalkerLiveStreamLayoutComponent', () => {
code: 'network-error',
player: 'html5',
source: 'hls',
sourceUrl: playback.streamUrl,
container: '',
mimeType: '',
videoCodecs: [],
@@ -604,9 +614,9 @@ describe('StalkerLiveStreamLayoutComponent', () => {
it('does not publish remote-control status when the bridge is incomplete', () => {
fixture.destroy();
const updateRemoteControlStatus = jest.fn();
window.electron = {
installElectronBridge({
updateRemoteControlStatus,
} as typeof window.electron;
});
fixture = TestBed.createComponent(StalkerLiveStreamLayoutComponent);
component = fixture.componentInstance;
@@ -782,35 +792,38 @@ describe('StalkerLiveStreamLayoutComponent', () => {
).toEqual([]);
});
it.each([true, false])('grows the render window for remote/numeric selection (cached=%s)', async (cached) => {
const full = Array.from({ length: 250 }, (_, index) => ({
id: `ch-${index}`,
cmd: `ffrt4://itv/${index}`,
name: `Channel ${index}`,
o_name: `Channel ${index}`,
logo: '',
}));
itvFullListActive.set(true);
itvSelectedCategoryFromCache.set(cached);
itvChannels.set(full);
itvFullChannelList.set(cached ? full : []);
searchPhrase.set('channel');
fixture.detectChanges();
it.each([true, false])(
'grows the render window for remote/numeric selection (cached=%s)',
async (cached) => {
const full = Array.from({ length: 250 }, (_, index) => ({
id: `ch-${index}`,
cmd: `ffrt4://itv/${index}`,
name: `Channel ${index}`,
o_name: `Channel ${index}`,
logo: '',
}));
itvFullListActive.set(true);
itvSelectedCategoryFromCache.set(cached);
itvChannels.set(full);
itvFullChannelList.set(cached ? full : []);
searchPhrase.set('channel');
fixture.detectChanges();
expect(component.visibleChannels()).toHaveLength(100);
expect(component.visibleChannels()).toHaveLength(100);
// Selecting channel index 150 (outside the 100-item window) must grow
// the window so it is rendered and can be highlighted/scrolled to.
await component.playChannel(full[150], false);
fixture.detectChanges();
// Selecting channel index 150 (outside the 100-item window) must grow
// the window so it is rendered and can be highlighted/scrolled to.
await component.playChannel(full[150], false);
fixture.detectChanges();
expect(component.visibleChannels().length).toBeGreaterThan(150);
expect(
component
.visibleChannels()
.some((channel) => channel.id === 'ch-150')
).toBe(true);
});
expect(component.visibleChannels().length).toBeGreaterThan(150);
expect(
component
.visibleChannels()
.some((channel) => channel.id === 'ch-150')
).toBe(true);
}
);
it('does not clear cached channels when switching category in full-list mode', async () => {
// Regression: the category-change reset effect used to setItvChannels([])
@@ -37,11 +37,13 @@ import {
ResizableDirective,
} from '@iptvnator/ui/components';
import {
ParentalLockService,
PlaylistsService,
RecordingsService,
RuntimeCapabilitiesService,
SettingsStore,
} from '@iptvnator/services';
import { isStalkerPlaybackRequestLockCurrent } from './stalker-live-lock-guard';
import {
foldSearchText,
buildStalkerEpgMappingKey,
@@ -125,6 +127,8 @@ interface StalkerPlaybackResolutionOwner {
readonly sourceId: string;
readonly contentType: string;
readonly channelId: string;
/** `ParentalLockService.version` when the request was issued. */
readonly lockVersion: number;
}
/** Channels rendered per "page" when the full list is served from the cache. */
@@ -181,6 +185,7 @@ export class StalkerLiveStreamLayoutComponent
implements OnDestroy, FullscreenChannelPanelHost
{
readonly stalkerStore = inject(StalkerStore);
private readonly parentalLock = inject(ParentalLockService);
private readonly playlistService = inject(PlaylistsService);
private readonly hostElement = inject(ElementRef<HTMLElement>);
private readonly dialog = inject(MatDialog);
@@ -643,6 +648,22 @@ export class StalkerLiveStreamLayoutComponent
30_000
);
// The parental lock clears the store selection from outside this
// layout when the playing channel's genre is withheld
// (ParentalLockEnforcementService). The template mounts the player
// only with a selection, so the playback held here is dropped too
// instead of resurfacing with the next selection.
effect(() => {
if (this.stalkerStore.selectedItem()) {
return;
}
untracked(() => {
if (this.activePlayback()) {
this.clearActivePlayback();
}
});
});
// Load favorites for current playlist
const playlistId = this.stalkerStore.currentPlaylist()?._id;
if (playlistId) {
@@ -914,6 +935,7 @@ export class StalkerLiveStreamLayoutComponent
this.stalkerStore.currentPlaylist()?._id
);
const contentType = this.stalkerStore.selectedContentType();
const lockVersion = this.parentalLock.version();
const isRadioMode = this.isRadioMode();
const deferSelection = !isRadioMode && this.usesEmbeddedPlayer();
// Inline video retains its stream during resolution. Keep selection,
@@ -931,10 +953,10 @@ export class StalkerLiveStreamLayoutComponent
try {
const playback = await this.resolvePlaybackForChannel(
item,
{ sourceId, contentType, channelId },
{ sourceId, contentType, channelId, lockVersion },
isRadioMode
);
const owner = { sourceId, contentType, channelId };
const owner = { sourceId, contentType, channelId, lockVersion };
if (
!this.isPlaybackRequestCurrent(
requestId,
@@ -996,7 +1018,7 @@ export class StalkerLiveStreamLayoutComponent
if (
!this.isPlaybackRequestCurrent(
requestId,
{ sourceId, contentType, channelId },
{ sourceId, contentType, channelId, lockVersion },
expectedSelectedId
)
) {
@@ -1046,6 +1068,13 @@ export class StalkerLiveStreamLayoutComponent
): boolean {
return (
requestId === this.playbackRequestId &&
// A relock while the stream resolved: the deferred selection
// means the enforcement service had nothing to clear yet.
isStalkerPlaybackRequestLockCurrent(
owner.lockVersion,
this.parentalLock.version(),
this.parentalLock.active()
) &&
this.selectedChannelId() === expectedSelectedId &&
normalizeStalkerEntityId(
this.stalkerStore.currentPlaylist()?._id
@@ -197,9 +197,13 @@ describe('StalkerLiveStreamLayoutComponent playback session ownership', () => {
expect(selectedItem()).toBe(channels[0]);
const currentPlayback = {
streamUrl: 'https://two.example/live.m3u8',
title: 'Channel two',
};
current.resolve(currentPlayback);
stale.resolve({ streamUrl: 'https://stale.example/live.m3u8' });
stale.resolve({
streamUrl: 'https://stale.example/live.m3u8',
title: 'Channel one',
});
await Promise.all([staleSelection, currentSelection]);
expect(selectedItem()).toBe(channels[1]);
expect(component.isSelectedChannel(channels[1])).toBe(true);
@@ -224,10 +228,16 @@ describe('StalkerLiveStreamLayoutComponent playback session ownership', () => {
const requestA = component.playChannel(shared);
playlist.set({ _id: 'playlist-b', title: 'Portal B' });
const requestB = component.playChannel(shared);
const playbackB = { streamUrl: 'https://b.example/shared.m3u8' };
const playbackB = {
streamUrl: 'https://b.example/shared.m3u8',
title: 'Shared channel (Portal B)',
};
sourceB.resolve(playbackB);
sourceA.resolve({ streamUrl: 'https://a.example/shared.m3u8' });
sourceA.resolve({
streamUrl: 'https://a.example/shared.m3u8',
title: 'Shared channel (Portal A)',
});
await Promise.all([requestA, requestB]);
expect(resolveItvPlayback).toHaveBeenCalledTimes(2);
@@ -274,7 +284,10 @@ describe('StalkerLiveStreamLayoutComponent playback session ownership', () => {
const request = component.playChannel(channels[0]);
selectedContentType.set('radio');
pending.resolve({ streamUrl: 'https://stale.example/itv.m3u8' });
pending.resolve({
streamUrl: 'https://stale.example/itv.m3u8',
title: 'Channel one',
});
await request;
expect(component.activePlayback()).toBeNull();
@@ -312,7 +325,10 @@ describe('StalkerLiveStreamLayoutComponent playback session ownership', () => {
resolveItvPlayback.mockReturnValueOnce(pending.promise);
const request = component.playChannel(channels[0]);
store.setSelectedItem(channels[1]);
pending.resolve({ streamUrl: 'https://stale.example/live.m3u8' });
pending.resolve({
streamUrl: 'https://stale.example/live.m3u8',
title: 'Channel one',
});
await request;
expect(selectedItem()).toBe(channels[1]);
expect(component.activePlayback()).toBeNull();
@@ -11,7 +11,7 @@ import {
RuntimeCapabilitiesService,
SettingsStore,
} from '@iptvnator/services';
import { EpgProgram } from '@iptvnator/shared/interfaces';
import { ElectronBridgeApi, EpgProgram } from '@iptvnator/shared/interfaces';
import { TranslateService } from '@ngx-translate/core';
import { of } from 'rxjs';
import { StalkerLiveStreamLayoutComponent } from './stalker-live-stream-layout.component';
@@ -22,6 +22,11 @@ import { StalkerLiveStreamLayoutComponent } from './stalker-live-stream-layout.c
* which sits at the max-lines test budget; the template is overridden to
* empty because these behaviors live entirely in constructor effects.
*/
/** Installs an intentionally partial Electron bridge double. */
function installElectronBridge(bridge: Partial<ElectronBridgeApi>): void {
window.electron = bridge as ElectronBridgeApi;
}
describe('StalkerLiveStreamLayoutComponent remote status', () => {
let fixture: ComponentFixture<StalkerLiveStreamLayoutComponent>;
const originalElectron = window.electron;
@@ -147,13 +152,13 @@ describe('StalkerLiveStreamLayoutComponent remote status', () => {
itvChannels.set(originalItvChannels);
stalkerStore.selectedCategoryId.set('1001');
stalkerStore.setSelectedItem.mockReset();
window.electron = {
installElectronBridge({
platform: 'darwin',
setUserAgent: jest.fn().mockResolvedValue(true),
updateRemoteControlStatus,
onChannelChange: jest.fn(() => jest.fn()),
onRemoteControlCommand: jest.fn(() => jest.fn()),
} as typeof window.electron;
});
selectedContentType.set('itv');
selectedItem.set(itvChannels()[0]);
@@ -179,13 +184,16 @@ describe('StalkerLiveStreamLayoutComponent remote status', () => {
// Mirrors the real capability check: every
// remote-control bridge method must be present.
get supportsRemoteControl() {
const bridge = window.electron as
Record<string, unknown> | undefined;
return [
'updateRemoteControlStatus',
'onChannelChange',
'onRemoteControlCommand',
].every(
const bridge:
Partial<ElectronBridgeApi> | undefined =
window.electron;
return (
[
'updateRemoteControlStatus',
'onChannelChange',
'onRemoteControlCommand',
] as const
).every(
(method) =>
typeof bridge?.[method] === 'function'
);
@@ -117,8 +117,14 @@ export class StubPortalEmptyStateComponent {
readonly message = input('');
}
/** Accepts the layout's resizable bindings without touching storage or the DOM. */
@Directive({
selector: '[appResizable]',
standalone: true,
})
export class StubResizableDirective {}
export class StubResizableDirective {
readonly minWidth = input<number>(200);
readonly maxWidth = input<number>(600);
readonly defaultWidth = input<number>(400);
readonly storageKey = input<string>('');
}
@@ -0,0 +1,35 @@
import { isStalkerSearchRequestCurrent } from './stalker-search-request.util';
describe('isStalkerSearchRequestCurrent', () => {
const key = {
search: 'family',
contentType: 'vod',
page: 2,
playlistId: 'stalker-1',
parentalLockVersion: 3,
};
it('accepts a response whose coordinates are unchanged', () => {
expect(isStalkerSearchRequestCurrent(key, { ...key })).toBe(true);
});
it('rejects a response issued under an older parental lock version', () => {
expect(
isStalkerSearchRequestCurrent(key, {
...key,
parentalLockVersion: 4,
})
).toBe(false);
});
it.each([
['search', { search: 'famil' }],
['contentType', { contentType: 'series' }],
['page', { page: 3 }],
['playlistId', { playlistId: 'stalker-2' }],
])('rejects a response whose %s moved on', (_field, change) => {
expect(isStalkerSearchRequestCurrent(key, { ...key, ...change })).toBe(
false
);
});
});
@@ -0,0 +1,29 @@
/** The coordinates a Stalker search page was requested under. */
export interface StalkerSearchRequestKey {
readonly search: string;
readonly contentType: string;
readonly page: number;
readonly playlistId: string | null;
/** `ParentalLockService.version` at request time. */
readonly parentalLockVersion: number;
}
/**
* Whether a search response may still be applied. Portal requests are not
* aborted, so a page issued under an older state can finish after a newer
* one; the parental lock version is part of the key because an older
* response was filtered with the OLDER withheld set — applying it after a
* relock would repopulate the grid with locked-category rows.
*/
export function isStalkerSearchRequestCurrent(
requested: StalkerSearchRequestKey,
current: StalkerSearchRequestKey
): boolean {
return (
requested.search === current.search &&
requested.contentType === current.contentType &&
requested.page === current.page &&
requested.playlistId === current.playlistId &&
requested.parentalLockVersion === current.parentalLockVersion
);
}
@@ -24,8 +24,15 @@ import {
StalkerStore,
} from '@iptvnator/portal/stalker/data-access';
import { createPlaybackSessionKey } from '@iptvnator/playback/util';
import { DataService, PlaylistsService } from '@iptvnator/services';
import { CONNECTIVITY_GUARD_RESET } from '@iptvnator/shared/interfaces';
import {
DataService,
ParentalLockService,
PlaylistsService,
} from '@iptvnator/services';
import {
ALL_CATEGORIES_WITHHELD,
CONNECTIVITY_GUARD_RESET,
} from '@iptvnator/shared/interfaces';
import type { ResolvedPortalPlayback } from '@iptvnator/shared/interfaces';
import { StalkerSearchComponent } from './stalker-search.component';
@@ -289,6 +296,37 @@ describe('StalkerSearchComponent playback session key', () => {
describe('StalkerSearchComponent result paging', () => {
let component: StalkerSearchComponent;
let dataService: { sendIpcEvent: jest.Mock };
let parentalLock: {
active: jest.Mock<boolean, []>;
version: ReturnType<typeof signal<number>>;
lockedStalkerIds: jest.Mock<string[], [string, string]>;
};
let stalkerStoreMock: {
selectedItem: ReturnType<typeof signal<unknown>>;
setSelectedContentType: jest.Mock;
setSelectedItem: jest.Mock;
addToFavorites: jest.Mock;
removeFromFavorites: jest.Mock;
resolveVodPlayback: jest.Mock;
};
async function flush(): Promise<void> {
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 +345,21 @@ describe('StalkerSearchComponent result paging', () => {
dataService = {
sendIpcEvent: jest.fn().mockResolvedValue({ success: true }),
};
parentalLock = {
active: jest.fn(() => false),
version: signal(0),
lockedStalkerIds: jest.fn(
(_playlistId: string, _type: string): string[] => []
),
};
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 +389,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 +447,82 @@ 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('drops withheld rows on screen at relock time, page 1 included', () => {
component.applySearchPageSuccess(
1,
[
{ id: 'news-1', name: 'News', category_id: '5' },
{ id: 'adult-1', name: 'Adult', category_id: '9' },
],
2
);
expect(component.searchResults()).toHaveLength(2);
component.applyRelockToResults(new Set(['9']), 'vod');
expect(component.searchResults().map((item) => item.id)).toEqual([
'news-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('closes a genre-less detail on relock only in fail-closed mode', () => {
component.selectItem({ id: 'no-genre', name: 'N' });
expect(component.itemDetails()).not.toBeNull();
component.closeWithheldDetail(new Set(['9']));
expect(component.itemDetails()).not.toBeNull();
component.closeWithheldDetail(ALL_CATEGORIES_WITHHELD);
expect(component.itemDetails()).toBeNull();
});
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,13 +21,17 @@ import {
executeStalkerRequest,
StalkerPortalRepairService,
StalkerSessionService,
stalkerWithheldRowKey,
withoutWithheldStalkerItems,
} from '@iptvnator/portal/stalker/data-access';
import {
DataService,
ParentalLockService,
PlaylistsService,
resetHostConnectivityGuard,
} from '@iptvnator/services';
import {
ALL_CATEGORIES_WITHHELD,
PlaybackPositionData,
ResolvedPortalPlayback,
StalkerPortalActions,
@@ -66,6 +70,7 @@ import {
} from '@iptvnator/portal/stalker/data-access';
import { StalkerVodPlaybackController } from '../stalker-vod-playback-controller';
import { createPlaybackSessionKey } from '@iptvnator/playback/util';
import { isStalkerSearchRequestCurrent } from './stalker-search-request.util';
interface StalkerFilter {
key: StalkerSearchContentType;
@@ -120,6 +125,16 @@ 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;
/**
* 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<string>();
private readonly playlistContext = inject(PlaylistContextFacade);
private readonly playlistService = inject(PlaylistsService);
readonly externalPlayback = inject(PORTAL_EXTERNAL_PLAYBACK);
@@ -241,6 +256,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 +278,43 @@ 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: ReadonlySet<string> =
!this.parentalLock.active()
? new Set<string>()
: this.parentalLock.withholdsEverything?.()
? ALL_CATEGORIES_WITHHELD
: new Set(
this.parentalLock.lockedStalkerIds(
playlist._id,
contentType
)
);
const lockVersionChanged =
this.searchResultsLockVersion !== null &&
this.searchResultsLockVersion !== params.parentalLockVersion;
this.searchResultsLockVersion = params.parentalLockVersion;
if (lockVersionChanged) {
this.applyRelockToResults(withheldCategoryIds, contentType);
}
if (lockVersionChanged && params.page > 1) {
// A lock flip past page 1: rebuild from page 1 rather than
// appending to pages accumulated under the old lock state.
this.searchPage.set(1);
return this.accumulatedSearchResults();
}
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
@@ -278,13 +333,18 @@ export class StalkerSearchComponent {
...(contentType === 'vod' ? { genre: '0' } : {}),
};
// A stale response (term/filter/page/portal moved on while this
// page was in flight) must not clobber the accumulated list.
// A stale response (term/filter/page/portal — or the parental
// lock — moved on while this page was in flight) must not clobber
// the accumulated list: the request is not aborted, and a
// pre-relock response was filtered with the pre-relock set.
const isCurrent = (): boolean =>
params.search === this.searchTerm() &&
params.contentType === this.selectedFilterType() &&
params.page === this.searchPage() &&
params.playlistId === (this.currentPlaylist()?._id ?? null);
isStalkerSearchRequestCurrent(params, {
search: this.searchTerm(),
contentType: this.selectedFilterType(),
page: this.searchPage(),
playlistId: this.currentPlaylist()?._id ?? null,
parentalLockVersion: this.parentalLock.version(),
});
try {
// executeStalkerRequest owns the portal-mode decision (shared
@@ -301,20 +361,53 @@ 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
);
// Before the withheld-id bookkeeping: a stale page must not
// pre-record ids into a set a newer relock request cleared,
// or that request's page counts no new withheld rows and
// stops paging short of later visible matches.
if (!isCurrent()) {
return items;
}
let newWithheldCount = 0;
if (items.length < rawItems.length) {
const kept = new Set(items);
for (const item of rawItems) {
const id = stalkerWithheldRowKey(item);
if (
!kept.has(item) &&
!this.searchWithheldIds.has(id)
) {
this.searchWithheldIds.add(id);
newWithheldCount += 1;
}
}
}
return this.applySearchPageSuccess(
const merged = 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 &&
(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,
@@ -343,7 +436,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 +446,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);
@@ -605,6 +702,84 @@ 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.
*/
/**
* A lock change reached the results on screen, which were read under
* the old lock state: close an open detail of a now-withheld genre (the
* list hiding it is not enough) and drop the withheld rows NOW, before
* the replacement page is awaited — on page 1 too, or they stay
* clickable while (or, if it hangs, after) that request is pending.
*/
applyRelockToResults(
withheldCategoryIds: ReadonlySet<string>,
contentType: Parameters<typeof withoutWithheldStalkerItems>[1]
): void {
this.closeWithheldDetail(withheldCategoryIds);
this.accumulatedSearchResults.set(
withoutWithheldStalkerItems(
this.accumulatedSearchResults(),
contentType,
withheldCategoryIds
)
);
}
closeWithheldDetail(withheldCategoryIds: ReadonlySet<string>): void {
const details = this.itemDetails();
if (!details) {
return;
}
// A detail without a genre is withheld only in fail-closed mode
// (`ALL_CATEGORIES_WITHHELD`): "unknown genre" is not "no locked
// genre" while the locks themselves are unknown.
const categoryId = details.category_id;
const withheld =
categoryId === undefined || categoryId === null || categoryId === ''
? withheldCategoryIds === ALL_CATEGORIES_WITHHELD
: withheldCategoryIds.has(String(categoryId));
if (!withheld) {
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();
@@ -31,7 +31,7 @@ const ITEM = {
rating_imdb: '8.4',
rating_kinopoisk: '8.0',
tmdb_id: 88001,
tmdb_status: 'returning-series',
tmdb_status: 'returning',
},
} as const;
@@ -210,7 +210,7 @@ describe('createStalkerSeriesDownloadAdapter', () => {
year: 2025,
genres: ['Drama', 'Mystery'],
rating: 8.4,
status: 'returning-series',
status: 'returning',
posterUrl: SERIES_POSTER_URL,
tmdbId: 88001,
providerCategoryId: '18',
@@ -12,10 +12,12 @@ import {
} from './stalker-series-resume';
function episode(
overrides: Partial<StalkerMappedEpisode> & { id: number }
overrides: Partial<Omit<StalkerMappedEpisode, 'id'>> & { id: number }
): StalkerMappedEpisode {
const { id, ...rest } = overrides;
return {
title: `Episode ${overrides.episode_num ?? 1}`,
id: String(id),
title: `Episode ${rest.episode_num ?? 1}`,
container_extension: 'mpg',
custom_sid: 'vod-series',
added: '',
@@ -23,7 +25,7 @@ function episode(
episode_num: 1,
direct_source: '',
info: {},
...overrides,
...rest,
} as StalkerMappedEpisode;
}
@@ -330,9 +330,10 @@ describe('StalkerSeriesViewComponent playback session key', () => {
pending.resolve({
streamUrl: 'https://stale.example/old-command.mpg',
title: 'Stale command',
contentInfo: {
playlistId: 'transport-playlist',
contentXtreamId: episodeA.id,
contentXtreamId: Number(episodeA.id),
contentType: 'episode',
},
});
@@ -676,9 +677,10 @@ describe('StalkerSeriesViewComponent playback session key', () => {
setLazySeries('replacement-parent', 'replacement-provider-episode');
pending.resolve({
streamUrl: 'https://stale.example/old-episode.mpg',
title: 'Stale episode',
contentInfo: {
playlistId: 'transport-playlist',
contentXtreamId: episode.id,
contentXtreamId: Number(episode.id),
contentType: 'episode',
},
});
@@ -709,18 +711,20 @@ describe('StalkerSeriesViewComponent playback session key', () => {
fixture.componentInstance.onEpisodeClicked(newEpisode);
const newestPlayback = {
streamUrl: 'https://new.example/episode.mpg',
title: 'Newest episode',
contentInfo: {
playlistId: 'transport-playlist',
contentXtreamId: newEpisode.id,
contentXtreamId: Number(newEpisode.id),
contentType: 'episode' as const,
},
};
newRequest.resolve(newestPlayback);
oldRequest.resolve({
streamUrl: 'https://old.example/episode.mpg',
title: 'Old episode',
contentInfo: {
playlistId: 'transport-playlist',
contentXtreamId: oldEpisode.id,
contentXtreamId: Number(oldEpisode.id),
contentType: 'episode',
},
});
@@ -6,7 +6,10 @@ import type {
PortalPlaybackPositions,
PortalPlayer,
} from '@iptvnator/portal/shared/util';
import type { PlaybackPositionData } from '@iptvnator/shared/interfaces';
import type {
PlaybackPositionData,
ResolvedPortalPlayback,
} from '@iptvnator/shared/interfaces';
import { StalkerVodPlaybackController } from './stalker-vod-playback-controller';
interface Deferred<T> {
@@ -119,11 +122,14 @@ describe('StalkerVodPlaybackController', () => {
it('does not mount playback that resolves after the detail closes', async () => {
const { controller, inlinePlayback } = createController();
const pending = createDeferred<{ streamUrl: string }>();
const pending = createDeferred<ResolvedPortalPlayback>();
const playback = controller.startVodPlayback(() => pending.promise);
controller.closeInlinePlayer();
pending.resolve({ streamUrl: 'https://stale.example/movie.mpg' });
pending.resolve({
streamUrl: 'https://stale.example/movie.mpg',
title: 'Stale movie',
});
await playback;
expect(inlinePlayback()).toBeNull();
@@ -131,14 +137,20 @@ describe('StalkerVodPlaybackController', () => {
it('keeps the newest VOD request when resolutions finish out of order', async () => {
const { controller, inlinePlayback } = createController();
const older = createDeferred<{ streamUrl: string }>();
const newer = createDeferred<{ streamUrl: string }>();
const older = createDeferred<ResolvedPortalPlayback>();
const newer = createDeferred<ResolvedPortalPlayback>();
const olderRequest = controller.startVodPlayback(() => older.promise);
const newerRequest = controller.startVodPlayback(() => newer.promise);
const newestPlayback = { streamUrl: 'https://new.example/movie.mpg' };
const newestPlayback = {
streamUrl: 'https://new.example/movie.mpg',
title: 'Newest movie',
};
newer.resolve(newestPlayback);
older.resolve({ streamUrl: 'https://old.example/movie.mpg' });
older.resolve({
streamUrl: 'https://old.example/movie.mpg',
title: 'Old movie',
});
await Promise.all([olderRequest, newerRequest]);
expect(inlinePlayback()).toBe(newestPlayback);
@@ -3,12 +3,12 @@
"compilerOptions": {
"emitDecoratorMetadata": false,
"outDir": "../../../../dist/out-tsc",
"module": "commonjs",
"module": "preserve",
"target": "es2016",
"types": ["jest", "node"],
"moduleResolution": "node10"
"moduleResolution": "bundler"
},
"files": ["src/test-setup.ts"],
"files": ["src/test-setup.ts", "../../../../global.d.ts"],
"include": [
"jest.config.ts",
"src/**/*.test.ts",
@@ -1,3 +1,5 @@
import { TestBed } from '@angular/core/testing';
import { ParentalLockService } from '@iptvnator/services';
import {
credentials,
ElectronXtreamDataSourceHarness,
@@ -21,6 +23,7 @@ describe('ElectronXtreamDataSource (DB-first strategy)', () => {
type: 'live' as const,
xtream_id: 10,
hidden: false,
locked: false,
};
const dbContentItem = {
id: 1,
@@ -33,6 +36,48 @@ describe('ElectronXtreamDataSource (DB-first strategy)', () => {
harness = setupElectronXtreamDataSource();
});
describe('withholding everything', () => {
it('serves no categories, content or search hits while the lock store withholds everything', async () => {
const parentalLock = TestBed.inject(ParentalLockService);
Object.defineProperty(parentalLock, 'withholdsEverything', {
configurable: true,
value: () => true,
});
harness.dbService.getXtreamImportStatus.mockResolvedValue(
'completed'
);
harness.dbService.getXtreamCategories.mockResolvedValue([
dbCategory,
]);
harness.dbService.getXtreamContent.mockResolvedValue([
dbContentItem,
]);
await expect(
harness.dataSource.getCategories(
playlistId,
credentials,
'live'
)
).resolves.toEqual([]);
await expect(
harness.dataSource.getContent(playlistId, credentials, 'live')
).resolves.toEqual([]);
await expect(
harness.dataSource.searchContent(playlistId, 'news', ['live'])
).resolves.toEqual([]);
await expect(
harness.dataSource.getCachedCategories(playlistId, 'live')
).resolves.toEqual([]);
await expect(
harness.dataSource.getCachedContent(playlistId, 'live')
).resolves.toEqual([]);
expect(
harness.dbService.getXtreamCategories
).not.toHaveBeenCalled();
});
});
describe('getCategories', () => {
it('returns cached categories without calling the API when import is completed', async () => {
harness.dbService.getXtreamImportStatus.mockResolvedValue(
@@ -56,6 +101,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' },
@@ -84,7 +149,9 @@ describe('ElectronXtreamDataSource (DB-first strategy)', () => {
playlistId,
remoteCategories,
'movies',
undefined
undefined,
// Parental lock: no locks stored for the playlist.
[]
);
expect(result).toEqual([dbCategory]);
expect(onPhaseChange.mock.calls).toEqual([
@@ -174,7 +241,8 @@ describe('ElectronXtreamDataSource (DB-first strategy)', () => {
playlistId,
expect.any(Array),
'live',
[5]
[5],
[]
);
});
@@ -207,7 +275,11 @@ describe('ElectronXtreamDataSource (DB-first strategy)', () => {
.mockResolvedValueOnce([]);
await expect(
harness.dataSource.getCategories(playlistId, credentials, 'live')
harness.dataSource.getCategories(
playlistId,
credentials,
'live'
)
).rejects.toThrow('portal unreachable');
await harness.dataSource.getCategories(
@@ -1,6 +1,7 @@
import { inject, Injectable } from '@angular/core';
import {
DatabaseService,
ParentalLockService,
PlaybackPositionService,
XtreamPendingRestoreService,
XtreamImportStatus,
@@ -46,6 +47,7 @@ export class ElectronXtreamDataSource implements IXtreamDataSource {
XtreamPendingRestoreService
);
private readonly apiService = inject(XtreamApiService);
private readonly parentalLock = inject(ParentalLockService);
private readonly categoryRequests = new Map<
string,
Promise<XtreamCategoryFromDb[]>
@@ -147,8 +149,17 @@ export class ElectronXtreamDataSource implements IXtreamDataSource {
type: CategoryType,
options?: XtreamOperationOptions
): Promise<XtreamCategoryFromDb[]> {
// Fail closed with the renderer: while the lock store is loading,
// unreadable or its SQLite index not yet reconciled, the index may
// still carry a stale `locked = false` stamp, so nothing is served.
if (this.parentalLock.withholdsEverything?.()) {
return [];
}
const dbType = mapCategoryTypeToDbType(type);
const requestKey = `${playlistId}:${dbType}`;
// The lock version keys the share: a read issued under an older lock
// state (still unlocked, or locks since edited) answers with rows the
// caller under the newer state must not be handed.
const requestKey = `${playlistId}:${dbType}:${this.parentalLock.version()}`;
const inFlightRequest = this.categoryRequests.get(requestKey);
if (inFlightRequest) {
@@ -187,7 +198,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;
}
@@ -213,7 +231,10 @@ export class ElectronXtreamDataSource implements IXtreamDataSource {
playlistId,
remoteData,
dbType,
hiddenCategoryXtreamIds
hiddenCategoryXtreamIds,
// Locks live in the renderer's store; the fresh rows get the
// SQLite mirror stamped on insert so a refresh keeps them.
this.parentalLock.lockedXtreamIds(playlistId, dbType)
);
}
@@ -252,6 +273,12 @@ export class ElectronXtreamDataSource implements IXtreamDataSource {
playlistId: string,
type: CategoryType
): Promise<XtreamCategoryFromDb[]> {
// Same fail-closed gate as the live reads: the warm-route hydration
// reads the cache directly and must not publish rows a stale index
// stamp still marks unlocked.
if (this.parentalLock.withholdsEverything?.()) {
return [];
}
return this.dbService.getXtreamCategories(
playlistId,
mapCategoryTypeToDbType(type)
@@ -292,7 +319,13 @@ export class ElectronXtreamDataSource implements IXtreamDataSource {
onTotal?: (total: number) => void,
options?: XtreamOperationOptions
): Promise<XtreamContentItem[]> {
const requestKey = `${playlistId}:${type}`;
// Fail closed with the renderer: while the lock store is loading,
// unreadable or its SQLite index not yet reconciled, the index may
// still carry a stale `locked = false` stamp, so nothing is served.
if (this.parentalLock.withholdsEverything?.()) {
return [];
}
const requestKey = `${playlistId}:${type}:${this.parentalLock.version()}`;
const inFlightRequest = this.contentRequests.get(requestKey);
if (inFlightRequest) {
@@ -329,7 +362,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;
}
@@ -370,6 +409,12 @@ export class ElectronXtreamDataSource implements IXtreamDataSource {
playlistId: string,
type: StreamType
): Promise<XtreamContentItem[]> {
// Same fail-closed gate as the live reads: the warm-route hydration
// reads the cache directly and must not publish rows a stale index
// stamp still marks unlocked.
if (this.parentalLock.withholdsEverything?.()) {
return [];
}
return this.dbService.getXtreamContent(playlistId, type);
}
@@ -403,6 +448,12 @@ export class ElectronXtreamDataSource implements IXtreamDataSource {
types: string[],
excludeHidden?: boolean
): Promise<XtreamContentItem[]> {
// Fail closed with the renderer: while the lock store is loading,
// unreadable or its SQLite index not yet reconciled, the index may
// still carry a stale `locked = false` stamp, so nothing is served.
if (this.parentalLock.withholdsEverything?.()) {
return [];
}
return this.dbService.searchXtreamContent(
playlistId,
searchTerm,
@@ -1,11 +1,12 @@
import { TestBed } from '@angular/core/testing';
import { PlaybackPositionData } from '@iptvnator/shared/interfaces';
import { PwaXtreamDataSource } from './pwa-xtream-data-source';
import type { XtreamContentItem } from './xtream-data-source.interface';
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 +18,11 @@ describe('PwaXtreamDataSource', () => {
getPlaylistById: jest.Mock;
transformPlaylistMeta: jest.Mock;
};
let parentalLock: {
active: jest.Mock<boolean, []>;
lockedXtreamIds: jest.Mock<number[], [string, string]>;
withholdsEverything: jest.Mock<boolean, []>;
};
const credentials: XtreamCredentials = {
serverUrl: 'http://localhost:3211',
@@ -34,10 +40,18 @@ describe('PwaXtreamDataSource', () => {
getPlaylistById: jest.fn(() => of(undefined)),
transformPlaylistMeta: jest.fn(() => of(null)),
};
parentalLock = {
active: jest.fn(() => false),
lockedXtreamIds: jest.fn(
(_playlistId: string, _type: string): number[] => []
),
withholdsEverything: jest.fn(() => false),
};
TestBed.configureTestingModule({
providers: [
PwaXtreamDataSource,
{ provide: ParentalLockService, useValue: parentalLock },
{
provide: XtreamApiService,
useValue: apiService,
@@ -56,6 +70,77 @@ 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('matches a noncanonical provider category id against its numeric lock', async () => {
apiService.getStreams.mockResolvedValue([
{ stream_id: 1, name: 'Family film', category_id: '5' },
{ stream_id: 2, name: 'Family after dark', category_id: '009' },
]);
parentalLock.active.mockReturnValue(true);
parentalLock.lockedXtreamIds.mockImplementation((_id, type) =>
type === 'movies' ? [9] : []
);
const content = await dataSource.getContent(
'playlist-1',
credentials,
'movie'
);
expect(content.map((item) => item.name)).toEqual(['Family film']);
});
it('withholds everything while the lock store cannot be read', async () => {
apiService.getStreams.mockResolvedValue([
{ stream_id: 1, name: 'Family film', category_id: '5' },
]);
parentalLock.active.mockReturnValue(true);
parentalLock.withholdsEverything = jest.fn(() => true);
const content = await dataSource.getContent(
'playlist-1',
credentials,
'movie'
);
const found = await dataSource.searchContent('playlist-1', 'family', [
'movie',
]);
expect(content).toEqual([]);
expect(found).toEqual([]);
expect(parentalLock.lockedXtreamIds).not.toHaveBeenCalled();
});
it('reports remote loading phases for API fetches but stays silent on cache hits', async () => {
apiService.getStreams.mockResolvedValue([
{ stream_id: 1, name: 'News' },
@@ -281,17 +366,17 @@ describe('PwaXtreamDataSource', () => {
'playlist-1',
credentials,
'live'
)) as Array<Record<string, unknown>>;
)) as XtreamContentItem[];
const vod = (await dataSource.getContent(
'playlist-1',
credentials,
'movie'
)) as Array<Record<string, unknown>>;
)) as XtreamContentItem[];
const series = (await dataSource.getContent(
'playlist-1',
credentials,
'series'
)) as Array<Record<string, unknown>>;
)) as XtreamContentItem[];
expect(live[0]).toEqual(
expect.objectContaining({
@@ -399,7 +484,7 @@ describe('PwaXtreamDataSource', () => {
'playlist-1',
credentials,
'movie'
)) as Array<Record<string, unknown>>;
)) as XtreamContentItem[];
expect(content).toEqual([
expect.objectContaining({
@@ -524,7 +609,7 @@ describe('PwaXtreamDataSource', () => {
'playlist-1',
credentials,
'movie'
)) as Array<Record<string, unknown>>;
)) as XtreamContentItem[];
expect(content[0]).toEqual(
expect.objectContaining({
@@ -1,5 +1,6 @@
import { inject, Injectable, Injector } from '@angular/core';
import {
ALL_CATEGORIES_WITHHELD,
foldSearchText,
ContentMetadataPatch,
Playlist,
@@ -10,14 +11,14 @@ import {
XtreamSerieItem,
XtreamVodStream,
} from '@iptvnator/shared/interfaces';
import { createLogger } from '@iptvnator/portal/shared/util';
import { createLogger } from '@iptvnator/portal/shared/util/logger';
import {
CategoryType,
StreamType,
XtreamApiService,
XtreamCredentials,
} from '../services/xtream-api.service';
import { PlaylistsService } from '@iptvnator/services';
import { ParentalLockService, PlaylistsService } from '@iptvnator/services';
import { firstValueFrom } from 'rxjs';
import {
DbCategoryType,
@@ -82,6 +83,7 @@ type StoredXtreamPlaylistData = Omit<XtreamPlaylistData, 'password'> & {
export class PwaXtreamDataSource implements IXtreamDataSource {
private readonly apiService = inject(XtreamApiService);
private readonly injector = inject(Injector);
private readonly parentalLock = inject(ParentalLockService);
private readonly logger = createLogger('PwaXtreamDataSource');
private readonly contentTypes = ['live', 'movie', 'series'] as const;
@@ -332,7 +334,11 @@ export class PwaXtreamDataSource implements IXtreamDataSource {
// Check in-memory cache first
const cachedCategories = this.categoryCache.get(cacheKey);
if (cachedCategories) {
return cachedCategories;
return this.withoutLockedCategories(
playlistId,
type,
cachedCategories
);
}
// Fetch from API
@@ -345,17 +351,112 @@ export class PwaXtreamDataSource implements IXtreamDataSource {
// Cache in memory
this.categoryCache.set(cacheKey, categories);
return categories;
return this.withoutLockedCategories(playlistId, type, categories);
}
/**
* Parental lock (PWA): there is no SQLite worker to filter reads, so the
* withheld categories are dropped here, at the same boundary the
* Electron source reads them through.
*/
private withheldCategoryIds(
playlistId: string,
type: CategoryType | StreamType
): ReadonlySet<string> {
if (!this.parentalLock.active()) {
return new Set();
}
if (this.parentalLock.withholdsEverything?.()) {
return ALL_CATEGORIES_WITHHELD;
}
const dbType =
type === 'vod' || type === 'movie'
? 'movies'
: type === 'series'
? 'series'
: 'live';
return new Set(
this.parentalLock
.lockedXtreamIds(playlistId, dbType)
.map((id) => String(id))
);
}
private withoutLockedCategories(
playlistId: string,
type: CategoryType,
categories: XtreamCategory[]
): XtreamCategory[] {
const withheld = this.withheldCategoryIds(playlistId, type);
return withheld.size === 0
? categories
: categories.filter(
(category) =>
!withheld.has(canonicalCategoryId(category.category_id))
);
}
private withoutLockedContent<T extends { category_id?: string | number }>(
playlistId: string,
type: StreamType,
items: T[]
): T[] {
const withheld = this.withheldCategoryIds(playlistId, type);
return withheld.size === 0
? items
: items.filter(
(item) => !withheld.has(canonicalCategoryId(item.category_id))
);
}
/**
* 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(
@@ -417,8 +518,11 @@ export class PwaXtreamDataSource implements IXtreamDataSource {
// Check in-memory cache first
const cachedContent = this.contentCache.get(cacheKey);
if (cachedContent) {
return cachedContent as
XtreamLiveStream[] | XtreamVodStream[] | XtreamSerieItem[];
return this.withoutLockedContent(
playlistId,
type,
cachedContent
) as XtreamLiveStream[] | XtreamVodStream[] | XtreamSerieItem[];
}
// Fetch from API
@@ -445,7 +549,7 @@ export class PwaXtreamDataSource implements IXtreamDataSource {
// Cache in memory
this.contentCache.set(cacheKey, content);
return content;
return this.withoutLockedContent(playlistId, type, content);
}
async getCachedContent(
@@ -553,7 +657,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 =
@@ -1476,3 +1586,14 @@ export class PwaXtreamDataSource implements IXtreamDataSource {
this.contentCache.clear();
}
}
/**
* The lock store keys provider category ids as numbers (the lock editor
* converts with `Number`), so a noncanonical provider id such as `"001"`
* must be compared as `"1"`. Non-numeric and empty ids stay as they are.
*/
function canonicalCategoryId(id: string | number | null | undefined): string {
const raw = String(id ?? '').trim();
const numeric = Number(raw);
return raw !== '' && Number.isFinite(numeric) ? String(numeric) : raw;
}
@@ -88,6 +88,8 @@ export interface XtreamCategoryFromDb {
type: 'movies' | 'live' | 'series';
xtream_id: number;
hidden: boolean;
/** Parental lock index; see ParentalLockService. */
locked: boolean;
}
/**
@@ -22,7 +22,7 @@ describe('EpgQueueService invalidation', () => {
type ServicePrivates = {
fetchEpg: (
credentials: typeof credentials,
portalCredentials: typeof credentials,
streamId: number
) => Promise<void>;
shouldFetch: (streamId: number) => boolean;
@@ -42,7 +42,9 @@ describe('EpgQueueService invalidation', () => {
provide: XtreamXmltvFallbackService,
useValue: {
getProgramsForChannel: jest.fn().mockResolvedValue([]),
getCurrentProgramsBatch: jest.fn().mockResolvedValue({}),
getCurrentProgramsBatch: jest
.fn()
.mockResolvedValue({}),
},
},
{
@@ -25,7 +25,7 @@ describe('EpgQueueService', () => {
type ServicePrivates = {
fetchEpg: (
credentials: typeof credentials,
portalCredentials: typeof credentials,
streamId: number
) => Promise<void>;
shouldFetch: (streamId: number) => boolean;
@@ -8,7 +8,7 @@ import {
import { EpgSourceSettingsService, SettingsStore } from '@iptvnator/services';
import { XtreamApiService, XtreamCredentials } from './xtream-api.service';
import { XtreamXmltvFallbackService } from './xtream-xmltv-fallback.service';
import { createLogger } from '@iptvnator/portal/shared/util';
import { createLogger } from '@iptvnator/portal/shared/util/logger';
interface CacheEntry {
data: EpgItem[];
@@ -1,6 +1,6 @@
import { inject, Injectable } from '@angular/core';
import { DataService } from '@iptvnator/services';
import { createLogger } from '@iptvnator/portal/shared/util';
import { createLogger } from '@iptvnator/portal/shared/util/logger';
import {
EpgItem,
XtreamCategory,
@@ -223,7 +223,7 @@ describe('XtreamUrlService', () => {
.mockResolvedValueOnce({ status: 206 });
window.electron = {
xtreamProbeUrl,
} as typeof window.electron;
} as unknown as typeof window.electron;
const firstUrl = await service.resolveCatchupUrl(
'playlist-1',
@@ -263,7 +263,7 @@ describe('XtreamUrlService', () => {
}));
window.electron = {
xtreamProbeUrl,
} as typeof window.electron;
} as unknown as typeof window.electron;
const initialUrl = await service.resolveCatchupUrl(
'playlist-format-refresh',
@@ -306,7 +306,7 @@ describe('XtreamUrlService', () => {
}));
window.electron = {
xtreamProbeUrl,
} as typeof window.electron;
} as unknown as typeof window.electron;
const catchupUrl = await service.resolveCatchupUrl(
'playlist-hls',
@@ -345,7 +345,7 @@ describe('XtreamUrlService', () => {
}));
window.electron = {
xtreamProbeUrl,
} as typeof window.electron;
} as unknown as typeof window.electron;
const catchupUrl = await service.resolveCatchupUrl(
'playlist-hls-only',
@@ -31,7 +31,7 @@ describe('XtreamXmltvFallbackService', () => {
getChannelPrograms: jest.fn(),
getCurrentProgramsBatch: jest.fn(),
};
(window as { electron?: Bridge }).electron = bridge;
(window as { electron?: unknown }).electron = bridge;
});
afterEach(() => {
@@ -4,7 +4,7 @@ import {
EpgProgram,
epgProviderClockMs,
} from '@iptvnator/shared/interfaces';
import { createLogger } from '@iptvnator/portal/shared/util';
import { createLogger } from '@iptvnator/portal/shared/util/logger';
import { EpgSourceSettingsService, SettingsStore } from '@iptvnator/services';
type ElectronEpgBridge = {
@@ -0,0 +1,277 @@
import { TestBed } from '@angular/core/testing';
import { PortalStatusType } from '../../xtream-state';
import {
createContentTestProviders,
createContentTestStore,
createDeferred,
createPendingRestoreServiceMock,
waitForCondition,
} from './with-content.feature.spec-helpers';
jest.mock('@iptvnator/portal/shared/util', () => ({
createLogger: () => ({
debug: jest.fn(),
info: jest.fn(),
warn: jest.fn(),
error: jest.fn(),
}),
}));
type ContentType = 'live' | 'movie' | 'series';
const TestContentStore = createContentTestStore(() =>
Promise.resolve('active' as PortalStatusType)
);
/**
* Parental-lock reloads (`reloadCategories` / `reloadCachedContent`) fail
* closed. Split from `with-content.feature.spec.ts`, which sits at the spec
* line cap.
*/
describe('withContent parental-lock reloads', () => {
let store: InstanceType<typeof TestContentStore>;
let dataSource: {
getCategories: jest.Mock;
getCachedCategories: jest.Mock;
getContent: jest.Mock;
getCachedContent: jest.Mock;
hasCategories: jest.Mock;
hasContent: jest.Mock;
restoreUserData: jest.Mock;
};
beforeEach(() => {
localStorage.clear();
dataSource = {
getCategories: jest.fn().mockResolvedValue([]),
getCachedCategories: jest.fn().mockResolvedValue([]),
getContent: jest.fn().mockResolvedValue([]),
getCachedContent: jest.fn().mockResolvedValue([]),
hasCategories: jest.fn().mockResolvedValue(true),
hasContent: jest.fn().mockResolvedValue(false),
restoreUserData: jest.fn().mockResolvedValue(undefined),
};
TestBed.configureTestingModule({
providers: createContentTestProviders(TestContentStore, {
dataSource,
databaseService: {
clearXtreamImportCache: jest.fn().mockResolvedValue(true),
cancelOperation: jest.fn().mockResolvedValue(true),
createOperationId: jest.fn(
(prefix?: string) => `${prefix ?? 'db-op'}-1`
),
getXtreamImportStatus: jest
.fn()
.mockResolvedValue('completed'),
setXtreamImportStatus: jest.fn().mockResolvedValue(true),
supportsDbOperationCancellation: jest
.fn()
.mockReturnValue(true),
},
xtreamApiService: {
cancelSession: jest.fn().mockResolvedValue(true),
},
pendingRestoreService: createPendingRestoreServiceMock(),
dataService: {
sendIpcEvent: jest
.fn()
.mockResolvedValue({ success: true }),
},
}),
});
store = TestBed.inject(TestContentStore);
});
afterEach(() => localStorage.clear());
it('withholds every catalog list at once and drops reads whose guard says no', async () => {
dataSource.getCategories.mockResolvedValue([{ category_id: 'x' }]);
dataSource.getContent.mockResolvedValue([{ xtream_id: 1 }]);
await store.initializeContent();
expect(store.liveStreams()).toEqual([{ xtream_id: 1 }]);
store.withholdCatalog();
expect(store.liveCategories()).toEqual([]);
expect(store.vodStreams()).toEqual([]);
expect(store.serialStreams()).toEqual([]);
expect(store.contentLoadStateByType().live).toBe('ready');
await store.reloadCategories(() => false);
await store.reloadCachedContent(() => false);
expect(store.liveCategories()).toEqual([]);
expect(store.liveStreams()).toEqual([]);
await store.reloadCachedContent();
expect(store.liveStreams()).toEqual([{ xtream_id: 1 }]);
});
it('withholds rows published by a hydration a relock overtook and reloads them afterwards', async () => {
const live = createDeferred<unknown[]>();
let calls = 0;
dataSource.getContent.mockImplementation(() => {
calls += 1;
if (calls === 1) {
// The hydration's live read is held open; the relock lands
// while it is in flight.
return live.promise;
}
// Reads 2-3 still belong to the hydration, later ones to the
// deferred filtered reload.
return Promise.resolve(
calls <= 3 ? [{ xtream_id: 1 }] : [{ xtream_id: 2 }]
);
});
let categoryCalls = 0;
dataSource.getCategories.mockImplementation(async () => {
categoryCalls += 1;
return categoryCalls <= 3
? [{ category_id: 'old' }]
: [{ category_id: 'new' }];
});
const initialization = store.initializeContent();
await waitForCondition(() => calls === 1);
await store.reloadCachedContent();
expect(calls).toBe(1);
live.resolve([{ xtream_id: 1 }]);
await initialization;
expect(calls).toBe(6);
expect(categoryCalls).toBe(6);
expect(store.liveCategories()).toEqual([{ category_id: 'new' }]);
expect(store.liveStreams()).toEqual([{ xtream_id: 2 }]);
expect(store.vodStreams()).toEqual([{ xtream_id: 2 }]);
expect(store.serialStreams()).toEqual([{ xtream_id: 2 }]);
expect(store.isContentInitialized()).toBe(true);
});
it("carries the deferred request's publish guard into the reload", async () => {
const live = createDeferred<unknown[]>();
let calls = 0;
dataSource.getContent.mockImplementation(() => {
calls += 1;
return calls === 1
? live.promise
: Promise.resolve([{ xtream_id: calls }]);
});
const initialization = store.initializeContent();
await waitForCondition(() => calls === 1);
let current = true;
await store.reloadCachedContent(() => current);
// The lock moved on again before the hydration settled.
current = false;
live.resolve([{ xtream_id: 1 }]);
await initialization;
// The reload stops at its first guard check instead of publishing
// the older rows; the superseding apply reads everything again.
expect(calls).toBe(4);
expect(store.liveStreams()).toEqual([]);
expect(store.vodStreams()).toEqual([]);
expect(store.serialStreams()).toEqual([]);
});
it('suppresses a hydration publication as soon as the catalog is withheld', async () => {
const live = createDeferred<unknown[]>();
let calls = 0;
let liveAfterHydrationPublish: unknown;
dataSource.getContent.mockImplementation(() => {
calls += 1;
if (calls === 1) {
return live.promise;
}
if (calls === 2) {
// The hydration published its live rows just before this
// (vod) read: capture what it left on screen.
liveAfterHydrationPublish = store.liveStreams();
}
return Promise.resolve([{ xtream_id: calls }]);
});
const initialization = store.initializeContent();
await waitForCondition(() => calls === 1);
// Relock: the catalog is withheld BEFORE any reload has run, then
// the hydration's pending (unlocked) live read resolves.
store.withholdCatalog();
live.resolve([{ xtream_id: 1 }]);
await initialization;
expect(liveAfterHydrationPublish).toEqual([]);
// The deferred reload refilled the list with fresh rows.
expect(store.liveStreams()).not.toEqual([{ xtream_id: 1 }]);
expect(store.liveStreams()).not.toEqual([]);
});
it('empties the category lists when their reload fails', async () => {
dataSource.getCategories.mockResolvedValue([{ category_id: 'x' }]);
await store.reloadCategories();
expect(store.liveCategories()).toEqual([{ category_id: 'x' }]);
dataSource.getCategories.mockRejectedValue(new Error('db'));
await store.reloadCategories();
expect(store.liveCategories()).toEqual([]);
expect(store.vodCategories()).toEqual([]);
expect(store.serialCategories()).toEqual([]);
});
it('empties a type whose cached reload fails and lets the others reload', async () => {
dataSource.getContent.mockResolvedValue([{ xtream_id: 1 }]);
await store.initializeContent();
expect(store.contentLoadStateByType()).toEqual({
live: 'ready',
vod: 'ready',
series: 'ready',
});
dataSource.getContent.mockImplementation(
(_playlistId: string, _credentials: unknown, type: ContentType) =>
type === 'movie'
? Promise.reject(new Error('db'))
: Promise.resolve([{ xtream_id: 2 }])
);
await store.reloadCachedContent();
expect(store.vodStreams()).toEqual([]);
expect(store.liveStreams()).toEqual([{ xtream_id: 2 }]);
expect(store.serialStreams()).toEqual([{ xtream_id: 2 }]);
expect(store.contentLoadStateByType()).toEqual({
live: 'ready',
vod: 'idle',
series: 'ready',
});
});
it('drops reloads whose playlist was switched away while they read', async () => {
dataSource.getCategories.mockResolvedValue([{ category_id: 'a' }]);
dataSource.getContent.mockResolvedValue([{ xtream_id: 1 }]);
await store.initializeContent();
const categories = createDeferred<unknown[]>();
const content = createDeferred<unknown[]>();
dataSource.getCategories.mockReturnValue(categories.promise);
dataSource.getContent.mockImplementation(
(_playlistId: string, _credentials: unknown, type: ContentType) =>
type === 'live'
? content.promise
: Promise.reject(new Error('db'))
);
const reloadingCategories = store.reloadCategories();
const reloadingContent = store.reloadCachedContent();
store.switchPlaylist('playlist-b');
categories.resolve([{ category_id: 'stale' }]);
content.resolve([{ xtream_id: 99 }]);
await reloadingCategories;
await reloadingContent;
expect(store.liveCategories()).toEqual([{ category_id: 'a' }]);
expect(store.liveStreams()).toEqual([{ xtream_id: 1 }]);
expect(store.contentLoadStateByType()).toEqual({
live: 'ready',
vod: 'ready',
series: 'ready',
});
});
});
Loaded 100 of 378 files, more files were not shown because too many files have changed in this diff. Show more