Proyecto App Linux

This commit is contained in:
2026-05-03 23:43:09 +02:00
commit 7482733843
1248 changed files with 189750 additions and 0 deletions
@@ -0,0 +1,21 @@
export default {
displayName: 'portal-stalker-data-access',
preset: '../../../../jest.preset.js',
setupFilesAfterEnv: ['<rootDir>/src/test-setup.ts'],
coverageDirectory: '../../../../coverage/libs/portal/stalker/data-access',
transform: {
'^.+\\.(ts|mjs|js|html)$': [
'jest-preset-angular',
{
tsconfig: '<rootDir>/tsconfig.spec.json',
stringifyContentPathRegex: '\\.(html|svg)$',
},
],
},
transformIgnorePatterns: ['node_modules/(?!.*\\.mjs$)'],
snapshotSerializers: [
'jest-preset-angular/build/serializers/no-ng-attributes',
'jest-preset-angular/build/serializers/ng-snapshot',
'jest-preset-angular/build/serializers/html-comment',
],
};
@@ -0,0 +1,21 @@
{
"name": "portal-stalker-data-access",
"$schema": "../../../../../node_modules/nx/schemas/project-schema.json",
"sourceRoot": "libs/portal/stalker/data-access/src",
"prefix": "lib",
"projectType": "library",
"tags": ["scope:portal", "domain:stalker", "type:data-access"],
"targets": {
"test": {
"executor": "@nx/jest:jest",
"outputs": ["{workspaceRoot}/coverage/{projectRoot}"],
"options": {
"jestConfig": "libs/portal/stalker/data-access/jest.config.ts",
"tsConfig": "libs/portal/stalker/data-access/tsconfig.spec.json"
}
},
"lint": {
"executor": "@nx/eslint:lint"
}
}
}
@@ -0,0 +1,8 @@
export * from './lib/models';
export * from './lib/stores';
export * from './lib/stalker-content-types';
export * from './lib/stalker-live-playback.utils';
export * from './lib/stalker-series.adapters';
export * from './lib/stalker-session.service';
export * from './lib/stalker-vod.utils';
export * from './lib/stalker.store';
@@ -0,0 +1,8 @@
export * from './stalker-favorite-item.interface';
export * from './stalker-category-item.interface';
export * from './stalker-content-item.interface';
export * from './stalker-itv-channel.interface';
export * from './stalker-season.interface';
export * from './stalker-serial-details.interface';
export * from './stalker-vod-details.interface';
export * from './stalker-vod-series.interface';
@@ -0,0 +1,5 @@
export interface StalkerCategoryItem {
category_id: string;
category_name: string;
[key: string]: unknown;
}
@@ -0,0 +1,9 @@
import { StalkerVodSource } from './stalker-favorite-item.interface';
/**
* Generic content item returned by Stalker category content endpoints.
* Used for VOD, series, and ITV category lists.
*/
export interface StalkerContentItem extends StalkerVodSource {
cover?: string;
}
@@ -0,0 +1,67 @@
import {
StalkerVodDetails,
StalkerVodInfo,
} from 'shared-interfaces';
export type StalkerSeriesFlag = true | 1 | '1';
/**
* Loose source shape coming from Stalker responses / favorites payloads.
* Keep optional to model heterogeneous portal responses.
*/
export interface StalkerVodSource {
id?: string | number;
stream_id?: string | number;
movie_id?: string | number;
series_id?: string | number;
cmd?: string;
series?: unknown[];
has_files?: number;
is_series?: boolean | number | string | null | undefined;
video_id?: string;
category_id?: string;
title?: string;
cover?: string;
logo?: string;
screenshot_uri?: string;
name?: string;
o_name?: string;
description?: string;
actors?: string;
director?: string;
releasedate?: string;
year?: string;
genre?: string;
genres_str?: string;
rating_imdb?: string | number;
rating_kinopoisk?: string | number;
info?: Partial<StalkerVodInfo> | null;
}
/**
* Normalized VOD item used by Stalker detail/favorites flows.
*/
export interface StalkerSelectedVodItem extends StalkerVodDetails {
series?: unknown[];
has_files?: number;
is_series?: true;
video_id?: string;
category_id?: string;
}
/**
* Favorite item persisted for Stalker portal.
*/
export interface StalkerFavoriteItem {
id?: string | number;
stream_id?: string | number;
movie_id?: string | number;
series_id?: string | number;
category_id?: string;
cmd?: string;
name?: string;
o_name?: string;
logo?: string;
details?: StalkerSelectedVodItem;
[key: string]: unknown;
}
@@ -0,0 +1,13 @@
import { StalkerVodSource } from './stalker-favorite-item.interface';
/**
* ITV/live channel shape used by Stalker live TV flow.
* Stalker channel payloads are heterogeneous, so this extends the shared source shape.
*/
export interface StalkerItvChannel extends StalkerVodSource {
id: string | number;
cmd: string;
name?: string;
o_name?: string;
logo?: string;
}
@@ -0,0 +1,17 @@
export interface StalkerSeason {
id: string;
series: string[];
name: string;
cmd: string;
description: string;
director: string;
actors: string;
year: string;
genres_str: string;
age: string;
rating_imdb: string;
rating_kinopoisk: string;
screenshot_uri: string;
added: string;
}
@@ -0,0 +1,5 @@
/**
* @deprecated Import from 'shared-interfaces' instead.
* This re-export exists for backward compatibility.
*/
export { StalkerSerialDetails } from 'shared-interfaces';
@@ -0,0 +1,8 @@
/**
* @deprecated Import from 'shared-interfaces' instead.
* This re-export exists for backward compatibility.
*/
export {
StalkerVodDetails,
StalkerVodInfo,
} from 'shared-interfaces';
@@ -0,0 +1,24 @@
/**
* Season item returned by Stalker VOD-series (Ministra is_series=1) endpoints.
*/
export interface StalkerVodSeriesSeason {
id: string;
video_id: string;
season_number?: string;
name?: string;
is_season?: boolean;
}
/**
* Episode item returned by Stalker VOD-series season endpoints.
*/
export interface StalkerVodSeriesEpisode {
id: string;
series_number?: number;
episode_num?: number;
name?: string;
cover?: string;
description?: string;
duration?: number | string;
is_episode?: boolean;
}
@@ -0,0 +1,32 @@
import { StalkerPortalActions } from 'shared-interfaces';
export const StalkerContentTypes = {
stb: {
doAuth: 'do_auth',
handshake: 'handshake',
},
itv: {
title: 'Live streams',
getContentAction: StalkerPortalActions.GetOrderedList,
getCategoryAction: StalkerPortalActions.GetGenres,
getLink: StalkerPortalActions.CreateLink,
},
vod: {
title: 'VOD streams',
getContentAction: StalkerPortalActions.GetOrderedList,
getCategoryAction: StalkerPortalActions.GetCategories,
getLink: StalkerPortalActions.CreateLink,
},
series: {
title: 'Series',
getContentAction: StalkerPortalActions.GetOrderedList,
getCategoryAction: StalkerPortalActions.GetCategories,
getLink: StalkerPortalActions.CreateLink,
},
/* radio: {
title: 'Radio',
getContentAction: StalkerPortalActions.GetOrderedList,
getCategoryAction: StalkerPortalActions.GetOrderedList,
getLink: StalkerPortalActions.CreateLink,
}, */
};
@@ -0,0 +1,94 @@
import { PlaylistMeta } from 'shared-interfaces';
export const STALKER_MAG_USER_AGENT =
'Mozilla/5.0 (QtEmbedded; U; Linux; C) AppleWebKit/533.3 (KHTML, like Gecko) MAG250';
export const STALKER_STREAM_USER_AGENT = 'KSPlayer';
export function getStalkerPortalOrigin(
playlist: PlaylistMeta | undefined | null
): string | undefined {
const portalUrl = playlist?.portalUrl;
if (!portalUrl) {
return undefined;
}
try {
return new URL(portalUrl).origin;
} catch {
return undefined;
}
}
export function isCrossOriginStalkerStream(
playlist: PlaylistMeta | undefined | null,
streamUrl?: string
): boolean {
const portalOrigin = getStalkerPortalOrigin(playlist);
if (!portalOrigin || !streamUrl) {
return false;
}
try {
return new URL(streamUrl).origin !== portalOrigin;
} catch {
return false;
}
}
export function buildStalkerExternalPlaybackHeaders(
playlist: PlaylistMeta | undefined | null,
token?: string | null,
streamUrl?: string
): Record<string, string> {
if (!playlist?.macAddress) {
return {};
}
if (isCrossOriginStalkerStream(playlist, streamUrl)) {
return {
'User-Agent': STALKER_STREAM_USER_AGENT,
Accept: '*/*',
Range: 'bytes=0-',
Connection: 'keep-alive',
'Icy-MetaData': '1',
};
}
const cookieParts = [
`mac=${playlist.macAddress}`,
'stb_lang=en_US@rg=dezzzz',
'timezone=Europe/Berlin',
];
if (playlist.stalkerSerialNumber) {
cookieParts.push(
`__cfduid=${playlist.stalkerSerialNumber.toLowerCase()}e030245495acd6ebfc1`
);
}
const headers: Record<string, string> = {
Cookie: cookieParts.join('; '),
'X-User-Agent': STALKER_MAG_USER_AGENT,
SN: playlist.stalkerSerialNumber || '',
};
if (token) {
headers['Authorization'] = `Bearer ${token}`;
}
const origin = getStalkerPortalOrigin(playlist);
if (origin) {
headers['Origin'] = origin;
headers['Referer'] = origin;
}
return Object.entries(headers).reduce<Record<string, string>>(
(acc, [name, value]) => {
if (value?.trim()) {
acc[name] = value;
}
return acc;
},
{}
);
}
@@ -0,0 +1,99 @@
import {
isVodSeriesItem,
mapRegularSeriesEpisodes,
mapRegularSeriesSeasons,
mapVodSeriesEpisodes,
mapVodSeriesSeasonsToVm,
} from './stalker-series.adapters';
import { StalkerSelectedVodItem } from './models';
type EpisodeWithMetadata = {
custom_sid?: string;
id?: string;
originalId?: string;
originalCmd?: string;
};
describe('stalker-series.adapters', () => {
it('detects vod-series flags from heterogeneous payloads', () => {
expect(isVodSeriesItem({ is_series: true })).toBe(true);
expect(isVodSeriesItem({ is_series: 1 })).toBe(true);
expect(isVodSeriesItem({ is_series: '1' })).toBe(true);
expect(isVodSeriesItem({ is_series: false })).toBe(false);
});
it('maps VOD-series seasons to VM shape with defaults', () => {
const seasons = mapVodSeriesSeasonsToVm([
{
id: '10',
video_id: '20',
season_number: '2',
},
]);
expect(seasons).toEqual([
{
id: '10',
video_id: '20',
season_number: '2',
name: 'Season 2',
episodes: [],
isLoading: false,
isExpanded: false,
},
]);
});
it('maps VOD-series episodes with stable tracking IDs and originalId metadata', () => {
const mapped = mapVodSeriesEpisodes(
[
{
id: 's1',
video_id: 'v1',
name: 'Season 1',
season_number: '1',
episodes: [
{
id: 'api-shared-id',
series_number: 1,
name: 'Episode 1',
},
{
id: 'api-shared-id',
series_number: 2,
name: 'Episode 2',
},
],
isLoading: false,
isExpanded: false,
},
],
'poster.jpg'
);
expect(mapped['1']).toHaveLength(2);
const firstEpisode = mapped['1'][0] as EpisodeWithMetadata;
expect(firstEpisode.custom_sid).toBe('vod-series');
expect(firstEpisode.originalId).toBe('api-shared-id');
expect(firstEpisode.id).not.toBe(mapped['1'][1].id);
});
it('maps embedded series payload into regular season episodes', () => {
const regularSeasons = mapRegularSeriesSeasons(
{
id: '100',
cmd: '/media/file_100.mpg',
series: ['1', 2, 'x'],
info: { name: 'Embedded Series' },
} as StalkerSelectedVodItem,
[]
);
const mapped = mapRegularSeriesEpisodes(regularSeasons, 'poster.jpg');
expect(regularSeasons[0].series).toEqual([1, 2]);
expect(mapped['1']).toHaveLength(2);
const firstEpisode = mapped['1'][0] as EpisodeWithMetadata;
expect(firstEpisode.custom_sid).toBe('regular-series');
expect(firstEpisode.originalCmd).toBe('/media/file_100.mpg');
});
});
@@ -0,0 +1,232 @@
import { XtreamSerieEpisode } from 'shared-interfaces';
import {
StalkerSeason,
StalkerVodSeriesEpisode,
StalkerVodSeriesSeason,
StalkerVodSource,
} from './models';
import { isStalkerSeriesFlag } from './stalker-vod.utils';
export interface VodSeriesSeasonVm {
id: string;
video_id: string;
name: string;
season_number: string;
episodes: StalkerVodSeriesEpisode[];
isLoading: boolean;
isExpanded: boolean;
}
export interface StalkerSeriesSeasonVm {
id: string;
name: string;
cmd?: string;
series: number[];
}
export interface StalkerMappedEpisode extends XtreamSerieEpisode {
originalId?: string;
originalCmd?: string;
}
function hashString(str: string): number {
let hash = 0;
for (let i = 0; i < str.length; i++) {
const char = str.charCodeAt(i);
hash = (hash << 5) - hash + char;
hash = hash & hash;
}
return Math.abs(hash);
}
function generateEpisodeId(
seed: string,
episodeNum: number,
seasonKey: string,
isVodSeries: boolean
): number {
if (isVodSeries) {
return hashString(`vod_${seasonKey}_${episodeNum}`);
}
return hashString(`${seed}_ep_${episodeNum}`);
}
function toEpisodeNumber(value: unknown): number {
if (typeof value === 'number') {
return Number.isFinite(value) ? value : 0;
}
if (typeof value === 'string') {
const parsed = Number(value);
return Number.isFinite(parsed) ? parsed : 0;
}
return 0;
}
function toNonEmptyString(value: unknown, fallback: string): string {
if (typeof value === 'string' && value.trim() !== '') {
return value;
}
return fallback;
}
function toSeriesNumbers(values: unknown[] | undefined): number[] {
if (!Array.isArray(values)) {
return [];
}
return values
.map((value) => toEpisodeNumber(value))
.filter((value) => value > 0);
}
export function isVodSeriesItem(
item: Pick<StalkerVodSource, 'is_series'> | null | undefined
): boolean {
return isStalkerSeriesFlag(item?.is_series);
}
export function mapVodSeriesSeasonsToVm(
seasons: StalkerVodSeriesSeason[] | undefined
): VodSeriesSeasonVm[] {
return (seasons ?? []).map((season) => ({
id: String(season.id),
video_id: String(season.video_id),
name: toNonEmptyString(
season.name,
`Season ${toNonEmptyString(season.season_number, '0')}`
),
season_number: toNonEmptyString(season.season_number, ''),
episodes: [],
isLoading: false,
isExpanded: false,
}));
}
export function mapRegularSeriesSeasons(
vodWithSeries: StalkerVodSource | null,
serialSeasons: StalkerSeason[] | undefined
): StalkerSeriesSeasonVm[] {
if (vodWithSeries?.series && vodWithSeries.series.length > 0) {
return [
{
id: String(vodWithSeries.id),
name: vodWithSeries.info?.name || 'Episodes',
cmd: vodWithSeries.cmd,
series: toSeriesNumbers(vodWithSeries.series),
},
];
}
return (serialSeasons ?? []).map((season) => ({
id: String(season.id),
name: season.name,
cmd: season.cmd,
series: toSeriesNumbers(season.series),
}));
}
function createBaseEpisode(
id: number,
episodeNum: number,
title: string,
containerExtension: string,
customSid: string,
season: number,
info: XtreamSerieEpisode['info']
): XtreamSerieEpisode {
return {
id: String(id),
episode_num: episodeNum,
title,
container_extension: containerExtension,
info,
custom_sid: customSid,
added: '',
season,
direct_source: '',
};
}
export function mapVodSeriesEpisodes(
seasons: ReadonlyArray<VodSeriesSeasonVm>,
fallbackPoster?: string
): Record<string, XtreamSerieEpisode[]> {
const mapped: Record<string, XtreamSerieEpisode[]> = {};
seasons.forEach((season) => {
const seasonKey = season.season_number || season.name || season.id;
const seasonNum = toEpisodeNumber(seasonKey) || 1;
mapped[seasonKey] = (season.episodes ?? []).map((episode) => {
const episodeNum =
toEpisodeNumber(episode.series_number) ||
toEpisodeNumber(episode.episode_num);
const trackingId = generateEpisodeId(
String(episode.id ?? ''),
episodeNum,
seasonKey,
true
);
return {
...createBaseEpisode(
trackingId,
episodeNum,
episode.name || `Episode ${episodeNum}`,
'mpg',
'vod-series',
seasonNum,
{
movie_image: episode.cover || fallbackPoster,
plot: episode.description || '',
duration: episode.duration
? `${episode.duration} min`
: '',
}
),
originalId: String(episode.id ?? ''),
} as StalkerMappedEpisode;
});
});
return mapped;
}
export function mapRegularSeriesEpisodes(
seasons: ReadonlyArray<StalkerSeriesSeasonVm>,
fallbackPoster?: string
): Record<string, XtreamSerieEpisode[]> {
const mapped: Record<string, XtreamSerieEpisode[]> = {};
seasons.forEach((season, index) => {
const seasonKey = String(index + 1);
mapped[seasonKey] = (season.series ?? []).map((episodeNum) => {
const trackingId = generateEpisodeId(
String(season.cmd ?? ''),
episodeNum,
seasonKey,
false
);
return {
...createBaseEpisode(
trackingId,
episodeNum,
`Episode ${episodeNum}`,
'',
'regular-series',
Number(seasonKey),
{
movie_image: fallbackPoster,
}
),
originalCmd: season.cmd,
} as StalkerMappedEpisode;
});
});
return mapped;
}
export function getVodSeriesSeasonKey(season: VodSeriesSeasonVm): string {
return season.season_number || season.name || season.id;
}
@@ -0,0 +1,631 @@
import { Injectable, inject } from '@angular/core';
import { Playlist, STALKER_REQUEST } from 'shared-interfaces';
import { DataService } from 'services';
/**
* SHA1 hash using native Web Crypto API
* Produces correct 40-character hex hash matching real Stalker clients
*/
async function sha1(str: string): Promise<string> {
const encoder = new TextEncoder();
const data = encoder.encode(str);
const hashBuffer = await crypto.subtle.digest('SHA-1', data);
const hashArray = Array.from(new Uint8Array(hashBuffer));
return hashArray.map((b) => b.toString(16).padStart(2, '0')).join('');
}
/**
* Generates SHA1 prehash from MAC address
* This must match what real Stalker clients send
*/
async function generatePrehash(macAddress: string): Promise<string> {
// Use MAC address with colons, uppercase - this is what most clients use
const str = macAddress.toUpperCase();
return (await sha1(str)).toUpperCase();
}
/**
* Generates a random string for metrics
*/
function generateRandom(): string {
const chars = 'abcdef0123456789';
let result = '';
for (let i = 0; i < 40; i++) {
result += chars.charAt(Math.floor(Math.random() * chars.length));
}
return result;
}
/**
* Deterministic serial number - 13 hex characters
* Constant value for consistency across all sessions
*/
export const STALKER_SERIAL_NUMBER = 'BEDACD4569BAF';
const STALKER_WATCHDOG_INTERVAL_MS = 25_000;
export interface StalkerHandshakeResponse {
js: {
token: string;
not_valid?: number;
random?: string;
};
}
export interface StalkerProfileResponse {
js: {
id?: string;
name?: string;
mac?: string;
status?: number;
msg?: string;
block_msg?: string;
account_info?: {
login?: string;
expire_date?: number;
tariff_plan_name?: string;
status?: number;
};
};
}
interface StalkerAuthConfirmationResponse {
js?: boolean;
}
/**
* Service to manage Stalker portal session tokens.
* Handles handshake authentication for full stalker portals (/stalker_portal/c URLs).
* Persists tokens during session and handles re-authentication on auth failures.
*/
@Injectable({
providedIn: 'root',
})
export class StalkerSessionService {
private dataService = inject(DataService);
// In-memory token cache for current session (keyed by playlist ID)
private tokenCache = new Map<string, string>();
// Pending authentication promises to prevent race conditions
// When multiple requests need a token simultaneously, they all wait for the same auth
private pendingAuth = new Map<
string,
Promise<{ token: string; serialNumber?: string }>
>();
private watchdogIntervals = new Map<string, ReturnType<typeof setInterval>>();
private watchdogPlaylists = new Map<string, Playlist>();
private watchdogInFlight = new Set<string>();
private activeWatchdogPlaylistId: string | null = null;
/**
* Generates a consistent 64-character device ID from MAC address
*/
private generateDeviceId(macAddress: string): string {
// Generate consistent device ID from MAC
const str = macAddress.toUpperCase().replace(/:/g, '');
const chars = '0123456789ABCDEF';
let result = '';
let seed = 0;
for (let i = 0; i < str.length; i++) {
seed = (seed * 31 + str.charCodeAt(i)) >>> 0;
}
for (let i = 0; i < 64; i++) {
seed = (seed * 1103515245 + 12345) >>> 0;
result += chars[seed % 16];
}
return result;
}
/**
* Checks if a URL is a full stalker portal URL (requires handshake)
* Full stalker portal URLs contain /stalker_portal/ in the path
*/
isFullStalkerPortal(url: string): boolean {
return (
url.includes('/stalker_portal/') || url.includes('/server/load.php')
);
}
/**
* Gets the cached token for a playlist, or null if not cached
*/
getCachedToken(playlistId: string): string | null {
return this.tokenCache.get(playlistId) || null;
}
/**
* Sets a token in the cache
*/
setCachedToken(playlistId: string, token: string): void {
this.tokenCache.set(playlistId, token);
}
/**
* Clears the cached token for a playlist (e.g., on auth failure)
*/
clearCachedToken(playlistId: string): void {
this.tokenCache.delete(playlistId);
}
/**
* Sets which playlist should receive periodic watchdog pings.
* This keeps some Ministra/Stalker sessions alive for live playback.
*/
setActiveWatchdogPlaylist(playlist?: Playlist | null): void {
const nextPlaylistId = playlist?._id ?? null;
if (
this.activeWatchdogPlaylistId &&
this.activeWatchdogPlaylistId !== nextPlaylistId
) {
this.stopWatchdog(this.activeWatchdogPlaylistId);
}
this.activeWatchdogPlaylistId = nextPlaylistId;
if (
!playlist ||
!playlist.isFullStalkerPortal ||
!playlist.portalUrl ||
!playlist.macAddress
) {
if (nextPlaylistId) {
this.stopWatchdog(nextPlaylistId);
}
return;
}
this.startWatchdog(playlist);
}
private startWatchdog(playlist: Playlist): void {
const playlistId = playlist._id;
this.watchdogPlaylists.set(playlistId, playlist);
if (this.watchdogIntervals.has(playlistId)) {
return;
}
void this.sendWatchdogPing(playlistId, '1');
const intervalId = setInterval(() => {
void this.sendWatchdogPing(playlistId, '0');
}, STALKER_WATCHDOG_INTERVAL_MS);
this.watchdogIntervals.set(playlistId, intervalId);
}
private stopWatchdog(playlistId: string): void {
const intervalId = this.watchdogIntervals.get(playlistId);
if (intervalId) {
clearInterval(intervalId);
this.watchdogIntervals.delete(playlistId);
}
this.watchdogPlaylists.delete(playlistId);
this.watchdogInFlight.delete(playlistId);
}
private async sendWatchdogPing(
playlistId: string,
init: '0' | '1'
): Promise<void> {
if (this.watchdogInFlight.has(playlistId)) {
return;
}
const playlist = this.watchdogPlaylists.get(playlistId);
if (
!playlist ||
!playlist.portalUrl ||
!playlist.macAddress ||
!playlist.isFullStalkerPortal
) {
this.stopWatchdog(playlistId);
return;
}
this.watchdogInFlight.add(playlistId);
try {
await this.makeAuthenticatedRequest(
playlist,
{
type: 'watchdog',
action: 'get_events',
event_active_id: '0',
cur_play_type: '0',
init,
JsHttpRequest: '1-xml',
},
false
);
} catch (error) {
// Keep failures non-fatal; next interval can recover after token refresh.
console.warn('[StalkerSession] Watchdog ping failed:', error);
} finally {
this.watchdogInFlight.delete(playlistId);
}
}
/**
* Performs handshake to get a session token for a full stalker portal
* Returns both the token and the random value for use in subsequent requests
*/
async performHandshake(
portalUrl: string,
macAddress: string
): Promise<{ token: string; random: string }> {
const prehash = await generatePrehash(macAddress);
const params: Record<string, string> = {
type: 'stb',
action: 'handshake',
token: '',
prehash,
JsHttpRequest: '1-xml',
};
try {
const response: StalkerHandshakeResponse =
await this.dataService.sendIpcEvent<StalkerHandshakeResponse>(
STALKER_REQUEST,
{
url: portalUrl,
macAddress,
params,
}
);
if (response?.js?.token) {
return {
token: response.js.token,
random: response.js.random || generateRandom(),
};
}
console.error('[StalkerSession] No token in response');
throw new Error('Handshake failed: No token received');
} catch (error) {
console.error('[StalkerSession] Handshake error:', error);
throw error;
}
}
/**
* Gets account profile information to validate the portal and check subscription
* Based on working implementation from stalker-to-m3u repo
*/
async getProfile(
portalUrl: string,
macAddress: string,
token: string,
serialNumber: string,
handshakeRandom: string,
providedDeviceId1?: string,
providedDeviceId2?: string,
providedSignature1?: string,
providedSignature2?: string
): Promise<StalkerProfileResponse> {
// Use provided device IDs or generate from MAC (64 hex chars each)
const deviceId1 =
providedDeviceId1?.trim() || this.generateDeviceId(macAddress);
const deviceId2 = providedDeviceId2?.trim() || deviceId1;
// Use provided signatures or empty string (some portals don't require them)
const signature1 = providedSignature1?.trim() || '';
const signature2 = providedSignature2?.trim() || '';
// Build metrics JSON matching working app
const metrics = {
mac: macAddress,
model: 'MAG250',
type: 'STB',
random: handshakeRandom,
sn: serialNumber,
};
// Generate prehash for get_profile (same as handshake)
const prehash = await generatePrehash(macAddress);
// Profile request matching working StalkerTV app
// auth_second_step=1, includes metrics, prehash, and device_id params
const params: Record<string, string> = {
type: 'stb',
action: 'get_profile',
hd: '1',
not_valid_token: '0',
video_out: 'hdmi',
auth_second_step: '1',
num_banks: '2',
metrics: JSON.stringify(metrics),
sn: serialNumber,
device_id: deviceId1,
device_id2: deviceId2,
signature: signature1,
...(signature2 ? { signature2: signature2 } : {}),
prehash: prehash,
stb_type: '',
JsHttpRequest: '1-xml',
};
try {
const response: StalkerProfileResponse =
await this.dataService.sendIpcEvent<StalkerProfileResponse>(
STALKER_REQUEST,
{
url: portalUrl,
macAddress,
params,
token,
serialNumber,
}
);
return response;
} catch (error) {
console.error('[StalkerSession] Get profile error:', error);
throw error;
}
}
/**
* Performs do_auth to authenticate the session after handshake
*/
async doAuth(
portalUrl: string,
macAddress: string,
token: string
): Promise<boolean> {
const params: Record<string, string> = {
type: 'stb',
action: 'do_auth',
login: '',
password: '',
device_id: '',
device_id2: '',
JsHttpRequest: '1-xml',
};
try {
const response =
await this.dataService.sendIpcEvent<StalkerAuthConfirmationResponse>(
STALKER_REQUEST,
{
url: portalUrl,
macAddress,
params,
token,
}
);
// do_auth returns { js: true } on success
if (response?.js === true) {
return true;
}
return false;
} catch (error) {
console.error('[StalkerSession] do_auth error:', error);
throw error;
}
}
/**
* Performs full authentication flow: handshake -> get_profile (NO do_auth based on working traces)
* Returns the token and account info if successful
*/
async authenticate(
portalUrl: string,
macAddress: string,
serialNumber: string,
deviceId1?: string,
deviceId2?: string,
signature1?: string,
signature2?: string
): Promise<{
token: string;
accountInfo?: StalkerProfileResponse['js']['account_info'];
}> {
// Step 1: Handshake to get token and random
const { token, random } = await this.performHandshake(
portalUrl,
macAddress
);
// Step 2: Get profile to activate token and get account info
// The random from handshake must be used in auth_second_step
try {
const profileResponse = await this.getProfile(
portalUrl,
macAddress,
token,
serialNumber,
random,
deviceId1,
deviceId2,
signature1,
signature2
);
// Check for profile-level errors
if (profileResponse?.js?.msg || profileResponse?.js?.block_msg) {
const errorMsg =
profileResponse.js.msg ||
profileResponse.js.block_msg ||
'Unknown profile error';
console.error('[StalkerSession] Profile error:', errorMsg);
throw new Error(`Profile error: ${errorMsg}`);
}
return {
token,
accountInfo: profileResponse?.js?.account_info,
};
} catch (error) {
// Profile fetch failed - this is a real error, propagate it
console.error('[StalkerSession] Profile fetch failed:', error);
throw error;
}
}
/**
* Ensures a valid token exists for a playlist, performing full auth if needed
* IMPORTANT: Based on working traces, each session needs handshake + get_profile
* Returns the token to use for requests, and the serial number to store
*/
async ensureToken(
playlist: Playlist
): Promise<{ token: string | null; serialNumber?: string }> {
// If not a full stalker portal, no token needed
if (!playlist.isFullStalkerPortal) {
return { token: null };
}
// Check in-memory cache first (valid for current session only)
const cachedToken = this.getCachedToken(playlist._id);
if (cachedToken) {
return { token: cachedToken };
}
// Check if there's already a pending authentication for this playlist
// This prevents race conditions when multiple resources request a token simultaneously
const pendingPromise = this.pendingAuth.get(playlist._id);
if (pendingPromise) {
console.log(
'[StalkerSession] Waiting for pending authentication...'
);
return pendingPromise;
}
// No cached token - need to do full authentication (handshake + get_profile)
// Don't trust stored tokens as they may be from a different session
if (!playlist.portalUrl || !playlist.macAddress) {
console.error('[StalkerSession] Missing portal URL or MAC address');
throw new Error('Portal URL and MAC address are required');
}
// Get or generate serial number - must be consistent for the MAC
let serialNumber = playlist.stalkerSerialNumber;
if (!serialNumber) {
serialNumber = STALKER_SERIAL_NUMBER;
}
// Create the authentication promise and store it to prevent concurrent auth attempts
// Use async/await wrapper to properly clean up on both success and failure
const authPromise = (async () => {
try {
const { token } = await this.authenticate(
playlist.portalUrl,
playlist.macAddress,
serialNumber
);
this.setCachedToken(playlist._id, token);
return { token, serialNumber };
} finally {
// Clean up pending promise regardless of success/failure
this.pendingAuth.delete(playlist._id);
}
})();
// Store the pending promise so other concurrent requests can wait on it
this.pendingAuth.set(playlist._id, authPromise);
return authPromise;
}
/**
* Checks if a response or error indicates an authorization failure
*/
private isAuthorizationError(responseOrError: unknown): boolean {
if (!responseOrError) return false;
const response = responseOrError as Record<string, unknown>;
// Convert response to string for pattern matching
const responseStr = JSON.stringify(responseOrError).toLowerCase();
// Check for "Authorization failed. XX" pattern (like "Authorization failed. 75")
if (/authorization\s*failed\.?\s*\d*/i.test(responseStr)) {
return true;
}
// Check for common auth failure indicators
const jsData = response?.['js'] as Record<string, unknown>;
const errorMessage =
(response?.['message'] as string)?.toLowerCase?.() ||
jsData?.['error']?.toString?.().toLowerCase?.() ||
jsData?.['msg']?.toString?.().toLowerCase?.() ||
'';
return (
errorMessage.includes('authorization') ||
errorMessage.includes('unauthorized') ||
errorMessage.includes('auth failed') ||
errorMessage.includes('invalid token') ||
response?.['status'] === 401 ||
jsData?.['error'] === 'Authorization failed'
);
}
/**
* Wrapper for making stalker requests with automatic token handling and retry on auth failure
* This should be used by all stalker API calls to ensure proper auth handling
*/
async makeAuthenticatedRequest<T>(
playlist: Playlist,
params: Record<string, string | number>,
retryOnAuthFailure = true
): Promise<T> {
// Get token (will wait if auth is in progress)
const { token } = await this.ensureToken(playlist);
const serialNumber = playlist.stalkerSerialNumber;
try {
const response = await this.dataService.sendIpcEvent<T>(
STALKER_REQUEST,
{
url: playlist.portalUrl,
macAddress: playlist.macAddress,
params,
token,
serialNumber,
}
);
// Check for authorization failure in response
if (this.isAuthorizationError(response)) {
if (retryOnAuthFailure && playlist.isFullStalkerPortal) {
// Clear cached token to force re-authentication
this.clearCachedToken(playlist._id);
// Retry once with fresh authentication
return this.makeAuthenticatedRequest<T>(
playlist,
params,
false
);
}
throw new Error('Authorization failed after retry');
}
return response;
} catch (error) {
// Check if error indicates auth failure
if (
this.isAuthorizationError(error) &&
retryOnAuthFailure &&
playlist.isFullStalkerPortal
) {
// Clear cached token and retry with new handshake
this.clearCachedToken(playlist._id);
return this.makeAuthenticatedRequest<T>(
playlist,
params,
false
);
}
throw error;
}
}
}
@@ -0,0 +1,227 @@
import { VodDetailsItem } from 'shared-interfaces';
import { StalkerFavoriteItem } from './models';
import {
buildStalkerFavoritePayload,
createStalkerInlineDetailState,
createStalkerDetailViewState,
normalizeStalkerFavoriteItem,
toggleStalkerVodFavorite,
} from './stalker-vod.utils';
describe('stalker-vod.utils regressions', () => {
it('routes embedded series[] items to series view state', () => {
const state = createStalkerDetailViewState(
{
id: '101',
cmd: '/media/file_101.mpg',
series: [1, 2, 3],
info: {
name: 'Series in VOD',
movie_image: '',
description: '',
actors: '',
director: '',
releasedate: '',
genre: '',
rating_imdb: '',
rating_kinopoisk: '',
},
},
'playlist-1'
);
expect(state.itemDetails?.series?.length).toBe(3);
expect(state.vodDetailsItem).toBeNull();
});
it('routes is_series=1 items to series view state (lazy season flow)', () => {
const state = createStalkerDetailViewState(
{
id: '1507',
cmd: '/media/file_1507.mpg',
is_series: true,
info: {
name: 'Flagged Series',
movie_image: '',
description: '',
actors: '',
director: '',
releasedate: '',
genre: '',
rating_imdb: '',
rating_kinopoisk: '',
},
},
'playlist-1'
);
expect(state.itemDetails?.is_series).toBe(true);
expect(state.vodDetailsItem).toBeNull();
});
it('builds a playable VOD details item for regular movies', () => {
const state = createStalkerDetailViewState(
{
id: '42',
cmd: '/media/file_42.mpg',
info: {
name: 'Regular Movie',
movie_image: 'poster.jpg',
description: '',
actors: '',
director: '',
releasedate: '',
genre: '',
rating_imdb: '',
rating_kinopoisk: '',
},
},
'playlist-1'
);
expect(state.itemDetails?.id).toBe('42');
expect(state.vodDetailsItem).toEqual(
expect.objectContaining({
type: 'stalker',
playlistId: 'playlist-1',
cmd: '/media/file_42.mpg',
})
);
expect(state.vodDetailsItem?.data.id).toBe('42');
});
it('favorite toggle uses completion callback path without delayed state update', () => {
const addToFavorites = jest.fn(
(_item: Record<string, unknown>, onDone?: () => void) => {
onDone?.();
}
);
const removeFromFavorites = jest.fn(
(_id: string, onDone?: () => void) => {
onDone?.();
}
);
const onComplete = jest.fn();
const item = {
type: 'stalker',
data: {
id: '42',
cmd: '/media/file_42.mpg',
info: {
name: 'Movie',
movie_image: '',
description: '',
actors: '',
director: '',
releasedate: '',
genre: '',
rating_imdb: '',
rating_kinopoisk: '',
},
},
playlistId: 'playlist-1',
vodId: 42,
cmd: '/media/file_42.mpg',
} as unknown as VodDetailsItem;
toggleStalkerVodFavorite(
{ item, isFavorite: true },
{ addToFavorites, removeFromFavorites, onComplete }
);
expect(addToFavorites).toHaveBeenCalledTimes(1);
expect(removeFromFavorites).not.toHaveBeenCalled();
expect(onComplete).toHaveBeenCalledTimes(1);
onComplete.mockClear();
toggleStalkerVodFavorite(
{ item, isFavorite: false },
{ addToFavorites, removeFromFavorites, onComplete }
);
expect(removeFromFavorites).toHaveBeenCalledTimes(1);
expect(onComplete).toHaveBeenCalledTimes(1);
});
it('preserves the concrete category id in favorite payloads', () => {
expect(
buildStalkerFavoritePayload({
id: '42',
cmd: '/media/file_42.mpg',
info: {
name: 'Movie',
movie_image: '',
description: '',
actors: '',
director: '',
releasedate: '',
genre: '',
rating_imdb: '',
rating_kinopoisk: '',
},
category_id: '17',
} as unknown as Parameters<typeof buildStalkerFavoritePayload>[0])
).toEqual(
expect.objectContaining({
id: '42',
category_id: '17',
})
);
});
it('normalizes nested favorite details for the shared inline detail shell', () => {
const detailState = createStalkerInlineDetailState(
normalizeStalkerFavoriteItem({
id: '7',
category_id: 'vod',
name: 'Nested Movie',
details: {
id: '7',
cmd: '/media/file_7.mpg',
series: [11, 12],
info: {
name: 'Nested Movie',
movie_image: '',
description: '',
actors: '',
director: '',
releasedate: '',
genre: '',
rating_imdb: '',
rating_kinopoisk: '',
},
},
} as StalkerFavoriteItem),
null
);
expect(detailState.categoryId).toBe('vod');
expect(detailState.seriesItem?.series).toEqual([11, 12]);
expect(detailState.isSeries).toBe(false);
expect(detailState.vodDetailsItem).toBeNull();
});
it('allows search to force series mode when results omit category metadata', () => {
const detailState = createStalkerInlineDetailState(
{
id: '9',
cmd: '/media/file_9.mpg',
info: {
name: 'Series Result',
movie_image: '',
description: '',
actors: '',
director: '',
releasedate: '',
genre: '',
rating_imdb: '',
rating_kinopoisk: '',
},
},
null,
'series'
);
expect(detailState.categoryId).toBe('series');
expect(detailState.seriesItem?.id).toBe('9');
});
});
@@ -0,0 +1,378 @@
import {
signal,
WritableSignal,
} from '@angular/core';
import { rxResource } from '@angular/core/rxjs-interop';
import { Observable } from 'rxjs';
import { PlaylistsService } from 'services';
import {
createStalkerVodItem,
StalkerVodDetails,
StalkerVodInfo,
VodDetailsItem,
} from 'shared-interfaces';
import {
StalkerFavoriteItem,
StalkerSelectedVodItem,
StalkerSeriesFlag,
StalkerVodSource,
} from './models/stalker-favorite-item.interface';
export interface StalkerDetailViewState {
itemDetails: StalkerSelectedVodItem | null;
vodDetailsItem: VodDetailsItem | null;
}
export interface StalkerInlineDetailState {
categoryId: 'vod' | 'series' | null;
seriesItem: StalkerSelectedVodItem | null;
isSeries: boolean;
vodDetailsItem: VodDetailsItem | null;
}
export interface StalkerFavoriteToggleEvent {
item: VodDetailsItem;
isFavorite: boolean;
}
export interface StalkerFavoriteToggleOptions {
addToFavorites: (
item: Record<string, unknown>,
onComplete?: () => void
) => void;
removeFromFavorites: (
favoriteId: string,
onComplete?: () => void
) => void;
onComplete?: () => void;
}
export function isStalkerSeriesFlag(value: unknown): boolean {
return value === true || value === 1 || value === '1';
}
export function normalizeStalkerSeriesFlag(
value: unknown
): StalkerSeriesFlag | undefined {
if (value === true || value === 1 || value === '1') {
return true;
}
return undefined;
}
export function isStalkerSeriesItem(item: {
is_series?: unknown;
series?: unknown;
}): boolean {
return (
isStalkerSeriesFlag(item?.is_series) ||
(Array.isArray(item?.series) && item.series.length > 0)
);
}
export function buildStalkerFavoritePayload(
data: StalkerVodDetails
): Record<string, unknown> {
const categoryId = String(
(data as StalkerVodDetails & { category_id?: string | number })
.category_id ?? 'vod'
).trim();
return {
...data,
category_id: categoryId || 'vod',
title: data.info?.name,
cover: data.info?.movie_image,
added_at: new Date().toISOString(),
};
}
export function matchesFavoriteById(
favorite: Partial<StalkerFavoriteItem>,
itemId: string | number
): boolean {
const expectedId = normalizeStalkerEntityId(itemId);
return (
normalizeStalkerEntityId(favorite?.id) === expectedId ||
normalizeStalkerEntityId(favorite?.movie_id) === expectedId ||
normalizeStalkerEntityId(favorite?.stream_id) === expectedId ||
normalizeStalkerEntityId(favorite?.series_id) === expectedId
);
}
export function normalizeStalkerEntityId(value: unknown): string {
if (value === null || value === undefined) {
return '';
}
return String(value).trim();
}
export function normalizeStalkerEntityIdAsNumber(
value: unknown
): number | null {
const normalized = normalizeStalkerEntityId(value);
if (!normalized) {
return null;
}
const parsed = Number(normalized);
return Number.isFinite(parsed) ? parsed : null;
}
function toStringOrFallback(value: unknown, fallback = ''): string {
if (typeof value === 'string') return value;
if (value === null || value === undefined) return fallback;
return String(value);
}
function toOptionalString(value: unknown): string | undefined {
if (value === null || value === undefined || value === '') {
return undefined;
}
return String(value);
}
export function createStalkerInfo(item: StalkerVodSource): StalkerVodInfo {
const info = item.info ?? {};
return {
name: toStringOrFallback(
info.name ?? info.o_name ?? item.o_name ?? item.name ?? item.title,
'Unknown'
),
o_name: toOptionalString(info.o_name ?? item.o_name),
movie_image: toStringOrFallback(
info.movie_image ?? item.cover ?? item.screenshot_uri ?? item.logo
),
description: toStringOrFallback(info.description ?? item.description),
actors: toStringOrFallback(info.actors ?? item.actors),
director: toStringOrFallback(info.director ?? item.director),
releasedate: toStringOrFallback(
info.releasedate ?? item.releasedate ?? item.year
),
genre: toStringOrFallback(info.genre ?? item.genre ?? item.genres_str),
rating_imdb: toStringOrFallback(info.rating_imdb ?? item.rating_imdb),
rating_kinopoisk: toStringOrFallback(
info.rating_kinopoisk ?? item.rating_kinopoisk
),
};
}
export function buildStalkerSelectedVodItem(
item: StalkerVodSource,
forceSeries = false
): StalkerSelectedVodItem {
return {
id: toStringOrFallback(item.id ?? item.stream_id),
cmd: toStringOrFallback(item.cmd),
series: item.series,
has_files: item.has_files,
is_series:
forceSeries || isStalkerSeriesFlag(item?.is_series)
? true
: undefined,
video_id: item.video_id,
category_id:
typeof item.category_id === 'string'
? item.category_id
: undefined,
info: createStalkerInfo(item),
};
}
export interface NormalizedStalkerFavoriteItem extends StalkerFavoriteItem {
details: StalkerSelectedVodItem;
}
export function normalizeStalkerFavoriteItem(
item: StalkerFavoriteItem
): NormalizedStalkerFavoriteItem {
if (item.details && item.details.info) {
return item as NormalizedStalkerFavoriteItem;
}
const source = (item.details || item) as StalkerVodSource;
const info = createStalkerInfo({
...item,
...source,
});
const normalizedIsSeries = normalizeStalkerSeriesFlag(
(item as StalkerVodSource).is_series ?? source.is_series
);
return {
...item,
details: {
...source,
info,
cmd: toStringOrFallback(
(item as StalkerVodSource).cmd ?? source.cmd
),
id: toStringOrFallback(item.stream_id ?? item.id ?? source.id),
series: (item as StalkerVodSource).series ?? source.series,
is_series: normalizedIsSeries ? true : undefined,
video_id:
(item as StalkerVodSource).video_id ?? source.video_id,
category_id:
typeof (item as StalkerVodSource).category_id === 'string'
? (item as StalkerVodSource).category_id
: typeof source.category_id === 'string'
? source.category_id
: undefined,
},
};
}
export function normalizeStalkerVodDetailsItem(
item: StalkerVodSource
): StalkerSelectedVodItem {
return {
...buildStalkerSelectedVodItem(item),
info: createStalkerInfo(item),
is_series: normalizeStalkerSeriesFlag(item?.is_series)
? true
: undefined,
};
}
export function createStalkerInlineDetailState(
itemDetails: StalkerSelectedVodItem | NormalizedStalkerFavoriteItem | null,
vodDetailsItem: VodDetailsItem | null,
categoryOverride?: 'vod' | 'series' | null
): StalkerInlineDetailState {
if (!itemDetails) {
return {
categoryId: null,
seriesItem: null,
isSeries: false,
vodDetailsItem: null,
};
}
const seriesItem =
'details' in itemDetails ? itemDetails.details : itemDetails;
const rawCategoryId =
categoryOverride ??
(typeof itemDetails.category_id === 'string'
? itemDetails.category_id
: typeof seriesItem.category_id === 'string'
? seriesItem.category_id
: undefined);
const categoryId =
rawCategoryId === 'vod' || rawCategoryId === 'movie'
? 'vod'
: rawCategoryId === 'series'
? 'series'
: null;
return {
categoryId,
seriesItem,
isSeries: isStalkerSeriesFlag(seriesItem?.is_series),
vodDetailsItem,
};
}
export function createStalkerDetailViewState(
normalizedItem: StalkerSelectedVodItem,
playlistId: string
): StalkerDetailViewState {
if (isStalkerSeriesItem(normalizedItem)) {
return {
itemDetails: normalizedItem,
vodDetailsItem: null,
};
}
return {
itemDetails: normalizedItem,
vodDetailsItem: createStalkerVodItem(
normalizedItem as StalkerVodDetails,
playlistId
),
};
}
export function clearStalkerDetailViewState(): StalkerDetailViewState {
return {
itemDetails: null,
vodDetailsItem: null,
};
}
export function createRefreshTrigger(): {
refreshVersion: WritableSignal<number>;
refresh: () => void;
} {
const refreshVersion = signal<number>(Date.now());
return {
refreshVersion,
refresh: () => refreshVersion.set(Date.now()),
};
}
export function createPortalFavoritesResource(
playlistService: PlaylistsService,
getPortalId: () => string | undefined,
getRefreshVersion: () => number
) {
return createPortalCollectionResource(
playlistService,
getPortalId,
getRefreshVersion,
(service, portalId) => service.getPortalFavorites(portalId)
);
}
export function createPortalCollectionResource<T>(
playlistService: PlaylistsService,
getPortalId: () => string | undefined,
getRefreshVersion: () => number,
streamFactory: (
playlistService: PlaylistsService,
portalId: string
) => Observable<T[]>
) {
return rxResource({
params: () => {
const portalId = getPortalId();
if (!portalId) return undefined;
return {
portalId,
refreshVersion: getRefreshVersion(),
};
},
stream: ({ params }) =>
streamFactory(playlistService, params.portalId),
});
}
export function isSelectedStalkerVodFavorite(
item: VodDetailsItem | null,
favorites: ReadonlyArray<StalkerFavoriteItem> | undefined
): boolean {
if (!item || item.type !== 'stalker') {
return false;
}
return (favorites ?? []).some((favorite) =>
matchesFavoriteById(favorite, item.data.id)
);
}
export function toggleStalkerVodFavorite(
event: StalkerFavoriteToggleEvent,
options: StalkerFavoriteToggleOptions
): void {
if (event.item.type !== 'stalker') {
return;
}
if (event.isFavorite) {
options.addToFavorites(
buildStalkerFavoritePayload(event.item.data),
options.onComplete
);
} else {
options.removeFromFavorites(event.item.data.id, options.onComplete);
}
}
@@ -0,0 +1,167 @@
import { TestBed } from '@angular/core/testing';
import { MatSnackBar } from '@angular/material/snack-bar';
import { Store } from '@ngrx/store';
import { TranslateService } from '@ngx-translate/core';
import { of } from 'rxjs';
import { PORTAL_PLAYER } from '@iptvnator/portal/shared/util';
import { DataService, PlaylistsService } from 'services';
import { StalkerSessionService } from './stalker-session.service';
import { StalkerStore } from './stalker.store';
describe('StalkerStore API compatibility smoke', () => {
let store: StalkerStore;
beforeEach(() => {
TestBed.configureTestingModule({
providers: [
StalkerStore,
{
provide: DataService,
useValue: {
isElectron: false,
sendIpcEvent: jest.fn(),
},
},
{
provide: StalkerSessionService,
useValue: {
ensureToken: jest.fn(),
makeAuthenticatedRequest: jest.fn(),
},
},
{
provide: PORTAL_PLAYER,
useValue: {
openPlayer: jest.fn(),
openResolvedPlayback: jest.fn(),
},
},
{
provide: PlaylistsService,
useValue: {
addPortalFavorite: jest.fn(() => of(undefined)),
removeFromPortalFavorites: jest.fn(() => of(undefined)),
addPortalRecentlyViewed: jest.fn(() => of(undefined)),
removeFromPortalRecentlyViewed: jest.fn(() =>
of(undefined)
),
},
},
{
provide: Store,
useValue: {
dispatch: jest.fn(),
},
},
{
provide: MatSnackBar,
useValue: {
open: jest.fn(),
},
},
{
provide: TranslateService,
useValue: {
instant: jest.fn((key: string) => key),
},
},
],
});
store = TestBed.inject(StalkerStore);
});
it('exposes compatibility state signals', () => {
const expectedSignals = [
'selectedContentType',
'selectedCategoryId',
'selectedVodId',
'selectedSerialId',
'selectedItvId',
'limit',
'page',
'searchPhrase',
'currentPlaylist',
'totalCount',
'selectedItem',
'vodCategories',
'seriesCategories',
'itvCategories',
'hasMoreChannels',
'itvChannels',
'vodSeriesSeasons',
'vodSeriesEpisodes',
'selectedVodSeriesSeasonId',
];
for (const signalName of expectedSignals) {
expect(typeof store[signalName]).toBe('function');
}
});
it('exposes compatibility computed selectors', () => {
const expectedComputed = [
'getTotalPages',
'getPaginatedContent',
'isPaginatedContentLoading',
'isPaginatedContentFailed',
'getSerialSeasonsResource',
'isSerialSeasonsLoading',
'getVodSeriesSeasonsResource',
'isVodSeriesSeasonsLoading',
'getCategoryResource',
'isCategoryResourceLoading',
'isCategoryResourceFailed',
'getSelectedCategoryName',
];
for (const computedName of expectedComputed) {
expect(typeof store[computedName]).toBe('function');
}
});
it('exposes compatibility methods and internal resources', () => {
const expectedMethods = [
'setSelectedContentType',
'setSelectedCategory',
'setSelectedSerialId',
'setSelectedVodId',
'setSelectedItvId',
'setLimit',
'setPage',
'setCurrentPlaylist',
'setSelectedItem',
'clearSelectedItem',
'setCategories',
'resetCategories',
'setItvChannels',
'setSearchPhrase',
'fetchVodSeriesEpisodes',
'getSelectedCategory',
'fetchLinkToPlay',
'getExpireDate',
'addToFavorites',
'removeFromFavorites',
'fetchMovieFileId',
'resolveVodPlayback',
'createLinkToPlayVod',
'addToRecentlyViewed',
'removeFromRecentlyViewed',
'fetchChannelEpg',
'makeStalkerRequest',
];
for (const methodName of expectedMethods) {
expect(typeof store[methodName]).toBe('function');
}
expect(store.getContentResource).toBeDefined();
expect(typeof store.getContentResource.value).toBe('function');
expect(store.serialSeasonsResource).toBeDefined();
expect(typeof store.serialSeasonsResource.value).toBe('function');
expect(store.vodSeriesSeasonsResource).toBeDefined();
expect(typeof store.vodSeriesSeasonsResource.value).toBe('function');
});
});
@@ -0,0 +1,28 @@
import { signalStore } from '@ngrx/signals';
import {
withStalkerContent,
withStalkerEpg,
withStalkerFavorites,
withStalkerPlayer,
withStalkerPortal,
withStalkerRecent,
withStalkerSelection,
withStalkerSeries,
} from './stores/features';
/**
* StalkerStore facade composed from feature slices.
* Public API compatibility is preserved while implementation is split by concern.
*/
export const StalkerStore = signalStore(
{ providedIn: 'root' },
withStalkerPortal(),
withStalkerSelection(),
withStalkerContent(),
withStalkerSeries(),
withStalkerPlayer(),
withStalkerFavorites(),
withStalkerRecent(),
withStalkerEpg()
);
@@ -0,0 +1,9 @@
export * from './with-stalker-content.feature';
export * from './with-stalker-epg.feature';
export * from './with-stalker-favorites.feature';
export * from './with-stalker-player.feature';
export * from './with-stalker-portal.feature';
export * from './with-stalker-recent.feature';
export * from './with-stalker-selection.feature';
export * from './with-stalker-series.feature';
@@ -0,0 +1,283 @@
import { TestBed } from '@angular/core/testing';
import { patchState, signalStore, withMethods, withState } from '@ngrx/signals';
import { TranslateService } from '@ngx-translate/core';
import { DataService } from 'services';
import { PlaylistMeta, StalkerPortalActions } from 'shared-interfaces';
import { StalkerSessionService } from '../../stalker-session.service';
import { withStalkerContent } from './with-stalker-content.feature';
jest.mock('@iptvnator/portal/shared/util', () => ({
createLogger: () => ({
debug: jest.fn(),
info: jest.fn(),
warn: jest.fn(),
error: jest.fn(),
}),
}));
const PLAYLIST = {
_id: 'playlist-1',
title: 'Demo Stalker',
count: 0,
autoRefresh: false,
importDate: '2026-04-14T00:00:00.000Z',
portalUrl: 'http://demo.example/stalker_portal/server/load.php',
macAddress: '00:1A:79:00:00:01',
isFullStalkerPortal: false,
} as PlaylistMeta;
const TestContentStore = signalStore(
withState({
currentPlaylist: undefined as PlaylistMeta | undefined,
selectedContentType: 'vod' as 'vod' | 'series' | 'itv',
selectedCategoryId: undefined as string | null | undefined,
searchPhrase: '',
page: 0,
limit: 14,
}),
withMethods((store) => ({
setCurrentPlaylist(playlist: PlaylistMeta | undefined) {
patchState(store, { currentPlaylist: playlist });
},
setSelectedContentType(type: 'vod' | 'series' | 'itv') {
patchState(store, { selectedContentType: type });
},
setSelectedCategory(id: string | null | undefined) {
patchState(store, { selectedCategoryId: id });
},
setPage(page: number) {
patchState(store, { page });
},
})),
withStalkerContent()
);
function createDeferred<T>() {
let resolve!: (value: T) => void;
let reject!: (reason?: unknown) => void;
const promise = new Promise<T>((res, rej) => {
resolve = res;
reject = rej;
});
return { promise, resolve, reject };
}
function createContentResponse(title: string) {
return {
js: {
data: [
{
id: title,
name: title,
category_id: '5',
},
],
total_items: 28,
},
};
}
async function flushResources(): Promise<void> {
TestBed.flushEffects();
await Promise.resolve();
await Promise.resolve();
TestBed.flushEffects();
await Promise.resolve();
}
async function waitForCondition(
predicate: () => boolean,
attempts = 20
): Promise<void> {
for (let index = 0; index < attempts; index += 1) {
if (predicate()) {
return;
}
await flushResources();
}
throw new Error('Timed out waiting for resource activity');
}
describe('withStalkerContent failure states', () => {
let store: InstanceType<typeof TestContentStore>;
let dataService: {
sendIpcEvent: jest.Mock<Promise<unknown>, unknown[]>;
};
beforeEach(() => {
dataService = {
sendIpcEvent: jest.fn(),
};
TestBed.configureTestingModule({
providers: [
TestContentStore,
{ provide: DataService, useValue: dataService },
{
provide: StalkerSessionService,
useValue: {
makeAuthenticatedRequest: jest.fn(),
},
},
{
provide: TranslateService,
useValue: {
instant: jest.fn((key: string) => key),
},
},
],
});
store = TestBed.inject(TestContentStore);
});
it('normalizes category failures into empty arrays and explicit error state', async () => {
dataService.sendIpcEvent.mockRejectedValue(
new Error('get_genres failed')
);
store.setSelectedContentType('itv');
store.setCurrentPlaylist(PLAYLIST);
void store.isCategoryResourceLoading();
await waitForCondition(
() => dataService.sendIpcEvent.mock.calls.length > 0
);
await flushResources();
expect(dataService.sendIpcEvent).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({
url: PLAYLIST.portalUrl,
macAddress: PLAYLIST.macAddress,
params: expect.objectContaining({
action: StalkerPortalActions.GetGenres,
type: 'itv',
}),
})
);
expect(store.getCategoryResource()).toEqual([]);
expect(store.itvCategories()).toEqual([]);
expect(store.isCategoryResourceFailed()).toBeInstanceOf(Error);
expect((store.isCategoryResourceFailed() as Error).message).toBe(
'get_genres failed'
);
});
it('derives the selected category from the normalized category collections', () => {
store.setSelectedContentType('series');
store.setCategories('series', [
{
category_id: '7',
category_name: 'Drama',
},
]);
store.setSelectedCategory('7');
expect(store.getSelectedCategory()).toEqual({
category_id: '7',
category_name: 'Drama',
});
});
it('normalizes content failures into empty collections instead of undefined state', async () => {
dataService.sendIpcEvent.mockRejectedValue(
new Error('get_ordered_list failed')
);
store.setSelectedContentType('itv');
store.setCategories('itv', [
{
category_id: '5',
category_name: 'News',
},
]);
store.setSelectedCategory('5');
store.setCurrentPlaylist(PLAYLIST);
void store.isPaginatedContentLoading();
await waitForCondition(
() => dataService.sendIpcEvent.mock.calls.length > 0
);
await flushResources();
expect(dataService.sendIpcEvent).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({
params: expect.objectContaining({
action: StalkerPortalActions.GetOrderedList,
type: 'itv',
category: '5',
genre: '5',
}),
})
);
expect(store.getPaginatedContent()).toEqual([]);
expect(store.itvChannels()).toEqual([]);
expect(store.hasMoreChannels()).toBe(false);
expect(store.isPaginatedContentFailed()).toBeInstanceOf(Error);
expect((store.isPaginatedContentFailed() as Error).message).toBe(
'get_ordered_list failed'
);
});
it('ignores stale content responses after the selected page changes', async () => {
const pendingByPage = new Map<
number,
ReturnType<typeof createDeferred<unknown>>[]
>();
dataService.sendIpcEvent.mockImplementation(
(_event: unknown, payload: { params?: { p?: number } }) => {
const page = Number(payload.params?.p);
const deferred = createDeferred<unknown>();
const pending = pendingByPage.get(page) ?? [];
pending.push(deferred);
pendingByPage.set(page, pending);
return deferred.promise;
}
);
store.setSelectedContentType('vod');
store.setCategories('vod', [
{
category_id: '5',
category_name: 'Movies',
},
]);
store.setSelectedCategory('5');
store.setCurrentPlaylist(PLAYLIST);
void store.isPaginatedContentLoading();
await waitForCondition(() => pendingByPage.get(1)?.length === 1);
pendingByPage
.get(1)?.[0]
.resolve(createContentResponse('Page 1 initial'));
await waitForCondition(
() => store.getPaginatedContent()[0]?.name === 'Page 1 initial'
);
store.setPage(1);
await waitForCondition(() => pendingByPage.get(2)?.length === 1);
store.setPage(0);
await waitForCondition(() => pendingByPage.get(1)?.length === 2);
pendingByPage
.get(1)?.[1]
.resolve(createContentResponse('Page 1 restored'));
await waitForCondition(
() => store.getPaginatedContent()[0]?.name === 'Page 1 restored'
);
pendingByPage
.get(2)?.[0]
.resolve(createContentResponse('Page 2 stale'));
await flushResources();
expect(store.page()).toBe(0);
expect(store.getPaginatedContent()[0]?.name).toBe('Page 1 restored');
});
});
@@ -0,0 +1,558 @@
import { computed, inject, resource } from '@angular/core';
import {
patchState,
signalStoreFeature,
withComputed,
withMethods,
withProps,
withState,
} from '@ngrx/signals';
import { TranslateService } from '@ngx-translate/core';
import { createLogger } from '@iptvnator/portal/shared/util';
import { DataService } from 'services';
import {
StalkerCategoryItem,
StalkerContentItem,
StalkerItvChannel,
StalkerVodSource,
} from '../../models';
import { StalkerContentTypes } from '../../stalker-content-types';
import { StalkerSessionService } from '../../stalker-session.service';
import {
ResourceState,
StalkerCategorySliceContract,
StalkerContentFeatureStoreContract,
StalkerContentType,
} from '../stalker-store.contracts';
import {
executeStalkerRequest,
toStalkerContentItem,
toStalkerItvChannel,
} from '../utils';
/**
* Content/categories/channels feature state.
*/
export interface StalkerContentState {
totalCount: number;
vodCategories: StalkerCategoryItem[];
seriesCategories: StalkerCategoryItem[];
itvCategories: StalkerCategoryItem[];
hasMoreChannels: boolean;
itvChannels: StalkerItvChannel[];
paginatedContent: StalkerContentItem[];
categoryError: unknown;
contentError: unknown;
}
const initialContentState: StalkerContentState = {
totalCount: 0,
vodCategories: [],
seriesCategories: [],
itvCategories: [],
hasMoreChannels: false,
itvChannels: [],
paginatedContent: [],
categoryError: null,
contentError: null,
};
interface StalkerCategoryResponseItem {
id?: string | number;
title?: string;
}
interface StalkerCategoryResponse {
js?: StalkerCategoryResponseItem[];
}
interface StalkerOrderedListResponse {
js?: {
data?: StalkerVodSource[];
total_items?: number;
};
}
interface StalkerContentResourceStoreContract extends StalkerContentFeatureStoreContract {
categoryResource: ResourceState<StalkerCategoryItem[]>;
getContentResource: ResourceState<StalkerContentItem[]>;
}
function getCategoriesByType(
store: StalkerCategorySliceContract,
contentType: StalkerContentType
): StalkerCategoryItem[] {
switch (contentType) {
case 'vod':
return store.vodCategories();
case 'series':
return store.seriesCategories();
case 'itv':
return store.itvCategories();
}
}
function buildCategoryPatch(
contentType: StalkerContentType,
categories: StalkerCategoryItem[]
): Partial<StalkerContentState> {
switch (contentType) {
case 'vod':
return { vodCategories: categories };
case 'series':
return { seriesCategories: categories };
case 'itv':
return { itvCategories: categories };
}
}
function prependAllCategory(
categories: StalkerCategoryItem[],
translateService: TranslateService
): StalkerCategoryItem[] {
const allIndex = categories.findIndex(
(category) => category.category_name.trim().toLowerCase() === 'all'
);
if (allIndex > 0) {
categories.unshift(categories.splice(allIndex, 1)[0]);
return categories;
}
if (
allIndex === -1 &&
categories.length > 0 &&
!categories.some((category) => String(category.category_id) === '*')
) {
categories.unshift({
category_name: translateService.instant('PORTALS.ALL_CATEGORIES'),
category_id: '*',
});
}
return categories;
}
function buildEmptyContentPatch(
contentType: StalkerContentType,
error: unknown
): Partial<StalkerContentState> {
const patch: Partial<StalkerContentState> = {
totalCount: 0,
paginatedContent: [],
contentError: error,
};
if (contentType === 'itv') {
patch.hasMoreChannels = false;
patch.itvChannels = [];
}
return patch;
}
export function withStalkerContent() {
const logger = createLogger('withStalkerContent');
return signalStoreFeature(
withState<StalkerContentState>(initialContentState),
withProps(
(
store,
dataService = inject(DataService),
stalkerSession = inject(StalkerSessionService),
translateService = inject(TranslateService)
) => {
const storeContext = store as typeof store &
StalkerContentResourceStoreContract;
const requestDeps = {
dataService,
stalkerSession,
};
return {
categoryResource: resource({
params: () => ({
contentType: storeContext.selectedContentType(),
currentPlaylist: storeContext.currentPlaylist(),
}),
loader: async ({
params,
}): Promise<StalkerCategoryItem[]> => {
if (!params.currentPlaylist) {
patchState(store, { categoryError: null });
return [];
}
const cachedCategories = getCategoriesByType(
store,
params.contentType
);
if (cachedCategories.length > 0) {
patchState(store, { categoryError: null });
return cachedCategories;
}
try {
const response =
await executeStalkerRequest<StalkerCategoryResponse>(
requestDeps,
params.currentPlaylist,
{
action: StalkerContentTypes[
params.contentType
].getCategoryAction,
type: params.contentType,
}
);
if (!Array.isArray(response?.js)) {
const invalidResponseError = new Error(
'Invalid categories response'
);
logger.warn(
'Invalid categories response',
response
);
patchState(store, {
...buildCategoryPatch(
params.contentType,
[]
),
categoryError: invalidResponseError,
});
return [];
}
const categories = prependAllCategory(
response.js
.map(
(item): StalkerCategoryItem => ({
category_name: item.title ?? '',
category_id: String(item.id),
})
)
.sort((left, right) =>
left.category_name.localeCompare(
right.category_name
)
),
translateService
);
patchState(store, {
...buildCategoryPatch(
params.contentType,
categories
),
categoryError: null,
});
return categories;
} catch (error) {
logger.warn('Error loading categories', {
contentType: params.contentType,
error,
});
patchState(store, {
...buildCategoryPatch(
params.contentType,
[]
),
categoryError: error,
});
return [];
}
},
}),
getContentResource: resource({
params: () => ({
contentType: storeContext.selectedContentType(),
category: storeContext.selectedCategoryId(),
search: storeContext.searchPhrase(),
pageIndex: storeContext.page() + 1,
currentPlaylist: storeContext.currentPlaylist(),
availableCategoryCount: getCategoriesByType(
store,
storeContext.selectedContentType()
).filter(
(category) =>
String(category.category_id) !== '*'
).length,
}),
loader: async ({
params,
}): Promise<StalkerContentItem[]> => {
if (!params.category || params.category === '') {
patchState(
store,
buildEmptyContentPatch(
params.contentType,
null
)
);
return [];
}
if (
params.category === '*' &&
(params.contentType === 'vod' ||
params.contentType === 'series') &&
params.availableCategoryCount === 0
) {
patchState(
store,
buildEmptyContentPatch(
params.contentType,
null
)
);
return [];
}
const playlist = params.currentPlaylist;
if (!playlist?.portalUrl) {
patchState(
store,
buildEmptyContentPatch(
params.contentType,
null
)
);
return [];
}
const categoryParam = params.category || '*';
const paramsPlaylistKey =
params.currentPlaylist?._id ??
params.currentPlaylist?.portalUrl ??
null;
const isCurrentRequest = (): boolean => {
const currentPlaylist =
storeContext.currentPlaylist();
const currentPlaylistKey =
currentPlaylist?._id ??
currentPlaylist?.portalUrl ??
null;
return (
params.contentType ===
storeContext.selectedContentType() &&
params.category ===
storeContext.selectedCategoryId() &&
params.search ===
storeContext.searchPhrase() &&
params.pageIndex ===
storeContext.page() + 1 &&
paramsPlaylistKey === currentPlaylistKey
);
};
const queryParams: Record<string, string | number> =
{
action: StalkerContentTypes[
params.contentType
].getContentAction,
type: params.contentType,
sortby: 'added',
...(params.search !== ''
? { search: params.search }
: {}),
p: params.pageIndex,
};
if (params.contentType === 'vod') {
queryParams['genre'] = '0';
queryParams['category'] = categoryParam;
} else if (params.contentType === 'series') {
queryParams['category'] = categoryParam;
} else {
queryParams['category'] = categoryParam;
queryParams['genre'] = categoryParam;
}
try {
patchState(store, {
paginatedContent: [],
contentError: null,
});
const response =
await executeStalkerRequest<StalkerOrderedListResponse>(
requestDeps,
playlist,
queryParams
);
if (!isCurrentRequest()) {
return [];
}
if (!Array.isArray(response?.js?.data)) {
const invalidResponseError = new Error(
'Invalid response structure'
);
logger.warn(
'Invalid response structure',
response
);
patchState(store, {
...buildEmptyContentPatch(
params.contentType,
invalidResponseError
),
});
return [];
}
const newItems = response.js.data.map((item) =>
toStalkerContentItem(
item,
playlist.portalUrl ?? ''
)
);
if (params.contentType === 'itv') {
const channels =
newItems.map(toStalkerItvChannel);
const nextChannels =
params.pageIndex === 1
? channels
: [
...store.itvChannels(),
...channels,
];
patchState(store, {
totalCount:
response.js.total_items ?? 0,
paginatedContent: newItems,
contentError: null,
itvChannels: nextChannels,
hasMoreChannels:
nextChannels.length <
(response.js.total_items ?? 0),
});
} else {
patchState(store, {
totalCount:
response.js.total_items ?? 0,
paginatedContent: newItems,
contentError: null,
hasMoreChannels: false,
});
}
return newItems;
} catch (error) {
if (!isCurrentRequest()) {
return [];
}
logger.warn('Error loading content', {
contentType: params.contentType,
category: params.category,
error,
});
patchState(
store,
buildEmptyContentPatch(
params.contentType,
error
)
);
return [];
}
},
}),
};
}
),
withComputed((store) => {
const storeContext = store as typeof store &
StalkerContentResourceStoreContract;
return {
getTotalPages: computed(() =>
Math.ceil(store.totalCount() / storeContext.limit())
),
getSelectedCategory: computed(() => {
const categoryId = storeContext.selectedCategoryId();
if (!categoryId) {
return {
id: 0,
category_name: 'All Items',
type: storeContext.selectedContentType(),
};
}
const contentType = storeContext.selectedContentType();
const categories = getCategoriesByType(store, contentType);
return (
categories.find(
(category) =>
String(category.category_id) ===
String(categoryId)
) || {
category_id: categoryId,
category_name: '',
type: contentType,
}
);
}),
getSelectedCategoryName: computed(() => {
const selectedCategoryId =
storeContext.selectedCategoryId();
if (!selectedCategoryId) {
return '';
}
const category = getCategoriesByType(
store,
storeContext.selectedContentType()
).find(
(item) =>
String(item.category_id) ===
String(selectedCategoryId)
);
return category?.category_name ?? '';
}),
getPaginatedContent: computed(() => store.paginatedContent()),
isPaginatedContentLoading: computed(() =>
storeContext.getContentResource.isLoading()
),
isPaginatedContentFailed: computed(() => store.contentError()),
getCategoryResource: computed(() =>
getCategoriesByType(
store,
storeContext.selectedContentType()
)
),
isCategoryResourceLoading: computed(() =>
storeContext.categoryResource.isLoading()
),
isCategoryResourceFailed: computed(() => store.categoryError()),
};
}),
withMethods((store) => ({
setCategories(
type: StalkerContentType,
categories: StalkerCategoryItem[]
) {
patchState(store, buildCategoryPatch(type, categories));
},
resetCategories() {
patchState(store, {
vodCategories: [],
seriesCategories: [],
itvCategories: [],
categoryError: null,
});
},
setItvChannels(channels: StalkerItvChannel[]) {
patchState(store, { itvChannels: channels });
},
}))
);
}
@@ -0,0 +1,199 @@
import { TestBed } from '@angular/core/testing';
import { signalStore, withState } from '@ngrx/signals';
import { DataService } from 'services';
import { EpgItem, Playlist } from 'shared-interfaces';
import { StalkerSessionService } from '../../stalker-session.service';
import { withStalkerEpg } from './with-stalker-epg.feature';
jest.mock('@iptvnator/portal/shared/util', () => ({
createLogger: () => ({
debug: jest.fn(),
info: jest.fn(),
warn: jest.fn(),
error: jest.fn(),
}),
}));
const PLAYLIST = {
_id: 'playlist-1',
title: 'Demo Stalker',
count: 0,
autoRefresh: false,
importDate: '2026-04-11T00:00:00.000Z',
lastUsage: '2026-04-11T00:00:00.000Z',
portalUrl: 'http://demo.example/portal.php',
macAddress: '00:1A:79:00:00:01',
} as Playlist;
const TestStalkerEpgStore = signalStore(
withState({
currentPlaylist: PLAYLIST,
selectedItvId: '10001',
}),
withStalkerEpg()
);
describe('withStalkerEpg', () => {
let store: InstanceType<typeof TestStalkerEpgStore>;
let dataService: {
sendIpcEvent: jest.Mock<Promise<unknown>, unknown[]>;
};
let stalkerSessionService: {
makeAuthenticatedRequest: jest.Mock<Promise<unknown>, unknown[]>;
};
beforeEach(() => {
dataService = {
sendIpcEvent: jest.fn(),
};
stalkerSessionService = {
makeAuthenticatedRequest: jest.fn(),
};
TestBed.configureTestingModule({
providers: [
TestStalkerEpgStore,
{ provide: DataService, useValue: dataService },
{
provide: StalkerSessionService,
useValue: stalkerSessionService,
},
],
});
store = TestBed.inject(TestStalkerEpgStore);
});
it('fetches fallback short EPG via get_short_epg', async () => {
dataService.sendIpcEvent.mockResolvedValue({
js: {
data: [
buildEntry('10001', 'Current Show', 1744365600, 1744367400),
],
},
});
const result = await store.fetchChannelEpg('10001');
expect(dataService.sendIpcEvent).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({
url: PLAYLIST.portalUrl,
macAddress: PLAYLIST.macAddress,
params: expect.objectContaining({
action: 'get_short_epg',
type: 'itv',
ch_id: '10001',
size: '10',
}),
})
);
expect(result).toEqual([
buildEpgItem('10001', 'Current Show', 1744365600, 1744367400),
]);
});
it('loads bulk EPG once and projects selected-channel programs from the cache', async () => {
dataService.sendIpcEvent.mockResolvedValue({
js: {
data: {
'10001': [
buildEntry('10001', 'Morning Show', 1744358400, 1744362000),
buildEntry('10001', 'Current Show', 1744362000, 1744365600),
],
'10002': [
buildEntry('10002', 'Other Channel', 1744362000, 1744365600),
],
},
},
});
await store.ensureBulkItvEpg(168);
expect(dataService.sendIpcEvent).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({
params: expect.objectContaining({
action: 'get_epg_info',
type: 'itv',
period: '168',
}),
})
);
expect(store.bulkItvEpgLoaded()).toBe(true);
expect(store.bulkItvEpgPlaylistId()).toBe('playlist-1');
expect(store.bulkItvEpgPeriodHours()).toBe(168);
expect(store.selectedItvEpgPrograms()).toEqual([
buildProgram('10001', 'Morning Show', 1744358400, 1744362000),
buildProgram('10001', 'Current Show', 1744362000, 1744365600),
]);
});
it('treats bulk EPG failures as loaded-empty cache so callers can fallback to short EPG', async () => {
dataService.sendIpcEvent.mockRejectedValue(new Error('unsupported'));
await store.ensureBulkItvEpg(168);
expect(store.bulkItvEpgLoaded()).toBe(true);
expect(store.bulkItvEpgByChannel()).toEqual({});
expect(store.selectedItvEpgPrograms()).toEqual([]);
expect(store.isLoadingBulkItvEpg()).toBe(false);
});
});
function buildEntry(
channelId: string,
title: string,
startTimestamp: number,
stopTimestamp: number
) {
return {
id: `${channelId}-${startTimestamp}`,
ch_id: channelId,
name: title,
descr: `${title} description`,
start: new Date(startTimestamp * 1000).toISOString(),
stop: new Date(stopTimestamp * 1000).toISOString(),
start_timestamp: startTimestamp,
stop_timestamp: stopTimestamp,
};
}
function buildEpgItem(
channelId: string,
title: string,
startTimestamp: number,
stopTimestamp: number
): EpgItem {
return {
id: `${channelId}-${startTimestamp}`,
epg_id: '',
title,
description: `${title} description`,
lang: '',
start: new Date(startTimestamp * 1000).toISOString(),
end: new Date(stopTimestamp * 1000).toISOString(),
stop: new Date(stopTimestamp * 1000).toISOString(),
channel_id: channelId,
start_timestamp: String(startTimestamp),
stop_timestamp: String(stopTimestamp),
};
}
function buildProgram(
channelId: string,
title: string,
startTimestamp: number,
stopTimestamp: number
) {
return {
start: new Date(startTimestamp * 1000).toISOString(),
stop: new Date(stopTimestamp * 1000).toISOString(),
channel: channelId,
title,
desc: `${title} description`,
category: null,
startTimestamp,
stopTimestamp,
};
}
@@ -0,0 +1,472 @@
import { computed, inject } from '@angular/core';
import {
patchState,
signalStoreFeature,
withComputed,
withMethods,
withState,
} from '@ngrx/signals';
import { createLogger } from '@iptvnator/portal/shared/util';
import { DataService } from 'services';
import { EpgItem, EpgProgram, StalkerPortalActions } from 'shared-interfaces';
import { normalizeStalkerEntityId } from '../../stalker-vod.utils';
import { StalkerSessionService } from '../../stalker-session.service';
import { StalkerEpgFeatureStoreContract } from '../stalker-store.contracts';
import { executeStalkerRequest } from '../utils';
interface StalkerEpgEntry {
id?: string | number;
name?: string;
descr?: string;
category?: string;
time?: string;
time_to?: string;
start?: string;
stop?: string;
ch_id?: string | number;
start_timestamp?: string | number;
stop_timestamp?: string | number;
}
type StalkerBulkEpgValue =
| StalkerEpgEntry[]
| {
data?: StalkerEpgEntry[];
epg?: StalkerEpgEntry[];
items?: StalkerEpgEntry[];
};
interface StalkerEpgResponse {
js?:
| StalkerEpgEntry[]
| {
data?: StalkerEpgEntry[] | Record<string, StalkerBulkEpgValue>;
}
| Record<string, StalkerBulkEpgValue>;
}
export interface StalkerEpgState {
bulkItvEpgByChannel: Record<string, EpgProgram[]>;
bulkItvEpgPlaylistId: string | null;
bulkItvEpgPeriodHours: number | null;
bulkItvEpgLoaded: boolean;
isLoadingBulkItvEpg: boolean;
}
const initialEpgState: StalkerEpgState = {
bulkItvEpgByChannel: {},
bulkItvEpgPlaylistId: null,
bulkItvEpgPeriodHours: null,
bulkItvEpgLoaded: false,
isLoadingBulkItvEpg: false,
};
const ACTIVE_EPG_FALLBACK_SIZE = 10;
/**
* EPG concern methods.
*/
export function withStalkerEpg() {
const logger = createLogger('withStalkerEpg');
return signalStoreFeature(
withState<StalkerEpgState>(initialEpgState),
withComputed((store) => {
const storeContext = store as typeof store &
StalkerEpgFeatureStoreContract;
return {
selectedItvEpgPrograms: computed(() => {
const selectedId = storeContext.selectedItvId();
if (!selectedId) {
return [];
}
return (
store.bulkItvEpgByChannel()[
normalizeStalkerEntityId(selectedId)
] ?? []
);
}),
};
}),
withMethods(
(
store,
dataService = inject(DataService),
stalkerSession = inject(StalkerSessionService)
) => {
const storeContext = store as typeof store &
StalkerEpgFeatureStoreContract;
const requestDeps = {
dataService,
stalkerSession,
};
const requestEpg = async (
playlist: NonNullable<
ReturnType<
StalkerEpgFeatureStoreContract['currentPlaylist']
>
>,
queryParams: Record<string, string>
): Promise<StalkerEpgResponse> => {
return executeStalkerRequest<StalkerEpgResponse>(
requestDeps,
playlist,
queryParams
);
};
const fetchShortEpg = async (
channelId: number | string,
size: number
): Promise<EpgItem[]> => {
const playlist = storeContext.currentPlaylist();
if (!playlist) {
return [];
}
const response = await requestEpg(playlist, {
action: StalkerPortalActions.GetShortEpg,
type: 'itv',
ch_id: String(channelId),
size: String(size),
});
return extractShortEpgEntries(response)
.map((item) => toEpgItem(item, channelId))
.filter((item) => item.start && item.stop)
.sort(
(left, right) =>
getProgramTimestampMs(
left.start,
left.start_timestamp
) -
getProgramTimestampMs(
right.start,
right.start_timestamp
)
);
};
return {
async fetchChannelEpg(
channelId: number | string,
size = ACTIVE_EPG_FALLBACK_SIZE
): Promise<EpgItem[]> {
try {
return await fetchShortEpg(channelId, size);
} catch (error) {
logger.error('Error loading short Stalker EPG', {
channelId,
size,
error,
});
return [];
}
},
async ensureBulkItvEpg(periodHours = 168): Promise<void> {
const playlist = storeContext.currentPlaylist();
if (!playlist?._id) {
patchState(store, initialEpgState);
return;
}
const playlistId = String(playlist._id);
const shouldReuseCache =
store.bulkItvEpgLoaded() &&
store.bulkItvEpgPlaylistId() === playlistId &&
store.bulkItvEpgPeriodHours() === periodHours;
if (shouldReuseCache || store.isLoadingBulkItvEpg()) {
return;
}
if (store.bulkItvEpgPlaylistId() !== playlistId) {
patchState(store, initialEpgState);
}
patchState(store, {
isLoadingBulkItvEpg: true,
bulkItvEpgPlaylistId: playlistId,
bulkItvEpgPeriodHours: periodHours,
});
try {
const response = await requestEpg(playlist, {
action: StalkerPortalActions.GetEpgInfo,
type: 'itv',
period: String(periodHours),
});
const selectedChannelId =
storeContext.selectedItvId() ?? null;
const bulkPrograms = extractBulkEpgByChannel(
response,
selectedChannelId
);
patchState(store, {
bulkItvEpgByChannel: bulkPrograms,
bulkItvEpgLoaded: true,
isLoadingBulkItvEpg: false,
});
} catch (error) {
logger.warn('Bulk Stalker EPG unavailable', error);
patchState(store, {
bulkItvEpgByChannel: {},
bulkItvEpgLoaded: true,
isLoadingBulkItvEpg: false,
});
}
},
clearBulkItvEpgCache(): void {
patchState(store, initialEpgState);
},
};
}
)
);
}
function extractShortEpgEntries(
response: StalkerEpgResponse
): StalkerEpgEntry[] {
if (Array.isArray(response?.js)) {
return response.js;
}
const data = response?.js?.data;
return Array.isArray(data) ? data : [];
}
function extractBulkEpgByChannel(
response: StalkerEpgResponse,
selectedChannelId?: string | null
): Record<string, EpgProgram[]> {
const raw = response?.js;
const groupedEntries: Record<string, StalkerEpgEntry[]> = {};
const appendEntries = (
channelId: string | number | null | undefined,
entries: StalkerEpgEntry[]
) => {
const normalizedChannelId = normalizeOptionalEntityId(channelId);
if (!normalizedChannelId || entries.length === 0) {
return;
}
groupedEntries[normalizedChannelId] = entries;
};
const groupArrayEntries = (entries: StalkerEpgEntry[]) => {
for (const entry of entries) {
const entryChannelId = entry.ch_id ?? selectedChannelId ?? null;
const normalizedChannelId =
normalizeOptionalEntityId(entryChannelId);
if (!normalizedChannelId) {
continue;
}
groupedEntries[normalizedChannelId] ??= [];
groupedEntries[normalizedChannelId].push(entry);
}
};
if (Array.isArray(raw)) {
groupArrayEntries(raw);
} else {
const rawData =
raw && typeof raw === 'object' && 'data' in raw ? raw.data : raw;
if (Array.isArray(rawData)) {
groupArrayEntries(rawData);
} else if (rawData && typeof rawData === 'object') {
for (const [channelId, value] of Object.entries(rawData)) {
appendEntries(channelId, extractEntriesFromBulkValue(value));
}
}
}
const normalizedPrograms: Record<string, EpgProgram[]> = {};
for (const [channelId, entries] of Object.entries(groupedEntries)) {
const programs = entriesToPrograms(entries, channelId);
if (programs.length > 0) {
normalizedPrograms[channelId] = programs;
}
}
return normalizedPrograms;
}
function extractEntriesFromBulkValue(value: unknown): StalkerEpgEntry[] {
if (Array.isArray(value)) {
return value;
}
if (!value || typeof value !== 'object') {
return [];
}
const recordValue = value as {
data?: StalkerEpgEntry[];
epg?: StalkerEpgEntry[];
items?: StalkerEpgEntry[];
};
if (Array.isArray(recordValue.data)) {
return recordValue.data;
}
if (Array.isArray(recordValue.epg)) {
return recordValue.epg;
}
if (Array.isArray(recordValue.items)) {
return recordValue.items;
}
return [];
}
function entriesToPrograms(
entries: StalkerEpgEntry[],
fallbackChannelId: string | number
): EpgProgram[] {
return entries
.map((entry) => toEpgProgram(entry, fallbackChannelId))
.filter((program): program is EpgProgram => program !== null)
.sort(
(left, right) =>
getProgramTimestampMs(left.start, left.startTimestamp) -
getProgramTimestampMs(right.start, right.startTimestamp)
);
}
function toEpgItem(
item: StalkerEpgEntry,
fallbackChannelId: string | number
): EpgItem {
const startRaw = item.time ?? item.start ?? '';
const stopRaw = item.time_to ?? item.stop ?? '';
const startTimestamp = getProgramTimestampSeconds(
startRaw,
item.start_timestamp
);
const stopTimestamp = getProgramTimestampSeconds(
stopRaw,
item.stop_timestamp
);
const start = toIsoString(startRaw, startTimestamp);
const stop = toIsoString(stopRaw, stopTimestamp);
return {
id: String(item.id ?? ''),
epg_id: '',
title: item.name ?? '',
description: item.descr ?? '',
lang: '',
start,
end: stop,
stop,
channel_id: String(item.ch_id ?? fallbackChannelId),
start_timestamp: startTimestamp !== null ? String(startTimestamp) : '',
stop_timestamp: stopTimestamp !== null ? String(stopTimestamp) : '',
};
}
function toEpgProgram(
item: StalkerEpgEntry,
fallbackChannelId: string | number
): EpgProgram | null {
const epgItem = toEpgItem(item, fallbackChannelId);
if (!epgItem.start || !epgItem.stop) {
return null;
}
return {
start: epgItem.start,
stop: epgItem.stop,
channel: normalizeStalkerEntityId(epgItem.channel_id),
title: epgItem.title,
desc: epgItem.description || null,
category: item.category ?? null,
startTimestamp: parseInteger(epgItem.start_timestamp),
stopTimestamp: parseInteger(epgItem.stop_timestamp),
};
}
function toIsoString(
rawValue: string,
timestampSeconds: number | null
): string {
if (timestampSeconds !== null) {
return new Date(timestampSeconds * 1000).toISOString();
}
const normalized = String(rawValue ?? '').trim();
if (!normalized) {
return '';
}
const candidate = normalized.includes('T')
? normalized
: normalized.replace(' ', 'T');
const parsed = Date.parse(candidate);
return Number.isFinite(parsed) ? new Date(parsed).toISOString() : '';
}
function getProgramTimestampSeconds(
rawValue: string,
timestampValue?: string | number | null
): number | null {
const parsedTimestamp = parseInteger(timestampValue);
if (parsedTimestamp !== null && parsedTimestamp > 0) {
return parsedTimestamp;
}
const normalized = String(rawValue ?? '').trim();
if (!normalized) {
return null;
}
const candidate = normalized.includes('T')
? normalized
: normalized.replace(' ', 'T');
const parsedDate = Date.parse(candidate);
return Number.isFinite(parsedDate) ? Math.floor(parsedDate / 1000) : null;
}
function getProgramTimestampMs(
rawValue: string,
timestampValue?: string | number | null
): number {
const timestampSeconds = getProgramTimestampSeconds(
rawValue,
timestampValue
);
if (timestampSeconds !== null) {
return timestampSeconds * 1000;
}
return Number.POSITIVE_INFINITY;
}
function parseInteger(value: unknown): number | null {
const parsed = Number.parseInt(String(value ?? ''), 10);
return Number.isFinite(parsed) ? parsed : null;
}
function normalizeOptionalEntityId(
value: string | number | null | undefined
): string | null {
if (value === null || value === undefined || value === '') {
return null;
}
return normalizeStalkerEntityId(value);
}
@@ -0,0 +1,113 @@
import { inject } from '@angular/core';
import { MatSnackBar } from '@angular/material/snack-bar';
import { signalStoreFeature, withMethods } from '@ngrx/signals';
import { Store } from '@ngrx/store';
import { TranslateService } from '@ngx-translate/core';
import { PlaylistsService } from 'services';
import { PlaylistMeta, StalkerPortalItem } from 'shared-interfaces';
import { StalkerSelectionStoreContract } from '../stalker-store.contracts';
import {
dispatchStalkerPlaylistMetaUpdate,
resolveStalkerCategoryId,
} from '../utils';
interface FavoritesStoreContext {
currentPlaylist(): PlaylistMeta | undefined;
selectedContentType(): StalkerSelectionStoreContract['selectedContentType'] extends () => infer T
? T
: string;
}
type FavoritePayload = StalkerPortalItem & {
stream_id?: string | number;
id?: string | number;
};
/**
* Favorites concern methods.
*/
export function withStalkerFavorites() {
return signalStoreFeature(
withMethods(
(
store,
playlistService = inject(PlaylistsService),
snackBar = inject(MatSnackBar),
translate = inject(TranslateService),
ngrxStore = inject(Store)
) => {
const storeContext = store as typeof store &
FavoritesStoreContext;
return {
addToFavorites(item: FavoritePayload, onDone?: () => void) {
const portalId = storeContext.currentPlaylist()?._id;
if (!portalId) {
return;
}
playlistService
.addPortalFavorite(portalId, {
...item,
category_id: resolveStalkerCategoryId(
item.category_id,
storeContext.selectedContentType()
),
added_at: Date.now(),
id: item.stream_id ?? item.id,
})
.subscribe((updatedPlaylist) => {
dispatchStalkerPlaylistMetaUpdate(
ngrxStore,
portalId,
{
favorites: updatedPlaylist?.favorites,
}
);
snackBar.open(
translate.instant(
'PORTALS.ADDED_TO_FAVORITES'
),
undefined,
{
duration: 1000,
}
);
onDone?.();
});
},
removeFromFavorites(
favoriteId: string,
onDone?: () => void
) {
const portalId = storeContext.currentPlaylist()?._id;
if (!portalId) {
return;
}
playlistService
.removeFromPortalFavorites(portalId, favoriteId)
.subscribe((updatedPlaylist) => {
dispatchStalkerPlaylistMetaUpdate(
ngrxStore,
portalId,
{
favorites: updatedPlaylist?.favorites,
}
);
snackBar.open(
translate.instant(
'PORTALS.REMOVED_FROM_FAVORITES'
),
undefined,
{
duration: 1000,
}
);
onDone?.();
});
},
};
}
)
);
}
@@ -0,0 +1,178 @@
import { TestBed } from '@angular/core/testing';
import { MatSnackBar } from '@angular/material/snack-bar';
import { signalStore, withState } from '@ngrx/signals';
import { Store } from '@ngrx/store';
import { TranslateService } from '@ngx-translate/core';
import { PORTAL_PLAYER } from '@iptvnator/portal/shared/util';
import { DataService, PlaylistsService } from 'services';
import { of } from 'rxjs';
import { PlaylistMeta, StalkerPortalActions } from 'shared-interfaces';
import { StalkerSessionService } from '../../stalker-session.service';
import { withStalkerPlayer } from './with-stalker-player.feature';
jest.mock('@iptvnator/portal/shared/util', () => ({
...jest.requireActual('@iptvnator/portal/shared/util'),
createLogger: () => ({
debug: jest.fn(),
info: jest.fn(),
warn: jest.fn(),
error: jest.fn(),
}),
}));
const PLAYLIST = {
_id: 'playlist-1',
title: 'Demo Stalker',
count: 0,
autoRefresh: false,
importDate: '2026-04-14T00:00:00.000Z',
portalUrl: 'http://demo.example/stalker_portal/server/load.php',
macAddress: '00:1A:79:00:00:01',
isFullStalkerPortal: false,
} as PlaylistMeta;
const TestPlayerStore = signalStore(
withState({
currentPlaylist: PLAYLIST,
selectedContentType: 'vod' as 'vod' | 'series' | 'itv',
selectedItem: {
id: '22',
cmd: '/media/source_22.mpg',
has_files: true,
title: 'Original Title',
category_id: 'vod',
},
}),
withStalkerPlayer()
);
describe('withStalkerPlayer', () => {
let store: InstanceType<typeof TestPlayerStore>;
let dataService: {
sendIpcEvent: jest.Mock<Promise<unknown>, unknown[]>;
};
let playlistService: {
addPortalRecentlyViewed: jest.Mock;
};
let ngrxStore: {
dispatch: jest.Mock;
};
beforeEach(() => {
dataService = {
sendIpcEvent: jest.fn(),
};
playlistService = {
addPortalRecentlyViewed: jest.fn(() =>
of({ recentlyViewed: [{ id: '22', title: 'Movie Title' }] })
),
};
ngrxStore = {
dispatch: jest.fn(),
};
TestBed.configureTestingModule({
providers: [
TestPlayerStore,
{ provide: DataService, useValue: dataService },
{
provide: PlaylistsService,
useValue: playlistService,
},
{
provide: PORTAL_PLAYER,
useValue: {
openResolvedPlayback: jest.fn(),
},
},
{
provide: StalkerSessionService,
useValue: {
getCachedToken: jest.fn(),
makeAuthenticatedRequest: jest.fn(),
},
},
{
provide: MatSnackBar,
useValue: {
open: jest.fn(),
},
},
{
provide: TranslateService,
useValue: {
instant: jest.fn((key: string) => key),
},
},
{
provide: Store,
useValue: ngrxStore,
},
],
});
store = TestBed.inject(TestPlayerStore);
});
it('falls back to file ids for VOD playback and persists recently viewed metadata', async () => {
dataService.sendIpcEvent
.mockResolvedValueOnce({
js: {
data: [{ id: 77 }],
},
})
.mockResolvedValueOnce({
js: {
cmd: 'ffmpeg http://cdn.example/video_77.mpg',
},
});
const playback = await store.resolveVodPlayback(
undefined,
'Movie Title',
'thumb.jpg'
);
expect(dataService.sendIpcEvent).toHaveBeenNthCalledWith(
1,
expect.anything(),
expect.objectContaining({
params: expect.objectContaining({
action: StalkerPortalActions.GetOrderedList,
type: 'vod',
movie_id: '22',
p: '1',
}),
})
);
expect(dataService.sendIpcEvent).toHaveBeenNthCalledWith(
2,
expect.anything(),
expect.objectContaining({
params: expect.objectContaining({
action: StalkerPortalActions.CreateLink,
cmd: '/media/file_77.mpg',
type: 'vod',
}),
})
);
expect(playlistService.addPortalRecentlyViewed).toHaveBeenCalledWith(
PLAYLIST._id,
expect.objectContaining({
id: '22',
title: 'Movie Title',
category_id: 'vod',
cover: 'thumb.jpg',
added_at: expect.any(Number),
})
);
expect(ngrxStore.dispatch).toHaveBeenCalled();
expect(playback.streamUrl).toBe('http://cdn.example/video_77.mpg');
expect(playback.contentInfo).toEqual({
playlistId: PLAYLIST._id,
contentXtreamId: 22,
contentType: 'vod',
seriesXtreamId: undefined,
});
});
});
@@ -0,0 +1,393 @@
import { inject } from '@angular/core';
import { MatSnackBar } from '@angular/material/snack-bar';
import { signalStoreFeature, withMethods } from '@ngrx/signals';
import { Store } from '@ngrx/store';
import { TranslateService } from '@ngx-translate/core';
import { PORTAL_PLAYER, createLogger } from '@iptvnator/portal/shared/util';
import { DataService, PlaylistsService } from 'services';
import {
PlaylistMeta,
ResolvedPortalPlayback,
StalkerPortalItem,
} from 'shared-interfaces';
import {
buildStalkerExternalPlaybackHeaders,
getStalkerPortalOrigin,
isCrossOriginStalkerStream,
STALKER_MAG_USER_AGENT,
} from '../../stalker-live-playback.utils';
import { StalkerSessionService } from '../../stalker-session.service';
import {
normalizeStalkerEntityId,
normalizeStalkerEntityIdAsNumber,
} from '../../stalker-vod.utils';
import {
StalkerPlayerFeatureStoreContract,
StalkerRecentlyViewedItem,
} from '../stalker-store.contracts';
import {
buildStalkerRecentlyViewedPayload,
dispatchStalkerPlaylistMetaUpdate,
fetchStalkerExpireDate,
fetchStalkerMovieFileId,
fetchStalkerPlaybackLink,
shouldResolveMovieFileId,
} from '../utils';
type StalkerPlayableItem = StalkerPortalItem & {
cmd?: string;
has_files?: unknown;
};
/**
* Playback/link/player concern methods.
*/
export function withStalkerPlayer() {
const logger = createLogger('withStalkerPlayer');
return signalStoreFeature(
withMethods(
(
store,
dataService = inject(DataService),
playlistService = inject(PlaylistsService),
playerService = inject(PORTAL_PLAYER),
stalkerSession = inject(StalkerSessionService),
snackBar = inject(MatSnackBar),
translate = inject(TranslateService),
ngrxStore = inject(Store)
) => {
const storeState = store as typeof store &
StalkerPlayerFeatureStoreContract;
const requestDeps = {
dataService,
stalkerSession,
};
const createRequestPlaylist = (
portalUrl: string,
macAddress: string
): PlaylistMeta => {
const playlist = storeState.currentPlaylist();
return {
...playlist,
_id: playlist?._id ?? 'stalker-player',
title: playlist?.title ?? 'Stalker Portal',
count: playlist?.count ?? 0,
autoRefresh: playlist?.autoRefresh ?? false,
importDate: playlist?.importDate ?? '',
portalUrl,
macAddress,
};
};
const persistRecentlyViewed = (
playlistId: string,
recentItem: StalkerRecentlyViewedItem
): void => {
playlistService
.addPortalRecentlyViewed(playlistId, recentItem)
.subscribe((updatedPlaylist) => {
dispatchStalkerPlaylistMetaUpdate(
ngrxStore,
playlistId,
{
recentlyViewed:
updatedPlaylist?.recentlyViewed,
}
);
});
};
const buildRecentlyViewedItem = (
item: StalkerPlayableItem,
cmd?: string,
cover?: string,
title?: string
): StalkerRecentlyViewedItem =>
buildStalkerRecentlyViewedPayload(
{
...item,
cmd,
cover,
title,
},
storeState.selectedContentType()
);
const recordRecentlyViewed = (
item: StalkerPlayableItem | null | undefined,
cmd?: string,
cover?: string,
title?: string
): void => {
const playlistId = storeState.currentPlaylist()?._id;
if (!item || !playlistId) {
return;
}
const recentItem = buildRecentlyViewedItem(
item,
cmd,
cover,
title
);
if (typeof storeState.addToRecentlyViewed === 'function') {
storeState.addToRecentlyViewed(recentItem);
return;
}
persistRecentlyViewed(playlistId, recentItem);
};
const resolveVodPlaybackInternal = async (
cmd?: string,
title?: string,
thumbnail?: string,
episodeNum?: number,
episodeId?: number,
startTime?: number
): Promise<ResolvedPortalPlayback> => {
const item = storeState.selectedItem() as
| StalkerPlayableItem
| null
| undefined;
let cmdToUse = cmd ?? item?.cmd;
if (!cmdToUse) {
throw new Error('nothing_to_play');
}
const playlist = storeState.currentPlaylist();
if (!playlist?.portalUrl || !playlist.macAddress) {
throw new Error('nothing_to_play');
}
if (shouldResolveMovieFileId(item, cmdToUse)) {
const itemId = normalizeStalkerEntityId(item?.id);
const fileId = await fetchStalkerMovieFileId(
requestDeps,
playlist,
itemId
);
if (fileId) {
cmdToUse = `/media/file_${fileId}.mpg`;
}
}
const streamUrl = await fetchStalkerPlaybackLink(
requestDeps,
{
playlist,
selectedContentType:
storeState.selectedContentType(),
cmd: cmdToUse,
series: episodeNum,
}
);
recordRecentlyViewed(item, cmd, thumbnail, title);
const isEpisode =
episodeNum !== undefined || episodeId !== undefined;
const selectedItemId =
normalizeStalkerEntityIdAsNumber(item?.id) ?? 0;
return {
streamUrl,
title: title ?? '',
thumbnail,
startTime,
userAgent: playlist.userAgent,
referer: playlist.referrer,
origin: playlist.origin,
contentInfo: {
playlistId: playlist._id,
contentXtreamId:
isEpisode && episodeId
? episodeId
: selectedItemId,
contentType: isEpisode ? 'episode' : 'vod',
seriesXtreamId: isEpisode
? selectedItemId
: undefined,
},
};
};
const resolveItvPlaybackInternal = async (
item: StalkerPlayableItem
): Promise<ResolvedPortalPlayback> => {
const playlist = storeState.currentPlaylist();
if (
!playlist?.portalUrl ||
!playlist.macAddress ||
!item.cmd
) {
throw new Error('nothing_to_play');
}
const streamUrl = await fetchStalkerPlaybackLink(
requestDeps,
{
playlist,
selectedContentType:
storeState.selectedContentType(),
cmd: item.cmd,
forcedContentType: 'itv',
}
);
const token = stalkerSession.getCachedToken(playlist._id);
const headers = buildStalkerExternalPlaybackHeaders(
playlist,
token,
streamUrl
);
const crossOriginStream = isCrossOriginStalkerStream(
playlist,
streamUrl
);
const portalOrigin = getStalkerPortalOrigin(playlist);
recordRecentlyViewed(
item,
item.cmd,
item.logo ?? item.cover,
item.o_name || item.name || item.title
);
return {
streamUrl,
title: item.o_name || item.name || item.title || '',
thumbnail: item.logo ?? item.cover ?? null,
headers,
userAgent:
headers['User-Agent'] ||
playlist.userAgent ||
STALKER_MAG_USER_AGENT,
referer: crossOriginStream
? undefined
: playlist.referrer || portalOrigin,
origin: crossOriginStream
? undefined
: playlist.origin || portalOrigin,
};
};
return {
async fetchLinkToPlay(
portalUrl: string,
macAddress: string,
cmd: string,
series?: number
) {
return fetchStalkerPlaybackLink(requestDeps, {
playlist: createRequestPlaylist(
portalUrl,
macAddress
),
selectedContentType:
storeState.selectedContentType(),
cmd,
series,
});
},
async getExpireDate() {
const playlist = storeState.currentPlaylist();
if (!playlist) {
return 'Unknown';
}
try {
return await fetchStalkerExpireDate(
requestDeps,
playlist
);
} catch (error) {
logger.error('Failed to fetch expire date', error);
return 'Error fetching data';
}
},
async fetchMovieFileId(
movieId: string
): Promise<string | null> {
const playlist = storeState.currentPlaylist();
if (!playlist) {
return null;
}
return fetchStalkerMovieFileId(
requestDeps,
playlist,
movieId
);
},
async resolveVodPlayback(
cmd?: string,
title?: string,
thumbnail?: string,
episodeNum?: number,
episodeId?: number,
startTime?: number
): Promise<ResolvedPortalPlayback> {
return resolveVodPlaybackInternal(
cmd,
title,
thumbnail,
episodeNum,
episodeId,
startTime
);
},
async resolveItvPlayback(
item: StalkerPlayableItem
): Promise<ResolvedPortalPlayback> {
return resolveItvPlaybackInternal(item);
},
async createLinkToPlayVod(
cmd?: string,
title?: string,
thumbnail?: string,
episodeNum?: number,
episodeId?: number,
startTime?: number
) {
try {
const playback = await resolveVodPlaybackInternal(
cmd,
title,
thumbnail,
episodeNum,
episodeId,
startTime
);
void playerService.openResolvedPlayback(
playback,
true
);
} catch (error) {
logger.error('Failed to get playback URL', error);
const errorMessage =
error instanceof Error &&
error.message === 'nothing_to_play'
? translate.instant(
'PORTALS.CONTENT_NOT_AVAILABLE'
)
: translate.instant(
'PORTALS.PLAYBACK_ERROR'
);
snackBar.open(errorMessage, undefined, {
duration: 3000,
});
}
},
};
}
)
);
}
@@ -0,0 +1,136 @@
import { inject } from '@angular/core';
import {
patchState,
signalStoreFeature,
withMethods,
withProps,
withState,
} from '@ngrx/signals';
import { PlaylistMeta, STALKER_REQUEST } from 'shared-interfaces';
import { createLogger } from '@iptvnator/portal/shared/util';
import { DataService } from 'services';
import { StalkerSessionService } from '../../stalker-session.service';
import { toStalkerSessionPlaylist } from '../utils';
type StalkerPortalWindow = Window & {
electron?: {
dbCreatePlaylist: (playlist: {
id: string;
name: string;
macAddress: string;
url: string;
type: 'stalker';
}) => Promise<unknown>;
dbGetPlaylist: (playlistId: string) => Promise<unknown>;
};
};
/**
* Portal/session state and methods.
*/
export interface StalkerPortalState {
currentPlaylist: PlaylistMeta | undefined;
}
const initialPortalState: StalkerPortalState = {
currentPlaylist: undefined,
};
export function withStalkerPortal() {
const logger = createLogger('withStalkerPortal');
return signalStoreFeature(
withState<StalkerPortalState>(initialPortalState),
withProps(
(
_store,
dataService = inject(DataService),
stalkerSession = inject(StalkerSessionService)
) => ({
/**
* Helper to make stalker requests with automatic token handling
*/
async makeStalkerRequest(
playlist: PlaylistMeta,
params: Record<string, string | number>
) {
// Get token if it's a full stalker portal
let token: string | undefined;
let serialNumber: string | undefined;
if (playlist.isFullStalkerPortal) {
try {
const result = await stalkerSession.ensureToken(
toStalkerSessionPlaylist(playlist)
);
token = result.token ?? undefined;
serialNumber = playlist.stalkerSerialNumber;
} catch (error) {
logger.error('Failed to get stalker token', error);
}
}
return dataService.sendIpcEvent(STALKER_REQUEST, {
url: playlist.portalUrl,
macAddress: playlist.macAddress,
params,
token,
serialNumber,
});
},
})
),
withMethods(
(
store,
dataService = inject(DataService),
stalkerSession = inject(StalkerSessionService)
) => ({
async setCurrentPlaylist(playlist: PlaylistMeta | undefined) {
stalkerSession.setActiveWatchdogPlaylist(
playlist
? toStalkerSessionPlaylist(playlist)
: undefined
);
patchState(store, { currentPlaylist: playlist });
// Ensure Stalker playlist exists in SQLite for playback positions
// Only sync if this is actually a Stalker playlist (has macAddress and portalUrl)
if (
playlist &&
dataService.isElectron &&
playlist._id &&
playlist.macAddress &&
playlist.portalUrl
) {
try {
const electronApi = (window as StalkerPortalWindow)
.electron;
if (!electronApi) {
return;
}
const playlistId = String(playlist._id);
// Check if playlist exists in SQLite
const existing =
await electronApi.dbGetPlaylist(playlistId);
if (!existing) {
// Create playlist in SQLite
await electronApi.dbCreatePlaylist({
id: playlistId,
name: playlist.title || '',
macAddress: playlist.macAddress || '',
url: playlist.portalUrl || '',
type: 'stalker',
});
}
} catch (error) {
logger.error(
'Error syncing Stalker playlist to SQLite',
error
);
}
}
},
})
)
);
}
@@ -0,0 +1,97 @@
import { inject } from '@angular/core';
import { signalStoreFeature, withMethods } from '@ngrx/signals';
import { Store } from '@ngrx/store';
import { PlaylistsService } from 'services';
import { PlaylistMeta, StalkerPortalItem } from 'shared-interfaces';
import { createLogger } from '@iptvnator/portal/shared/util';
import { StalkerContentType } from '../stalker-store.contracts';
import {
buildStalkerRecentlyViewedPayload,
dispatchStalkerPlaylistMetaUpdate,
} from '../utils';
interface RecentStoreContext {
currentPlaylist(): PlaylistMeta | undefined;
selectedContentType(): StalkerContentType;
}
type RecentlyViewedPayload = StalkerPortalItem & {
id?: string | number;
title?: string;
};
/**
* Recently-viewed concern methods.
*/
export function withStalkerRecent() {
const logger = createLogger('withStalkerRecent');
return signalStoreFeature(
withMethods(
(
store,
playlistService = inject(PlaylistsService),
ngrxStore = inject(Store)
) => {
const storeContext = store as typeof store & RecentStoreContext;
return {
addToRecentlyViewed(item: RecentlyViewedPayload) {
const portalId = storeContext.currentPlaylist()?._id;
if (!portalId) {
return;
}
const selectedContentType =
storeContext.selectedContentType();
const recentItem = buildStalkerRecentlyViewedPayload(
item,
selectedContentType
);
playlistService
.addPortalRecentlyViewed(portalId, recentItem)
.subscribe((updatedPlaylist) => {
dispatchStalkerPlaylistMetaUpdate(
ngrxStore,
portalId,
{
recentlyViewed:
updatedPlaylist?.recentlyViewed,
}
);
});
},
removeFromRecentlyViewed(
itemId: string | number,
onComplete?: () => void
) {
const portalId = storeContext.currentPlaylist()?._id;
if (!portalId) {
return;
}
playlistService
.removeFromPortalRecentlyViewed(portalId, itemId)
.subscribe({
next: (updatedPlaylist) => {
dispatchStalkerPlaylistMetaUpdate(
ngrxStore,
portalId,
{
recentlyViewed:
updatedPlaylist?.recentlyViewed,
}
);
onComplete?.();
},
error: (error) =>
logger.error(
'Failed to remove item from recently viewed',
error
),
});
},
};
}
)
);
}
@@ -0,0 +1,55 @@
import { TestBed } from '@angular/core/testing';
import { signalStore } from '@ngrx/signals';
import { withStalkerSelection } from './with-stalker-selection.feature';
const TestSelectionStore = signalStore(withStalkerSelection());
describe('withStalkerSelection', () => {
let store: InstanceType<typeof TestSelectionStore>;
beforeEach(() => {
TestBed.configureTestingModule({
providers: [TestSelectionStore],
});
store = TestBed.inject(TestSelectionStore);
});
it('resets paging when the selected category changes', () => {
store.setPage(3);
store.setSelectedCategory('series-7');
expect(store.selectedCategoryId()).toBe('series-7');
expect(store.page()).toBe(0);
});
it('keeps paging when the search phrase is unchanged', () => {
store.setSearchPhrase('matrix');
store.setPage(2);
store.setSearchPhrase('matrix');
expect(store.searchPhrase()).toBe('matrix');
expect(store.page()).toBe(2);
});
it('resets paging when the search phrase changes', () => {
store.setPage(2);
store.setSearchPhrase('matrix');
expect(store.searchPhrase()).toBe('matrix');
expect(store.page()).toBe(0);
});
it('synchronizes entity ids when the selected item changes', () => {
store.setSelectedItem({
id: '55',
name: 'Example',
});
expect(store.selectedVodId()).toBe('55');
expect(store.selectedSerialId()).toBe('55');
expect(store.selectedItvId()).toBe('55');
});
});
@@ -0,0 +1,111 @@
import {
patchState,
signalStoreFeature,
withMethods,
withState,
} from '@ngrx/signals';
import { StalkerVodSource } from '../../models';
import { normalizeStalkerEntityId } from '../../stalker-vod.utils';
/**
* Selection/pagination/search feature state.
*/
export interface StalkerSelectionState {
selectedContentType: 'vod' | 'itv' | 'series';
selectedCategoryId: string | null | undefined;
selectedVodId: string | undefined;
selectedSerialId: string | undefined;
selectedItvId: string | undefined;
limit: number;
page: number;
searchPhrase: string;
selectedItem: StalkerVodSource | null | undefined;
}
const initialSelectionState: StalkerSelectionState = {
selectedContentType: 'vod',
selectedCategoryId: undefined,
selectedVodId: undefined,
selectedSerialId: undefined,
selectedItvId: undefined,
limit: 14,
page: 0,
searchPhrase: '',
selectedItem: undefined,
};
export function withStalkerSelection() {
return signalStoreFeature(
withState<StalkerSelectionState>(initialSelectionState),
withMethods((store) => ({
setSelectedContentType(type: 'vod' | 'itv' | 'series') {
patchState(store, { selectedContentType: type });
},
setSelectedCategory(id: string | number | null) {
const newId =
id !== null && id !== undefined ? String(id) : null;
if (store.selectedCategoryId() === newId) {
return;
}
patchState(store, {
selectedCategoryId: newId,
page: 0,
});
},
setSelectedSerialId(id: string) {
patchState(store, { selectedSerialId: id });
},
setSelectedVodId(id: string) {
patchState(store, { selectedVodId: id });
},
setSelectedItvId(id: string) {
patchState(store, { selectedItvId: id });
},
setLimit(limit: number) {
if (store.limit() === limit) {
return;
}
patchState(store, { limit });
},
setPage(page: number) {
if (store.page() === page) {
return;
}
patchState(store, { page });
},
setSearchPhrase(phrase: string) {
if (store.searchPhrase() === phrase) {
return;
}
patchState(store, { searchPhrase: phrase, page: 0 });
},
setSelectedItem(selectedItem: StalkerVodSource | null | undefined) {
const selectedIdRaw =
selectedItem?.id !== undefined && selectedItem?.id !== null
? selectedItem.id
: undefined;
const selectedId =
selectedIdRaw !== undefined
? normalizeStalkerEntityId(selectedIdRaw)
: undefined;
patchState(store, {
selectedVodId: selectedId,
selectedSerialId: selectedId,
selectedItvId: selectedId,
selectedItem,
});
},
clearSelectedItem() {
patchState(store, {
selectedVodId: undefined,
selectedSerialId: undefined,
selectedItvId: undefined,
selectedItem: undefined,
});
},
}))
);
}
@@ -0,0 +1,276 @@
import { computed, inject, resource } from '@angular/core';
import {
patchState,
signalStoreFeature,
withComputed,
withMethods,
withProps,
withState,
} from '@ngrx/signals';
import { createLogger } from '@iptvnator/portal/shared/util';
import { DataService } from 'services';
import { StalkerPortalActions } from 'shared-interfaces';
import {
StalkerSeason,
StalkerVodSeriesEpisode,
StalkerVodSeriesSeason,
} from '../../models';
import { StalkerContentTypes } from '../../stalker-content-types';
import { StalkerSessionService } from '../../stalker-session.service';
import { StalkerSeriesFeatureStoreContract } from '../stalker-store.contracts';
import {
executeStalkerRequest,
sortByNumericValue,
sortEpisodesByNumber,
sortVodSeriesSeasonsByNumber,
} from '../utils';
/**
* Regular-series and VOD-series feature state.
*/
export interface StalkerSeriesState {
/** For VOD items that are actually series (Ministra plugin is_series=1) */
vodSeriesSeasons: StalkerVodSeriesSeason[];
vodSeriesEpisodes: StalkerVodSeriesEpisode[];
selectedVodSeriesSeasonId: string | undefined;
}
const initialSeriesState: StalkerSeriesState = {
vodSeriesSeasons: [],
vodSeriesEpisodes: [],
selectedVodSeriesSeasonId: undefined,
};
interface StalkerSeriesResponse<T> {
js?:
| {
data?: T[];
}
| T[];
}
type StalkerSeriesStoreContext = StalkerSeriesFeatureStoreContract;
function extractSeriesItems<T>(response: StalkerSeriesResponse<T>): T[] {
if (Array.isArray(response?.js)) {
return response.js;
}
if (Array.isArray(response?.js?.data)) {
return response.js.data;
}
return [];
}
function toMovieId(value: unknown): string {
const raw = String(value ?? '').trim();
if (!raw) {
return '';
}
return raw.includes(':') ? raw.split(':')[0] : raw;
}
export function withStalkerSeries() {
const logger = createLogger('withStalkerSeries');
return signalStoreFeature(
withState<StalkerSeriesState>(initialSeriesState),
withProps(
(
store,
dataService = inject(DataService),
stalkerSession = inject(StalkerSessionService)
) => {
const storeContext = store as typeof store &
StalkerSeriesStoreContext;
const requestDeps = {
dataService,
stalkerSession,
};
return {
serialSeasonsResource: resource({
params: () => ({
itemId: storeContext.selectedSerialId(),
currentPlaylist: storeContext.currentPlaylist(),
}),
loader: async ({
params,
}): Promise<StalkerSeason[]> => {
const playlist = params.currentPlaylist;
const movieId = toMovieId(params.itemId);
if (!playlist || !movieId) {
return [];
}
const response = await executeStalkerRequest<
StalkerSeriesResponse<StalkerSeason>
>(requestDeps, playlist, {
action: StalkerContentTypes.series
.getContentAction,
type: 'series',
movie_id: movieId,
});
const seasons = extractSeriesItems(response);
if (seasons.length === 0) {
logger.warn(
'Invalid seasons response',
response
);
return [];
}
return sortByNumericValue(seasons);
},
}),
vodSeriesSeasonsResource: resource({
params: () => ({
currentPlaylist: storeContext.currentPlaylist(),
selectedItem: storeContext.selectedItem(),
selectedContentType:
storeContext.selectedContentType(),
}),
loader: async ({
params,
}): Promise<StalkerVodSeriesSeason[]> => {
const { currentPlaylist, selectedItem } = params;
logger.debug(
'vodSeriesSeasonsResource loader called',
{
item: selectedItem,
isSeries: selectedItem?.is_series,
currentPlaylist,
}
);
if (
!currentPlaylist ||
params.selectedContentType !== 'vod' ||
!selectedItem ||
selectedItem.id === undefined ||
selectedItem.id === null ||
!selectedItem.is_series
) {
logger.debug(
'vodSeriesSeasonsResource skipped - conditions not met'
);
return [];
}
const response = await executeStalkerRequest<
StalkerSeriesResponse<StalkerVodSeriesSeason>
>(requestDeps, currentPlaylist, {
action: StalkerPortalActions.GetOrderedList,
type: 'vod',
movie_id: selectedItem.id,
p: '1',
});
const seasonItems = extractSeriesItems(response);
if (seasonItems.length === 0) {
logger.debug(
'vodSeriesSeasonsResource - no response data'
);
return [];
}
logger.debug(
'vodSeriesSeasonsResource response data',
seasonItems
);
const seasons = seasonItems.filter(
(item) => item.is_season === true
);
logger.debug(
'vodSeriesSeasonsResource filtered seasons',
seasons
);
return sortVodSeriesSeasonsByNumber(seasons);
},
}),
};
}
),
withComputed((store) => {
const storeContext = store as typeof store &
StalkerSeriesStoreContext;
return {
getSerialSeasonsResource: computed(() =>
storeContext.serialSeasonsResource.value()
),
isSerialSeasonsLoading: computed(() =>
storeContext.serialSeasonsResource.isLoading()
),
getVodSeriesSeasonsResource: computed(() =>
storeContext.vodSeriesSeasonsResource.value()
),
isVodSeriesSeasonsLoading: computed(() =>
storeContext.vodSeriesSeasonsResource.isLoading()
),
};
}),
withMethods(
(
store,
dataService = inject(DataService),
stalkerSession = inject(StalkerSessionService)
) => {
const storeContext = store as typeof store &
Pick<StalkerSeriesStoreContext, 'currentPlaylist'>;
const requestDeps = {
dataService,
stalkerSession,
};
return {
async fetchVodSeriesEpisodes(
videoId: string,
seasonId: string
): Promise<StalkerVodSeriesEpisode[]> {
const playlist = storeContext.currentPlaylist();
if (!playlist) {
return [];
}
const response = await executeStalkerRequest<
StalkerSeriesResponse<StalkerVodSeriesEpisode>
>(requestDeps, playlist, {
action: StalkerPortalActions.GetOrderedList,
type: 'vod',
movie_id: videoId,
season_id: seasonId,
p: '1',
});
const episodeItems = extractSeriesItems(response);
if (episodeItems.length === 0) {
return [];
}
const episodes = sortEpisodesByNumber(
episodeItems.filter(
(item) => item.is_episode === true
)
);
patchState(store, {
vodSeriesEpisodes: episodes,
selectedVodSeriesSeasonId: seasonId,
});
return episodes;
},
};
}
)
);
}
@@ -0,0 +1,3 @@
export * from './features';
export * from './utils';
@@ -0,0 +1,81 @@
import { PlaylistMeta, StalkerPortalItem } from 'shared-interfaces';
import {
StalkerCategoryItem,
StalkerContentItem,
StalkerSeason,
StalkerVodSeriesSeason,
StalkerVodSource,
} from '../models';
export type StalkerContentType = 'vod' | 'series' | 'itv';
export interface ResourceState<T> {
value(): T;
isLoading(): boolean;
error(): unknown;
}
export interface StalkerPortalStoreContract {
currentPlaylist(): PlaylistMeta | undefined;
}
export interface StalkerSelectionStoreContract {
selectedContentType(): StalkerContentType;
selectedCategoryId(): string | null | undefined;
selectedVodId(): string | undefined;
selectedSerialId(): string | undefined;
selectedItvId(): string | undefined;
limit(): number;
page(): number;
searchPhrase(): string;
selectedItem(): StalkerVodSource | null | undefined;
}
export interface StalkerCategorySliceContract {
vodCategories(): StalkerCategoryItem[];
seriesCategories(): StalkerCategoryItem[];
itvCategories(): StalkerCategoryItem[];
}
export interface StalkerContentFeatureStoreContract
extends
StalkerPortalStoreContract,
StalkerSelectionStoreContract,
StalkerCategorySliceContract {
getContentResource: ResourceState<StalkerContentItem[]>;
categoryResource: ResourceState<StalkerCategoryItem[]>;
}
export interface StalkerSeriesFeatureStoreContract
extends
StalkerPortalStoreContract,
Pick<
StalkerSelectionStoreContract,
'selectedContentType' | 'selectedItem' | 'selectedSerialId'
> {
serialSeasonsResource: ResourceState<StalkerSeason[]>;
vodSeriesSeasonsResource: ResourceState<StalkerVodSeriesSeason[]>;
}
export interface StalkerRecentCallbackStoreContract {
addToRecentlyViewed?: (item: StalkerRecentlyViewedItem) => void;
}
export type StalkerRecentlyViewedItem = StalkerPortalItem & {
id: string | number;
title: string;
};
export interface StalkerPlayerFeatureStoreContract
extends
StalkerPortalStoreContract,
Pick<
StalkerSelectionStoreContract,
'selectedContentType' | 'selectedItem'
>,
StalkerRecentCallbackStoreContract {}
export interface StalkerEpgFeatureStoreContract
extends
StalkerPortalStoreContract,
Pick<StalkerSelectionStoreContract, 'selectedItvId'> {}
@@ -0,0 +1,5 @@
export * from './stalker-collection-persistence.utils';
export * from './stalker-player-request.utils';
export * from './stalker-request.utils';
export * from './stalker-content-mappers';
export * from './stalker-sorting';
@@ -0,0 +1,76 @@
import { Store } from '@ngrx/store';
import { PlaylistActions } from 'm3u-state';
import { PlaylistMeta, StalkerPortalItem } from 'shared-interfaces';
import { normalizeStalkerEntityId } from '../../stalker-vod.utils';
import {
StalkerContentType,
StalkerRecentlyViewedItem,
} from '../stalker-store.contracts';
export type StalkerCollectionPayload = StalkerPortalItem & {
cmd?: string;
cover?: string;
id?: string | number;
stream_id?: string | number;
title?: string;
};
export function resolveStalkerCategoryId(
value: unknown,
fallback: string
): string {
const normalized = String(value ?? '').trim();
return normalized || fallback;
}
export function getStalkerSeriesRecentMetadata(
selectedContentType: StalkerContentType
): {
category_id?: 'series';
is_series?: true;
} {
if (selectedContentType !== 'series') {
return {};
}
return {
category_id: 'series',
is_series: true,
};
}
export function buildStalkerRecentlyViewedPayload(
item: StalkerCollectionPayload,
selectedContentType: StalkerContentType
): StalkerRecentlyViewedItem {
return {
...item,
category_id: resolveStalkerCategoryId(
item.category_id,
selectedContentType
),
...getStalkerSeriesRecentMetadata(selectedContentType),
added_at: Date.now(),
id: normalizeStalkerEntityId(item.id ?? item.stream_id ?? ''),
title: item.title ?? item.name ?? item.o_name ?? '',
};
}
export function dispatchStalkerPlaylistMetaUpdate(
ngrxStore: Store,
playlistId: string | undefined,
changes: Pick<PlaylistMeta, 'favorites' | 'recentlyViewed'>
): void {
if (!playlistId) {
return;
}
ngrxStore.dispatch(
PlaylistActions.updatePlaylistMeta({
playlist: {
_id: playlistId,
...changes,
} as PlaylistMeta,
})
);
}
@@ -0,0 +1,77 @@
import {
StalkerContentItem,
StalkerItvChannel,
StalkerVodSource,
} from '../../models';
/**
* Convert relative URLs to absolute URLs using the portal base URL
* Handles screenshot_uri and cmd paths that come as relative from the server
*/
export function makeAbsoluteUrl(baseUrl: string, relativePath: string): string {
if (!relativePath) return '';
// Already absolute URL
if (
relativePath.startsWith('http://') ||
relativePath.startsWith('https://')
) {
return relativePath;
}
// Parse the base URL to get origin
try {
const url = new URL(baseUrl);
// Ensure the relative path starts with /
const path = relativePath.startsWith('/')
? relativePath
: `/${relativePath}`;
return `${url.origin}${path}`;
} catch {
return relativePath;
}
}
/**
* Post-process stalker items to convert relative URLs to absolute
*/
export function processItemUrls<T extends StalkerVodSource>(
item: T,
portalUrl: string
): T {
const processed = { ...item };
// Convert screenshot_uri to absolute URL
if (processed.screenshot_uri) {
processed.screenshot_uri = makeAbsoluteUrl(
portalUrl,
processed.screenshot_uri
);
}
return processed;
}
export function toStalkerContentItem(
item: StalkerVodSource,
portalUrl: string
): StalkerContentItem {
const processed = processItemUrls(item, portalUrl);
return {
...processed,
cover: processed.screenshot_uri,
};
}
export function toStalkerItvChannel(item: StalkerContentItem): StalkerItvChannel {
return {
...item,
id: item.id ?? item.stream_id ?? '',
cmd: String(item.cmd ?? ''),
name:
typeof item.name === 'string' ? item.name : undefined,
o_name:
typeof item.o_name === 'string' ? item.o_name : undefined,
logo:
typeof item.logo === 'string' ? item.logo : undefined,
};
}
@@ -0,0 +1,136 @@
import { PlaylistMeta, StalkerPortalActions } from 'shared-interfaces';
import { StalkerSessionService } from '../../stalker-session.service';
import {
fetchStalkerExpireDate,
fetchStalkerMovieFileId,
fetchStalkerPlaybackLink,
shouldResolveMovieFileId,
} from './stalker-player-request.utils';
const PLAYLIST = {
_id: 'playlist-1',
title: 'Demo Stalker',
count: 0,
autoRefresh: false,
importDate: '2026-04-14T00:00:00.000Z',
portalUrl: 'http://demo.example/stalker_portal/server/load.php',
macAddress: '00:1A:79:00:00:01',
isFullStalkerPortal: false,
} as PlaylistMeta;
describe('stalker-player-request.utils', () => {
let dataService: {
sendIpcEvent: jest.Mock<Promise<unknown>, unknown[]>;
};
let stalkerSession: Pick<StalkerSessionService, 'makeAuthenticatedRequest'>;
beforeEach(() => {
dataService = {
sendIpcEvent: jest.fn(),
};
stalkerSession = {
makeAuthenticatedRequest: jest.fn(),
};
});
it('builds create_link requests and normalizes relative portal URLs', async () => {
dataService.sendIpcEvent.mockResolvedValue({
js: { cmd: '/media/video_77.mpg' },
});
const streamUrl = await fetchStalkerPlaybackLink(
{
dataService: dataService as never,
stalkerSession: stalkerSession as StalkerSessionService,
},
{
playlist: PLAYLIST,
selectedContentType: 'series',
cmd: '/media/source.mpg',
series: 3,
}
);
expect(dataService.sendIpcEvent).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({
url: PLAYLIST.portalUrl,
macAddress: PLAYLIST.macAddress,
params: expect.objectContaining({
action: StalkerPortalActions.CreateLink,
cmd: '/media/source.mpg',
type: 'vod',
series: '3',
download: '0',
disable_ad: '0',
JsHttpRequest: '1-xml',
}),
})
);
expect(streamUrl).toBe(
'http://demo.example/stalker_portal/media/video_77.mpg'
);
});
it('detects file-id fallback candidates and reads the movie file id', async () => {
expect(
shouldResolveMovieFileId(
{ has_files: true },
'/media/source_42.mpg'
)
).toBe(true);
expect(
shouldResolveMovieFileId({ has_files: true }, '/media/file_42.mpg')
).toBe(false);
dataService.sendIpcEvent.mockResolvedValue({
js: {
data: [{ id: 77 }],
},
});
await expect(
fetchStalkerMovieFileId(
{
dataService: dataService as never,
stalkerSession: stalkerSession as StalkerSessionService,
},
PLAYLIST,
'22'
)
).resolves.toBe('77');
expect(dataService.sendIpcEvent).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({
params: expect.objectContaining({
action: StalkerPortalActions.GetOrderedList,
type: 'vod',
movie_id: '22',
p: '1',
}),
})
);
});
it('returns a localized expire date string from account info', async () => {
const expireDate = 1_713_139_200;
dataService.sendIpcEvent.mockResolvedValue({
js: {
account_info: {
expire_date: expireDate,
},
},
});
await expect(
fetchStalkerExpireDate(
{
dataService: dataService as never,
stalkerSession: stalkerSession as StalkerSessionService,
},
PLAYLIST
)
).resolves.toBe(new Date(expireDate * 1000).toLocaleDateString());
});
});
@@ -0,0 +1,204 @@
import { DataService } from 'services';
import {
PlaylistMeta,
StalkerPortalActions,
StalkerPortalItem,
} from 'shared-interfaces';
import { StalkerSessionService } from '../../stalker-session.service';
import { StalkerContentTypes } from '../../stalker-content-types';
import { StalkerContentType } from '../stalker-store.contracts';
import { executeStalkerRequest } from './stalker-request.utils';
export interface StalkerPlayerResponse {
js?: {
error?: string;
cmd?: string;
data?: Array<{ id?: string | number }>;
account_info?: {
expire_date?: string | number;
};
};
}
export interface StalkerPlayerRequestDeps {
dataService: DataService;
stalkerSession: StalkerSessionService;
}
export interface StalkerPlayableItemLike extends StalkerPortalItem {
cmd?: string;
has_files?: unknown;
}
export function normalizeStalkerPlaybackCommand(value: string): string {
const trimmed = String(value ?? '').trim();
if (!trimmed) {
return '';
}
const splitAt = trimmed.indexOf(' ');
if (splitAt > 0) {
const candidate = trimmed.slice(splitAt + 1).trim();
if (
candidate.startsWith('http://') ||
candidate.startsWith('https://') ||
candidate.startsWith('/') ||
candidate.startsWith('?')
) {
return candidate;
}
}
return trimmed;
}
export function resolveStalkerPlaybackUrl(
portalUrl: string,
originalCmd: string,
responseCmd: string
): string {
let url = normalizeStalkerPlaybackCommand(responseCmd);
if (!url) {
return '';
}
if (url.startsWith('http://') || url.startsWith('https://')) {
return url;
}
try {
const portalUrlObj = new URL(portalUrl);
const pathParts = portalUrlObj.pathname.split('/');
let basePath = '';
for (let index = 0; index < pathParts.length; index += 1) {
if (
pathParts[index] === 'stalker_portal' ||
pathParts[index] === 'c' ||
pathParts[index] === 'portal'
) {
basePath = '/' + pathParts.slice(1, index + 1).join('/');
break;
}
}
if (url.startsWith('?')) {
const normalizedCmd = normalizeStalkerPlaybackCommand(originalCmd);
if (
normalizedCmd.startsWith('http://') ||
normalizedCmd.startsWith('https://')
) {
return `${normalizedCmd}${url}`;
}
return `${portalUrlObj.origin}${basePath}${normalizedCmd}${url}`;
}
if (url.startsWith('/')) {
return `${portalUrlObj.origin}${basePath}${url}`;
}
} catch {
return url;
}
return url;
}
export function shouldResolveMovieFileId(
item: Pick<StalkerPlayableItemLike, 'has_files'> | null | undefined,
cmd: string
): boolean {
return (
item?.has_files !== undefined &&
!cmd.includes('://') &&
cmd.includes('/media/') &&
!cmd.includes('/media/file_')
);
}
export async function fetchStalkerPlaybackLink(
deps: StalkerPlayerRequestDeps,
options: {
playlist: PlaylistMeta;
selectedContentType: StalkerContentType;
cmd: string;
series?: number;
forcedContentType?: StalkerContentType;
}
): Promise<string> {
const contentType =
options.forcedContentType ?? options.selectedContentType;
const response = await executeStalkerRequest<StalkerPlayerResponse>(
deps,
options.playlist,
{
action: StalkerContentTypes[contentType].getLink,
cmd: options.cmd,
type: options.series ? 'vod' : contentType,
disable_ad: '0',
download: '0',
JsHttpRequest: '1-xml',
...(options.series ? { series: String(options.series) } : {}),
}
);
if (response.js?.error) {
throw new Error(response.js.error);
}
const streamUrl = resolveStalkerPlaybackUrl(
options.playlist.portalUrl ?? '',
options.cmd,
response.js?.cmd ?? ''
);
if (!streamUrl) {
throw new Error('nothing_to_play');
}
return streamUrl;
}
export async function fetchStalkerMovieFileId(
deps: StalkerPlayerRequestDeps,
playlist: PlaylistMeta,
movieId: string
): Promise<string | null> {
const response = await executeStalkerRequest<StalkerPlayerResponse>(
deps,
playlist,
{
action: StalkerPortalActions.GetOrderedList,
type: 'vod',
movie_id: movieId,
p: '1',
}
);
const fileId = response?.js?.data?.[0]?.id;
return fileId == null ? null : String(fileId);
}
export async function fetchStalkerExpireDate(
deps: StalkerPlayerRequestDeps,
playlist: PlaylistMeta
): Promise<string> {
const response = await executeStalkerRequest<StalkerPlayerResponse>(
deps,
playlist,
{
type: 'account_info',
action: 'get_main_info',
JsHttpRequest: '1-xml',
}
);
const expireDate = response?.js?.account_info?.expire_date;
const numericExpireDate = Number(expireDate);
if (expireDate && !Number.isNaN(numericExpireDate)) {
return new Date(numericExpireDate * 1000).toLocaleDateString();
}
return expireDate ? String(expireDate) : 'Unknown';
}
@@ -0,0 +1,34 @@
import { DataService } from 'services';
import { Playlist, PlaylistMeta, STALKER_REQUEST } from 'shared-interfaces';
import { StalkerSessionService } from '../../stalker-session.service';
export interface StalkerRequestDeps {
dataService: DataService;
stalkerSession: StalkerSessionService;
}
export function toStalkerSessionPlaylist(playlist: PlaylistMeta): Playlist {
return {
lastUsage: '',
...playlist,
} as Playlist;
}
export async function executeStalkerRequest<T>(
deps: StalkerRequestDeps,
playlist: PlaylistMeta,
params: Record<string, string | number>
): Promise<T> {
if (playlist.isFullStalkerPortal) {
return deps.stalkerSession.makeAuthenticatedRequest<T>(
toStalkerSessionPlaylist(playlist),
params
);
}
return deps.dataService.sendIpcEvent<T>(STALKER_REQUEST, {
url: playlist.portalUrl,
macAddress: playlist.macAddress,
params,
});
}
@@ -0,0 +1,48 @@
import {
StalkerSeason,
StalkerVodSeriesEpisode,
StalkerVodSeriesSeason,
} from '../../models';
export function extractNumericValue(str: string): number {
const matches = str.match(/\d+/);
if (matches) {
return parseInt(matches[0], 10);
}
return 0;
}
export function sortByNumericValue(array: StalkerSeason[]): StalkerSeason[] {
if (!array) return [];
const key = 'name';
return [...array].sort((a, b) => {
const numericA = extractNumericValue(a[key]);
const numericB = extractNumericValue(b[key]);
return numericA - numericB;
});
}
export function sortVodSeriesSeasonsByNumber(
array: StalkerVodSeriesSeason[]
): StalkerVodSeriesSeason[] {
if (!array) return [];
return [...array].sort((a, b) => {
const numA = Number(a.season_number ?? 0) || 0;
const numB = Number(b.season_number ?? 0) || 0;
return numA - numB;
});
}
/**
* Sort episodes by series_number in ascending numeric order (1, 2, 3... not "1", "10", "2")
*/
export function sortEpisodesByNumber(
episodes: StalkerVodSeriesEpisode[]
): StalkerVodSeriesEpisode[] {
if (!episodes) return [];
return [...episodes].sort((a, b) => {
const numA = Number(a.series_number ?? 0) || 0;
const numB = Number(b.series_number ?? 0) || 0;
return numA - numB;
});
}
@@ -0,0 +1,6 @@
import { setupZoneTestEnv } from 'jest-preset-angular/setup-env/zone';
setupZoneTestEnv({
errorOnUnknownElements: true,
errorOnUnknownProperties: true,
});
@@ -0,0 +1,31 @@
{
"extends": "../../../../tsconfig.base.json",
"compilerOptions": {
"isolatedModules": true,
"target": "es2022",
"moduleResolution": "bundler",
"strict": true,
"noImplicitOverride": true,
"noPropertyAccessFromIndexSignature": true,
"noImplicitReturns": true,
"noFallthroughCasesInSwitch": true,
"emitDecoratorMetadata": false,
"module": "preserve"
},
"angularCompilerOptions": {
"enableI18nLegacyMessageIdFormat": false,
"strictInjectionParameters": true,
"strictInputAccessModifiers": true,
"strictTemplates": true
},
"files": [],
"include": [],
"references": [
{
"path": "./tsconfig.lib.json"
},
{
"path": "./tsconfig.spec.json"
}
]
}
@@ -0,0 +1,12 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"outDir": "../../../../dist/out-tsc",
"declaration": true,
"declarationMap": true,
"inlineSources": true,
"types": []
},
"include": ["src/**/*.ts"],
"exclude": ["src/**/*.spec.ts", "src/**/*.test.ts"]
}
@@ -0,0 +1,17 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"outDir": "../../../../dist/out-tsc",
"module": "commonjs",
"target": "es2016",
"types": ["jest", "node"],
"moduleResolution": "node10"
},
"files": ["src/test-setup.ts"],
"include": [
"jest.config.ts",
"src/**/*.test.ts",
"src/**/*.spec.ts",
"src/**/*.d.ts"
]
}