refactor: refactor Stalker portal store, features and utilities

- Updated `with-stalker-portal.feature.ts` to handle undefined currentPlaylist and improved error handling for token retrieval.
- Enhanced `with-stalker-recent.feature.ts` by introducing utility functions for recently viewed items and dispatching playlist meta updates.
- Added unit tests for `with-stalker-selection.feature.ts` to ensure correct state management on category and item selection changes.
- Simplified `with-stalker-selection.feature.ts` by removing unnecessary computed properties and streamlining state management.
- Improved `with-stalker-series.feature.ts` by refactoring resource fetching logic and integrating new utility functions for request execution.
- Created `stalker-store.contracts.ts` to define interfaces for various store contracts, enhancing type safety and clarity.
- Added utility functions in `stalker-collection-persistence.utils.ts` for building recently viewed payloads and dispatching updates.
- Implemented player request utilities in `stalker-player-request.utils.ts` for handling playback links and movie file IDs.
- Introduced request execution utilities in `stalker-request.utils.ts` to streamline authenticated and non-authenticated requests.

Entire-Checkpoint: c6e522b4276c
This commit is contained in:
4gray committed 2026-04-16 21:58:42 +02:00
1 parent 542334a9a9
commit d0f36a1afe
18 files changed
+1780 -1060

No files matched your search

@@ -1,11 +1,11 @@
import { Playlist } from 'shared-interfaces';
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: Playlist | undefined | null
playlist: PlaylistMeta | undefined | null
): string | undefined {
const portalUrl = playlist?.portalUrl;
if (!portalUrl) {
@@ -20,7 +20,7 @@ export function getStalkerPortalOrigin(
}
export function isCrossOriginStalkerStream(
playlist: Playlist | undefined | null,
playlist: PlaylistMeta | undefined | null,
streamUrl?: string
): boolean {
const portalOrigin = getStalkerPortalOrigin(playlist);
@@ -36,7 +36,7 @@ export function isCrossOriginStalkerStream(
}
export function buildStalkerExternalPlaybackHeaders(
playlist: Playlist | undefined | null,
playlist: PlaylistMeta | undefined | null,
token?: string | null,
streamUrl?: string
): Record<string, string> {
@@ -0,0 +1,197 @@
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 });
},
})),
withStalkerContent()
);
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'
);
});
});
@@ -8,22 +8,27 @@ import {
withState,
} from '@ngrx/signals';
import { TranslateService } from '@ngx-translate/core';
import { DataService } from 'services';
import {
Playlist,
STALKER_REQUEST,
StalkerPortalActions,
} from 'shared-interfaces';
import { createLogger } from '@iptvnator/portal/shared/util';
import { DataService } from 'services';
import {
StalkerCategoryItem,
StalkerContentItem,
StalkerItvChannel,
StalkerVodSource,
} from '../../models';
import { StalkerSessionService } from '../../stalker-session.service';
import { StalkerContentTypes } from '../../stalker-content-types';
import { toStalkerContentItem, toStalkerItvChannel } from '../utils';
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.
@@ -35,6 +40,9 @@ export interface StalkerContentState {
itvCategories: StalkerCategoryItem[];
hasMoreChannels: boolean;
itvChannels: StalkerItvChannel[];
paginatedContent: StalkerContentItem[];
categoryError: unknown;
contentError: unknown;
}
const initialContentState: StalkerContentState = {
@@ -44,14 +52,11 @@ const initialContentState: StalkerContentState = {
itvCategories: [],
hasMoreChannels: false,
itvChannels: [],
paginatedContent: [],
categoryError: null,
contentError: null,
};
interface ResourceState<T> {
value(): T;
isLoading(): boolean;
error(): unknown;
}
interface StalkerCategoryResponseItem {
id?: string | number;
title?: string;
@@ -68,19 +73,87 @@ interface StalkerOrderedListResponse {
};
}
interface StalkerContentStoreContext {
selectedContentType(): 'vod' | 'series' | 'itv';
currentPlaylist(): Playlist | undefined;
selectedCategoryId(): string | null | undefined;
searchPhrase(): string;
page(): number;
limit(): number;
getContentResource: ResourceState<StalkerContentItem[] | undefined>;
getCategoryResource: ResourceState<StalkerCategoryItem[]>;
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(
@@ -90,331 +163,354 @@ export function withStalkerContent() {
stalkerSession = inject(StalkerSessionService),
translateService = inject(TranslateService)
) => {
const storeContext =
store as unknown as StalkerContentStoreContext;
const storeContext = store as typeof store &
StalkerContentResourceStoreContract;
const requestDeps = {
dataService,
stalkerSession,
};
return {
getCategoryResource: resource({
params: () => ({
contentType: storeContext.selectedContentType(),
action: StalkerPortalActions.GetCategories,
currentPlaylist: storeContext.currentPlaylist(),
}),
loader: async ({
params,
}): Promise<StalkerCategoryItem[]> => {
if (!params.currentPlaylist) return [];
switch (params.contentType) {
case 'itv':
if (store.itvCategories().length > 0) {
return store.itvCategories();
}
break;
case 'vod':
if (store.vodCategories().length > 0) {
return store.vodCategories();
}
break;
case 'series':
if (store.seriesCategories().length > 0) {
return store.seriesCategories();
}
break;
}
const { portalUrl, macAddress } =
params.currentPlaylist;
// Use makeAuthenticatedRequest for automatic retry on auth failure
const playlist = params.currentPlaylist as Playlist;
const queryParams = {
action: StalkerContentTypes[params.contentType]
.getCategoryAction,
type: params.contentType,
};
let response: StalkerCategoryResponse;
if (playlist.isFullStalkerPortal) {
// Full stalker portal - use authenticated request with retry
response = await stalkerSession.makeAuthenticatedRequest<StalkerCategoryResponse>(
playlist,
queryParams
);
} else {
// Simple stalker portal - no auth needed
response = await dataService.sendIpcEvent<StalkerCategoryResponse>(
STALKER_REQUEST,
{
url: portalUrl,
macAddress,
params: queryParams,
}
);
}
// Guard: ensure response has expected structure
if (!response?.js || !Array.isArray(response.js)) {
logger.warn(
'Invalid categories response',
response
);
return [];
}
const categories = response.js
.map(
(item): StalkerCategoryItem => ({
category_name: item.title,
category_id: String(item.id),
})
)
.sort((a, b) =>
a.category_name.localeCompare(b.category_name)
);
// If the portal provides its own "All" category entry,
// promote it to position 0. Otherwise add a synthetic one.
const allIdx = categories.findIndex(
(c) =>
c.category_name.trim().toLowerCase() === 'all'
);
if (allIdx > 0) {
// Move the existing 'All' entry to the front
categories.unshift(categories.splice(allIdx, 1)[0]);
} else if (
allIdx === -1 &&
categories.length > 0 &&
!categories.some(
(category) =>
String(category.category_id) === '*'
)
) {
// No 'All' entry found — prepend synthetic one
categories.unshift({
category_name: translateService.instant(
'PORTALS.ALL_CATEGORIES'
),
category_id: '*',
});
}
patchState(store, {
[`${params.contentType}Categories`]: categories,
});
return categories;
},
}),
getContentResource: resource({
params: () => ({
contentType: storeContext.selectedContentType(),
category: storeContext.selectedCategoryId(),
action: StalkerPortalActions.GetOrderedList,
search: storeContext.searchPhrase(),
pageIndex: storeContext.page() + 1,
availableCategoryCount: (() => {
const contentType = storeContext.selectedContentType();
const categories =
contentType === 'vod'
? store.vodCategories()
: contentType === 'series'
? store.seriesCategories()
: store.itvCategories();
return categories.filter(
(category) =>
String(category.category_id) !== '*'
).length;
})(),
}),
loader: async ({
params,
}): Promise<StalkerContentItem[] | undefined> => {
if (
!params.category ||
params.category === null ||
params.category === ''
) {
patchState(store, { totalCount: 0 });
return Promise.resolve(undefined);
}
if (
params.category === '*' &&
(params.contentType === 'vod' ||
params.contentType === 'series') &&
params.availableCategoryCount === 0
) {
patchState(store, { totalCount: 0 });
return Promise.resolve(undefined);
}
const currentPlaylist = storeContext.currentPlaylist;
// Guard: ensure currentPlaylist is available (may not be during deep link init)
if (
!currentPlaylist() ||
!currentPlaylist().portalUrl
) {
patchState(store, { totalCount: 0 });
return Promise.resolve(undefined);
}
// VOD uses 'genre' param, series uses 'category' param, itv uses both
// Based on stalker-to-m3u implementation
// Use "*" for categories without an ID (e.g. "All") to fetch all items
const categoryParam = params.category || '*';
const queryParams: Record<string, string | number> = {
action: StalkerContentTypes[params.contentType]
.getContentAction,
type: params.contentType,
sortby: 'added',
...(params.search !== ''
? { search: params.search }
: {}),
p: params.pageIndex,
};
// Add the correct category/genre param based on content type
// Based on working app traces: VOD uses genre=0 and category={id}
if (params.contentType === 'vod') {
queryParams['genre'] = '0';
queryParams['category'] = categoryParam;
} else if (params.contentType === 'series') {
queryParams['category'] = categoryParam;
} else {
// itv - use both for compatibility
queryParams['category'] = categoryParam;
queryParams['genre'] = categoryParam;
}
// Use makeAuthenticatedRequest for automatic retry on auth failure
const playlist = currentPlaylist() as Playlist;
let response: StalkerOrderedListResponse;
if (playlist.isFullStalkerPortal) {
// Full stalker portal - use authenticated request with retry
response = await stalkerSession.makeAuthenticatedRequest<StalkerOrderedListResponse>(
playlist,
queryParams
);
} else {
// Simple stalker portal - no auth needed
response = await dataService.sendIpcEvent<StalkerOrderedListResponse>(
STALKER_REQUEST,
{
url: playlist.portalUrl,
macAddress: playlist.macAddress,
params: queryParams,
}
);
}
// Guard: ensure response has expected structure
if (!response?.js?.data) {
logger.warn('Invalid response structure', response);
return [];
}
patchState(store, {
totalCount: response.js.total_items ?? 0,
});
const portalUrl = currentPlaylist().portalUrl;
const newItems = response.js.data.map(
(item: StalkerVodSource) =>
toStalkerContentItem(item, portalUrl)
);
if (storeContext.selectedContentType() === 'itv') {
const channels = newItems.map(toStalkerItvChannel);
// Check if we're loading the first page or loading more
if (params.pageIndex === 1) {
patchState(store, { itvChannels: channels });
} else {
patchState(store, {
itvChannels: [
...store.itvChannels(),
...channels,
],
});
categoryResource: resource({
params: () => ({
contentType: storeContext.selectedContentType(),
currentPlaylist: storeContext.currentPlaylist(),
}),
loader: async ({
params,
}): Promise<StalkerCategoryItem[]> => {
if (!params.currentPlaylist) {
patchState(store, { categoryError: null });
return [];
}
// Update hasMoreItems based on total count and current items
const totalLoaded = store.itvChannels().length;
patchState(store, {
hasMoreChannels:
totalLoaded <
(response.js.total_items ?? 0),
});
}
const cachedCategories = getCategoriesByType(
store,
params.contentType
);
if (cachedCategories.length > 0) {
patchState(store, { categoryError: null });
return cachedCategories;
}
return newItems;
},
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 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 {
const response =
await executeStalkerRequest<StalkerOrderedListResponse>(
requestDeps,
playlist,
queryParams
);
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) {
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 unknown as StalkerContentStoreContext;
const storeContext = store as typeof store &
StalkerContentResourceStoreContract;
return {
getTotalPages: computed(() => {
return Math.ceil(store.totalCount() / storeContext.limit());
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 type = storeContext.selectedContentType();
const selectedCategoryId = storeContext.selectedCategoryId();
if (!selectedCategoryId) return '';
let categories: StalkerCategoryItem[] = [];
if (type === 'vod') {
categories = store.vodCategories();
} else if (type === 'series') {
categories = store.seriesCategories();
} else if (type === 'itv') {
categories = store.itvCategories();
const selectedCategoryId =
storeContext.selectedCategoryId();
if (!selectedCategoryId) {
return '';
}
const category = categories.find(
(cat) =>
String(cat.category_id) ===
const category = getCategoriesByType(
store,
storeContext.selectedContentType()
).find(
(item) =>
String(item.category_id) ===
String(selectedCategoryId)
);
return category ? category.category_name : '';
return category?.category_name ?? '';
}),
/** category content */
getPaginatedContent: computed(() =>
storeContext.getContentResource.value()
),
getPaginatedContent: computed(() => store.paginatedContent()),
isPaginatedContentLoading: computed(() =>
storeContext.getContentResource.isLoading()
),
isPaginatedContentFailed: computed(() =>
storeContext.getContentResource.error()
),
/** category resource */
isPaginatedContentFailed: computed(() => store.contentError()),
getCategoryResource: computed(() =>
storeContext.getCategoryResource.value()
getCategoriesByType(
store,
storeContext.selectedContentType()
)
),
isCategoryResourceLoading: computed(() =>
storeContext.getCategoryResource.isLoading()
),
isCategoryResourceFailed: computed(() =>
storeContext.getCategoryResource.error()
storeContext.categoryResource.isLoading()
),
isCategoryResourceFailed: computed(() => store.categoryError()),
};
}),
withMethods((store) => ({
setCategories(
type: 'vod' | 'series' | 'itv',
type: StalkerContentType,
categories: StalkerCategoryItem[]
) {
if (type === 'vod') {
patchState(store, { vodCategories: categories });
} else if (type === 'series') {
patchState(store, { seriesCategories: categories });
} else if (type === 'itv') {
patchState(store, { itvCategories: categories });
}
patchState(store, buildCategoryPatch(type, categories));
},
resetCategories() {
patchState(store, {
vodCategories: [],
seriesCategories: [],
itvCategories: [],
categoryError: null,
});
},
setItvChannels(channels: StalkerItvChannel[]) {
@@ -8,20 +8,11 @@ import {
} from '@ngrx/signals';
import { createLogger } from '@iptvnator/portal/shared/util';
import { DataService } from 'services';
import {
EpgItem,
EpgProgram,
Playlist,
STALKER_REQUEST,
StalkerPortalActions,
} from 'shared-interfaces';
import { EpgItem, EpgProgram, StalkerPortalActions } from 'shared-interfaces';
import { normalizeStalkerEntityId } from '../../stalker-vod.utils';
import { StalkerSessionService } from '../../stalker-session.service';
interface EpgStoreContext {
currentPlaylist(): Playlist | undefined;
selectedItvId(): string | undefined;
}
import { StalkerEpgFeatureStoreContract } from '../stalker-store.contracts';
import { executeStalkerRequest } from '../utils';
interface StalkerEpgEntry {
id?: string | number;
@@ -49,9 +40,7 @@ interface StalkerEpgResponse {
js?:
| StalkerEpgEntry[]
| {
data?:
| StalkerEpgEntry[]
| Record<string, StalkerBulkEpgValue>;
data?: StalkerEpgEntry[] | Record<string, StalkerBulkEpgValue>;
}
| Record<string, StalkerBulkEpgValue>;
}
@@ -83,11 +72,12 @@ export function withStalkerEpg() {
return signalStoreFeature(
withState<StalkerEpgState>(initialEpgState),
withComputed((store) => {
const storeContext = store as unknown as EpgStoreContext;
const storeContext = store as typeof store &
StalkerEpgFeatureStoreContract;
return {
selectedItvEpgPrograms: computed(() => {
const selectedId = storeContext.selectedItvId?.();
const selectedId = storeContext.selectedItvId();
if (!selectedId) {
return [];
}
@@ -106,26 +96,25 @@ export function withStalkerEpg() {
dataService = inject(DataService),
stalkerSession = inject(StalkerSessionService)
) => {
const storeContext = store as unknown as EpgStoreContext;
const storeContext = store as typeof store &
StalkerEpgFeatureStoreContract;
const requestDeps = {
dataService,
stalkerSession,
};
const requestEpg = async (
playlist: Playlist,
playlist: NonNullable<
ReturnType<
StalkerEpgFeatureStoreContract['currentPlaylist']
>
>,
queryParams: Record<string, string>
): Promise<StalkerEpgResponse> => {
if (playlist.isFullStalkerPortal) {
return stalkerSession.makeAuthenticatedRequest(
playlist,
queryParams
);
}
return dataService.sendIpcEvent<StalkerEpgResponse>(
STALKER_REQUEST,
{
url: playlist.portalUrl,
macAddress: playlist.macAddress,
params: queryParams,
}
return executeStalkerRequest<StalkerEpgResponse>(
requestDeps,
playlist,
queryParams
);
};
@@ -212,7 +201,7 @@ export function withStalkerEpg() {
period: String(periodHours),
});
const selectedChannelId =
storeContext.selectedItvId?.() ?? null;
storeContext.selectedItvId() ?? null;
const bulkPrograms = extractBulkEpgByChannel(
response,
selectedChannelId
@@ -242,7 +231,9 @@ export function withStalkerEpg() {
);
}
function extractShortEpgEntries(response: StalkerEpgResponse): StalkerEpgEntry[] {
function extractShortEpgEntries(
response: StalkerEpgResponse
): StalkerEpgEntry[] {
if (Array.isArray(response?.js)) {
return response.js;
}
@@ -272,11 +263,9 @@ function extractBulkEpgByChannel(
const groupArrayEntries = (entries: StalkerEpgEntry[]) => {
for (const entry of entries) {
const entryChannelId =
entry.ch_id ?? selectedChannelId ?? null;
const normalizedChannelId = normalizeOptionalEntityId(
entryChannelId
);
const entryChannelId = entry.ch_id ?? selectedChannelId ?? null;
const normalizedChannelId =
normalizeOptionalEntityId(entryChannelId);
if (!normalizedChannelId) {
continue;
}
@@ -290,9 +279,7 @@ function extractBulkEpgByChannel(
groupArrayEntries(raw);
} else {
const rawData =
raw && typeof raw === 'object' && 'data' in raw
? raw.data
: raw;
raw && typeof raw === 'object' && 'data' in raw ? raw.data : raw;
if (Array.isArray(rawData)) {
groupArrayEntries(rawData);
@@ -386,8 +373,7 @@ function toEpgItem(
end: stop,
stop,
channel_id: String(item.ch_id ?? fallbackChannelId),
start_timestamp:
startTimestamp !== null ? String(startTimestamp) : '',
start_timestamp: startTimestamp !== null ? String(startTimestamp) : '',
stop_timestamp: stopTimestamp !== null ? String(stopTimestamp) : '',
};
}
@@ -452,9 +438,7 @@ function getProgramTimestampSeconds(
: normalized.replace(' ', 'T');
const parsedDate = Date.parse(candidate);
return Number.isFinite(parsedDate)
? Math.floor(parsedDate / 1000)
: null;
return Number.isFinite(parsedDate) ? Math.floor(parsedDate / 1000) : null;
}
function getProgramTimestampMs(
@@ -3,13 +3,19 @@ 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 { PlaylistActions } from 'm3u-state';
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(): string;
selectedContentType(): StalkerSelectionStoreContract['selectedContentType'] extends () => infer T
? T
: string;
}
type FavoritePayload = StalkerPortalItem & {
@@ -17,14 +23,6 @@ type FavoritePayload = StalkerPortalItem & {
id?: string | number;
};
function resolveCategoryId(
value: unknown,
fallback: string
): string {
const normalized = String(value ?? '').trim();
return normalized || fallback;
}
/**
* Favorites concern methods.
*/
@@ -38,14 +36,19 @@ export function withStalkerFavorites() {
translate = inject(TranslateService),
ngrxStore = inject(Store)
) => {
const storeContext = store as unknown as FavoritesStoreContext;
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: resolveCategoryId(
category_id: resolveStalkerCategoryId(
item.category_id,
storeContext.selectedContentType()
),
@@ -53,20 +56,18 @@ export function withStalkerFavorites() {
id: item.stream_id ?? item.id,
})
.subscribe((updatedPlaylist) => {
ngrxStore.dispatch(
PlaylistActions.updatePlaylistMeta({
playlist: {
_id: portalId,
favorites:
updatedPlaylist?.favorites,
} as PlaylistMeta,
})
dispatchStalkerPlaylistMetaUpdate(
ngrxStore,
portalId,
{
favorites: updatedPlaylist?.favorites,
}
);
snackBar.open(
translate.instant(
'PORTALS.ADDED_TO_FAVORITES'
),
null,
undefined,
{
duration: 1000,
}
@@ -79,23 +80,25 @@ export function withStalkerFavorites() {
onDone?: () => void
) {
const portalId = storeContext.currentPlaylist()?._id;
if (!portalId) {
return;
}
playlistService
.removeFromPortalFavorites(portalId, favoriteId)
.subscribe((updatedPlaylist) => {
ngrxStore.dispatch(
PlaylistActions.updatePlaylistMeta({
playlist: {
_id: portalId,
favorites:
updatedPlaylist?.favorites,
} as PlaylistMeta,
})
dispatchStalkerPlaylistMetaUpdate(
ngrxStore,
portalId,
{
favorites: updatedPlaylist?.favorites,
}
);
snackBar.open(
translate.instant(
'PORTALS.REMOVED_FROM_FAVORITES'
),
null,
undefined,
{
duration: 1000,
}
@@ -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,
});
});
});
@@ -3,19 +3,13 @@ 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 { PlaylistActions } from 'm3u-state';
import { PORTAL_PLAYER } from '@iptvnator/portal/shared/util';
import { PORTAL_PLAYER, createLogger } from '@iptvnator/portal/shared/util';
import { DataService, PlaylistsService } from 'services';
import {
Playlist,
PlaylistMeta,
ResolvedPortalPlayback,
STALKER_REQUEST,
StalkerPortalActions,
StalkerPortalItem,
} from 'shared-interfaces';
import { createLogger } from '@iptvnator/portal/shared/util';
import { StalkerContentTypes } from '../../stalker-content-types';
import {
buildStalkerExternalPlaybackHeaders,
getStalkerPortalOrigin,
@@ -27,56 +21,30 @@ import {
normalizeStalkerEntityId,
normalizeStalkerEntityIdAsNumber,
} from '../../stalker-vod.utils';
import {
StalkerPlayerFeatureStoreContract,
StalkerRecentlyViewedItem,
} from '../stalker-store.contracts';
import {
buildStalkerRecentlyViewedPayload,
dispatchStalkerPlaylistMetaUpdate,
fetchStalkerExpireDate,
fetchStalkerMovieFileId,
fetchStalkerPlaybackLink,
shouldResolveMovieFileId,
} from '../utils';
type StalkerContentType = 'itv' | 'vod' | 'series';
interface StalkerPlayableItem extends StalkerPortalItem {
type StalkerPlayableItem = StalkerPortalItem & {
cmd?: string;
has_files?: unknown;
}
interface StalkerPlayerStoreLike {
selectedContentType: () => StalkerContentType;
currentPlaylist: () => Playlist | null;
selectedItem: () => StalkerPlayableItem | null;
addToRecentlyViewed?: (item: Record<string, unknown>) => void;
}
interface StalkerResponse {
js?: {
error?: string;
cmd?: string;
data?: Array<{ id?: string | number }>;
account_info?: {
expire_date?: string | number;
};
};
}
};
/**
* Playback/link/player concern methods.
*/
export function withStalkerPlayer() {
const logger = createLogger('withStalkerPlayer');
const resolveCategoryId = (value: unknown, fallback: string): string => {
const normalized = String(value ?? '').trim();
return normalized || fallback;
};
const getSeriesRecentMetadata = (
selectedContentType: StalkerContentType
): {
category_id?: 'series';
is_series?: true;
} => {
if (selectedContentType !== 'series') {
return {};
}
return {
category_id: 'series',
is_series: true,
};
};
return signalStoreFeature(
withMethods(
(
@@ -89,179 +57,89 @@ export function withStalkerPlayer() {
translate = inject(TranslateService),
ngrxStore = inject(Store)
) => {
const storeState = store as unknown as StalkerPlayerStoreLike;
const fetchLinkToPlayInternal = async (
portalUrl: string,
macAddress: string,
cmd: string,
series?: number,
forcedContentType?: StalkerContentType
) => {
const normalizeCmdValue = (value: string): string => {
const trimmed = String(value ?? '').trim();
if (!trimmed) return '';
// Some portals prepend transport wrappers like:
// "ffmpeg http://...", "ffrt http://...", etc.
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;
};
const selectedContentType =
forcedContentType ?? storeState.selectedContentType();
const type = series ? 'vod' : selectedContentType;
// Always use create_link to get the tokenized streaming URL
// The server adds the required token for playback authorization
// Note: cmd is already transformed during item processing (has_files items)
const params = {
action: StalkerContentTypes[selectedContentType]
.getLink,
cmd: cmd,
type,
disable_ad: '0',
download: '0',
JsHttpRequest: '1-xml',
...(series ? { series: String(series) } : {}),
};
// Use makeAuthenticatedRequest for automatic retry on auth failure
const playlist = storeState.currentPlaylist();
let response: StalkerResponse;
if (playlist?.isFullStalkerPortal) {
// Full stalker portal - use authenticated request with retry
response =
await stalkerSession.makeAuthenticatedRequest(
playlist,
params
);
} else {
// Simple stalker portal - no auth needed
response = await dataService.sendIpcEvent(
STALKER_REQUEST,
{
url: portalUrl,
macAddress,
params,
}
);
}
// Check for server-side errors
if (response.js?.error) {
const errorMsg = response.js.error;
logger.error('Server error', errorMsg);
throw new Error(errorMsg);
}
let url = normalizeCmdValue(response.js.cmd as string);
// If cmd is empty, the content is not available
if (!url) {
throw new Error('nothing_to_play');
}
// Handle incomplete URLs - some portals return just query params or relative paths
if (
url &&
!url.startsWith('http://') &&
!url.startsWith('https://')
) {
// Extract base URL from portal URL
try {
const portalUrlObj = new URL(portalUrl);
// Get the stalker portal base path (e.g., /stalker_portal from /stalker_portal/server/load.php)
const pathParts = portalUrlObj.pathname.split('/');
// Find the stalker_portal or c directory and use that as base
let basePath = '';
for (let i = 0; i < pathParts.length; i++) {
if (
pathParts[i] === 'stalker_portal' ||
pathParts[i] === 'c' ||
pathParts[i] === 'portal'
) {
basePath =
'/' +
pathParts.slice(1, i + 1).join('/');
break;
}
}
// If url starts with ?, it's just query params
// Combine with the original cmd path to form the complete streaming URL
if (url.startsWith('?')) {
const normalizedCmd = normalizeCmdValue(cmd);
// The streaming URL is: portal origin + base path + original cmd path + token query
// e.g., http://portal.com + /stalker_portal + /media/12345.mpg + ?token=xxx
if (
normalizedCmd.startsWith('http://') ||
normalizedCmd.startsWith('https://')
) {
url = `${normalizedCmd}${url}`;
} else {
url = `${portalUrlObj.origin}${basePath}${normalizedCmd}${url}`;
}
} else if (url.startsWith('/')) {
// Relative path - prepend origin and base path
url = `${portalUrlObj.origin}${basePath}${url}`;
}
} catch {
// URL parsing failed, return as-is
}
}
return url;
const storeState = store as typeof store &
StalkerPlayerFeatureStoreContract;
const requestDeps = {
dataService,
stalkerSession,
};
const fetchMovieFileIdInternal = async (
movieId: string
): Promise<string | null> => {
const createRequestPlaylist = (
portalUrl: string,
macAddress: string
): PlaylistMeta => {
const playlist = storeState.currentPlaylist();
if (!playlist) return null;
const queryParams = {
action: StalkerPortalActions.GetOrderedList,
type: 'vod',
movie_id: movieId,
p: '1',
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,
};
};
let response: StalkerResponse;
if (playlist.isFullStalkerPortal) {
response =
await stalkerSession.makeAuthenticatedRequest(
playlist,
queryParams
const persistRecentlyViewed = (
playlistId: string,
recentItem: StalkerRecentlyViewedItem
): void => {
playlistService
.addPortalRecentlyViewed(playlistId, recentItem)
.subscribe((updatedPlaylist) => {
dispatchStalkerPlaylistMetaUpdate(
ngrxStore,
playlistId,
{
recentlyViewed:
updatedPlaylist?.recentlyViewed,
}
);
} else {
response = await dataService.sendIpcEvent(
STALKER_REQUEST,
{
url: playlist.portalUrl,
macAddress: playlist.macAddress,
params: queryParams,
}
);
});
};
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;
}
// Extract id from the first data item
if (response?.js?.data?.[0]?.id) {
const fileId = response.js.data[0].id;
return String(fileId);
const recentItem = buildRecentlyViewedItem(
item,
cmd,
cover,
title
);
if (typeof storeState.addToRecentlyViewed === 'function') {
storeState.addToRecentlyViewed(recentItem);
return;
}
return null;
persistRecentlyViewed(playlistId, recentItem);
};
const resolveVodPlaybackInternal = async (
@@ -272,57 +150,45 @@ export function withStalkerPlayer() {
episodeId?: number,
startTime?: number
): Promise<ResolvedPortalPlayback> => {
const item = storeState.selectedItem();
const item = storeState.selectedItem() as
| StalkerPlayableItem
| null
| undefined;
let cmdToUse = cmd ?? item?.cmd;
if (!cmdToUse) {
throw new Error('nothing_to_play');
}
// For items with has_files and relative path, we need to fetch the file id first
if (
item?.has_files !== undefined &&
cmdToUse &&
!cmdToUse.includes('://') &&
cmdToUse.includes('/media/') &&
!cmdToUse.includes('/media/file_')
) {
const fileId = await fetchMovieFileIdInternal(
normalizeStalkerEntityId(item.id)
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 playlist = storeState.currentPlaylist();
if (!playlist) {
throw new Error('nothing_to_play');
}
const streamUrl = await fetchLinkToPlayInternal(
playlist.portalUrl,
playlist.macAddress,
cmdToUse,
episodeNum
const streamUrl = await fetchStalkerPlaybackLink(
requestDeps,
{
playlist,
selectedContentType:
storeState.selectedContentType(),
cmd: cmdToUse,
series: episodeNum,
}
);
if (typeof storeState.addToRecentlyViewed === 'function') {
storeState.addToRecentlyViewed({
...item,
id: item?.id,
cmd: cmd,
cover: thumbnail,
title,
});
} else {
addToRecentlyViewedInternal(
item,
cmd,
thumbnail,
title
);
}
recordRecentlyViewed(item, cmd, thumbnail, title);
const isEpisode =
episodeNum !== undefined || episodeId !== undefined;
@@ -339,7 +205,6 @@ export function withStalkerPlayer() {
origin: playlist.origin,
contentInfo: {
playlistId: playlist._id,
// For episodes, use episodeId if provided, otherwise fall back to item.id
contentXtreamId:
isEpisode && episodeId
? episodeId
@@ -356,17 +221,25 @@ export function withStalkerPlayer() {
item: StalkerPlayableItem
): Promise<ResolvedPortalPlayback> => {
const playlist = storeState.currentPlaylist();
if (!playlist || !item?.cmd) {
if (
!playlist?.portalUrl ||
!playlist.macAddress ||
!item.cmd
) {
throw new Error('nothing_to_play');
}
const streamUrl = await fetchLinkToPlayInternal(
playlist.portalUrl,
playlist.macAddress,
item.cmd,
undefined,
'itv'
const streamUrl = await fetchStalkerPlaybackLink(
requestDeps,
{
playlist,
selectedContentType:
storeState.selectedContentType(),
cmd: item.cmd,
forcedContentType: 'itv',
}
);
const token = stalkerSession.getCachedToken(playlist._id);
const headers = buildStalkerExternalPlaybackHeaders(
playlist,
@@ -379,14 +252,12 @@ export function withStalkerPlayer() {
);
const portalOrigin = getStalkerPortalOrigin(playlist);
if (typeof storeState.addToRecentlyViewed === 'function') {
storeState.addToRecentlyViewed({
...item,
id: item.id,
cover: item.logo ?? item.cover,
title: item.o_name || item.name || item.title,
});
}
recordRecentlyViewed(
item,
item.cmd,
item.logo ?? item.cover,
item.o_name || item.name || item.title
);
return {
streamUrl,
@@ -406,47 +277,6 @@ export function withStalkerPlayer() {
};
};
const addToRecentlyViewedInternal = (
item: StalkerPlayableItem,
cmd?: string,
cover?: string,
title?: string
) => {
const playlistId = storeState.currentPlaylist()?._id;
if (!playlistId) return;
const selectedContentType =
storeState.selectedContentType();
const recentlyViewedItem: {
id: string;
title: string;
} & Record<string, unknown> = {
...item,
id: normalizeStalkerEntityId(item?.id),
cmd,
cover,
title,
category_id: resolveCategoryId(
item.category_id,
selectedContentType
),
...getSeriesRecentMetadata(selectedContentType),
added_at: Date.now(),
};
playlistService
.addPortalRecentlyViewed(playlistId, recentlyViewedItem)
.subscribe((updatedPlaylist) => {
ngrxStore.dispatch(
PlaylistActions.updatePlaylistMeta({
playlist: {
_id: playlistId,
recentlyViewed:
updatedPlaylist?.recentlyViewed,
} as PlaylistMeta,
})
);
});
};
return {
async fetchLinkToPlay(
portalUrl: string,
@@ -454,85 +284,46 @@ export function withStalkerPlayer() {
cmd: string,
series?: number
) {
return fetchLinkToPlayInternal(
portalUrl,
macAddress,
return fetchStalkerPlaybackLink(requestDeps, {
playlist: createRequestPlaylist(
portalUrl,
macAddress
),
selectedContentType:
storeState.selectedContentType(),
cmd,
series
);
series,
});
},
async getExpireDate() {
const params = {
type: 'account_info',
action: 'get_main_info',
JsHttpRequest: '1-xml',
};
const playlist = storeState.currentPlaylist();
if (!playlist) {
return 'Unknown';
}
try {
// Use makeAuthenticatedRequest for automatic retry on auth failure
const playlist = storeState.currentPlaylist();
let response: StalkerResponse;
if (!playlist) {
return 'Unknown';
}
if (playlist?.isFullStalkerPortal) {
// Full stalker portal - use authenticated request with retry
response =
await stalkerSession.makeAuthenticatedRequest(
playlist,
params
);
} else {
// Simple stalker portal - no auth needed
response = await dataService.sendIpcEvent(
STALKER_REQUEST,
{
url: playlist.portalUrl,
macAddress: playlist.macAddress,
params,
}
);
}
if (
response &&
response.js &&
response.js.account_info
) {
// Extract the expire date from the response
const expireDate =
response.js.account_info.expire_date;
const numericExpireDate = Number(expireDate);
// Convert timestamp to readable date if it's a unix timestamp
if (
expireDate &&
!Number.isNaN(numericExpireDate)
) {
const date = new Date(
numericExpireDate * 1000
); // Convert seconds to milliseconds
return date.toLocaleDateString();
}
return expireDate || 'Unknown';
}
return 'Unknown';
return await fetchStalkerExpireDate(
requestDeps,
playlist
);
} catch (error) {
logger.error('Failed to fetch expire date', error);
return 'Error fetching data';
}
},
/**
* Fetch movie files using get_ordered_list with movie_id parameter.
* This is needed for items with has_files property to get the correct video_id
* for the create_link request.
*/
async fetchMovieFileId(
movieId: string
): Promise<string | null> {
return fetchMovieFileIdInternal(movieId);
const playlist = storeState.currentPlaylist();
if (!playlist) {
return null;
}
return fetchStalkerMovieFileId(
requestDeps,
playlist,
movieId
);
},
async resolveVodPlayback(
cmd?: string,
@@ -556,15 +347,6 @@ export function withStalkerPlayer() {
): Promise<ResolvedPortalPlayback> {
return resolveItvPlaybackInternal(item);
},
/**
* Play VOD or episode content
* @param cmd The media command/path
* @param title Display title
* @param thumbnail Thumbnail URL
* @param episodeNum Episode number (for series param in API)
* @param episodeId Optional episode ID for playback tracking (defaults to item.id)
* @param startTime Optional start time in seconds for resume playback
*/
async createLinkToPlayVod(
cmd?: string,
title?: string,
@@ -590,14 +372,16 @@ export function withStalkerPlayer() {
} catch (error) {
logger.error('Failed to get playback URL', error);
const errorMessage =
error?.message === 'nothing_to_play'
error instanceof Error &&
error.message === 'nothing_to_play'
? translate.instant(
'PORTALS.CONTENT_NOT_AVAILABLE'
)
: translate.instant(
'PORTALS.PLAYBACK_ERROR'
);
snackBar.open(errorMessage, null, {
snackBar.open(errorMessage, undefined, {
duration: 3000,
});
}
@@ -2,20 +2,34 @@ import { inject } from '@angular/core';
import {
patchState,
signalStoreFeature,
withProps,
withMethods,
withProps,
withState,
} from '@ngrx/signals';
import { Playlist, PlaylistMeta, STALKER_REQUEST } from 'shared-interfaces';
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;
currentPlaylist: PlaylistMeta | undefined;
}
const initialPortalState: StalkerPortalState = {
@@ -42,14 +56,13 @@ export function withStalkerPortal() {
// Get token if it's a full stalker portal
let token: string | undefined;
let serialNumber: string | undefined;
if ((playlist as Playlist).isFullStalkerPortal) {
if (playlist.isFullStalkerPortal) {
try {
const result = await stalkerSession.ensureToken(
playlist as Playlist
toStalkerSessionPlaylist(playlist)
);
token = result.token ?? undefined;
serialNumber = (playlist as Playlist)
.stalkerSerialNumber;
serialNumber = playlist.stalkerSerialNumber;
} catch (error) {
logger.error('Failed to get stalker token', error);
}
@@ -73,7 +86,9 @@ export function withStalkerPortal() {
) => ({
async setCurrentPlaylist(playlist: PlaylistMeta | undefined) {
stalkerSession.setActiveWatchdogPlaylist(
(playlist as Playlist) || undefined
playlist
? toStalkerSessionPlaylist(playlist)
: undefined
);
patchState(store, { currentPlaylist: playlist });
@@ -87,13 +102,19 @@ export function withStalkerPortal() {
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 window.electron.dbGetPlaylist(playlistId);
await electronApi.dbGetPlaylist(playlistId);
if (!existing) {
// Create playlist in SQLite
await window.electron.dbCreatePlaylist({
await electronApi.dbCreatePlaylist({
id: playlistId,
name: playlist.title || '',
macAddress: playlist.macAddress || '',
@@ -1,14 +1,18 @@
import { inject } from '@angular/core';
import { signalStoreFeature, withMethods } from '@ngrx/signals';
import { Store } from '@ngrx/store';
import { PlaylistActions } from 'm3u-state';
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(): string;
selectedContentType(): StalkerContentType;
}
type RecentlyViewedPayload = StalkerPortalItem & {
@@ -16,25 +20,6 @@ type RecentlyViewedPayload = StalkerPortalItem & {
title?: string;
};
function resolveCategoryId(value: unknown, fallback: string): string {
const normalized = String(value ?? '').trim();
return normalized || fallback;
}
function getSeriesRecentMetadata(selectedContentType: string): {
category_id?: 'series';
is_series?: true;
} {
if (selectedContentType !== 'series') {
return {};
}
return {
category_id: 'series',
is_series: true,
};
}
/**
* Recently-viewed concern methods.
*/
@@ -47,35 +32,31 @@ export function withStalkerRecent() {
playlistService = inject(PlaylistsService),
ngrxStore = inject(Store)
) => {
const storeContext = store as unknown as RecentStoreContext;
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 = {
...item,
category_id: resolveCategoryId(
item.category_id,
selectedContentType
),
...getSeriesRecentMetadata(selectedContentType),
added_at: Date.now(),
id: item.id ?? item.stream_id ?? '',
title: item.title ?? item.name ?? item.o_name ?? '',
};
const recentItem = buildStalkerRecentlyViewedPayload(
item,
selectedContentType
);
playlistService
.addPortalRecentlyViewed(portalId, recentItem)
.subscribe((updatedPlaylist) => {
ngrxStore.dispatch(
PlaylistActions.updatePlaylistMeta({
playlist: {
_id: portalId,
recentlyViewed:
updatedPlaylist?.recentlyViewed,
} as PlaylistMeta,
})
dispatchStalkerPlaylistMetaUpdate(
ngrxStore,
portalId,
{
recentlyViewed:
updatedPlaylist?.recentlyViewed,
}
);
});
},
@@ -84,18 +65,21 @@ export function withStalkerRecent() {
onComplete?: () => void
) {
const portalId = storeContext.currentPlaylist()?._id;
if (!portalId) {
return;
}
playlistService
.removeFromPortalRecentlyViewed(portalId, itemId)
.subscribe({
next: (updatedPlaylist) => {
ngrxStore.dispatch(
PlaylistActions.updatePlaylistMeta({
playlist: {
_id: portalId,
recentlyViewed:
updatedPlaylist?.recentlyViewed,
} as PlaylistMeta,
})
dispatchStalkerPlaylistMetaUpdate(
ngrxStore,
portalId,
{
recentlyViewed:
updatedPlaylist?.recentlyViewed,
}
);
onComplete?.();
},
@@ -0,0 +1,36 @@
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('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');
});
});
@@ -1,19 +1,12 @@
import { computed } from '@angular/core';
import {
patchState,
signalStoreFeature,
withMethods,
withState,
} from '@ngrx/signals';
import { StalkerCategoryItem, StalkerVodSource } from '../../models';
import { StalkerVodSource } from '../../models';
import { normalizeStalkerEntityId } from '../../stalker-vod.utils';
interface SelectionStoreContext {
vodCategories(): StalkerCategoryItem[];
seriesCategories(): StalkerCategoryItem[];
itvCategories(): StalkerCategoryItem[];
}
/**
* Selection/pagination/search feature state.
*/
@@ -101,49 +94,6 @@ export function withStalkerSelection() {
selectedItem: undefined,
});
},
/** getters */
getSelectedCategory: computed(() => {
const storeContext = store as unknown as SelectionStoreContext;
const categoryId = store.selectedCategoryId();
if (!categoryId) {
return {
id: 0,
category_name: 'All Items',
type: store.selectedContentType(),
};
}
// Get categories based on content type
const contentType = store.selectedContentType();
let categories: StalkerCategoryItem[] = [];
const readCategories = (
getterName:
| 'vodCategories'
| 'seriesCategories'
| 'itvCategories'
) =>
typeof storeContext[getterName] === 'function'
? storeContext[getterName]()
: [];
if (contentType === 'vod') {
categories = readCategories('vodCategories');
} else if (contentType === 'series') {
categories = readCategories('seriesCategories');
} else if (contentType === 'itv') {
categories = readCategories('itvCategories');
}
return (
categories.find(
(category) =>
String(category.category_id) === String(categoryId)
) || {
category_id: categoryId,
category_name: '',
type: contentType,
}
);
}),
}))
);
}
@@ -7,22 +7,23 @@ import {
withProps,
withState,
} from '@ngrx/signals';
import { createLogger } from '@iptvnator/portal/shared/util';
import { DataService } from 'services';
import { Playlist, STALKER_REQUEST, StalkerPortalActions } from 'shared-interfaces';
import { StalkerPortalActions } from 'shared-interfaces';
import {
StalkerSeason,
StalkerVodSource,
StalkerVodSeriesEpisode,
StalkerVodSeriesSeason,
} from '../../models';
import { StalkerSessionService } from '../../stalker-session.service';
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';
import { createLogger } from '@iptvnator/portal/shared/util';
/**
* Regular-series and VOD-series feature state.
@@ -40,11 +41,6 @@ const initialSeriesState: StalkerSeriesState = {
selectedVodSeriesSeasonId: undefined,
};
interface ResourceState<T> {
value(): T;
isLoading(): boolean;
}
interface StalkerSeriesResponse<T> {
js?:
| {
@@ -53,14 +49,7 @@ interface StalkerSeriesResponse<T> {
| T[];
}
interface StalkerSeriesStoreContext {
selectedSerialId(): string | undefined;
currentPlaylist(): Playlist | undefined;
selectedItem(): StalkerVodSource | null | undefined;
selectedContentType(): 'vod' | 'series' | 'itv';
serialSeasonsResource: ResourceState<StalkerSeason[]>;
vodSeriesSeasonsResource: ResourceState<StalkerVodSeriesSeason[]>;
}
type StalkerSeriesStoreContext = StalkerSeriesFeatureStoreContract;
function extractSeriesItems<T>(response: StalkerSeriesResponse<T>): T[] {
if (Array.isArray(response?.js)) {
@@ -74,13 +63,18 @@ function extractSeriesItems<T>(response: StalkerSeriesResponse<T>): T[] {
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');
const toMovieId = (value: unknown): string => {
const raw = String(value ?? '').trim();
if (!raw) return '';
return raw.includes(':') ? raw.split(':')[0] : raw;
};
return signalStoreFeature(
withState<StalkerSeriesState>(initialSeriesState),
withProps(
@@ -89,151 +83,133 @@ export function withStalkerSeries() {
dataService = inject(DataService),
stalkerSession = inject(StalkerSessionService)
) => {
const storeContext =
store as unknown as StalkerSeriesStoreContext;
const storeContext = store as typeof store &
StalkerSeriesStoreContext;
const requestDeps = {
dataService,
stalkerSession,
};
return {
serialSeasonsResource: resource({
params: () => ({
itemId: storeContext.selectedSerialId(),
}),
loader: async ({ params }): Promise<StalkerSeason[]> => {
const movieId = toMovieId(params.itemId);
// Guard: ensure currentPlaylist and itemId are available
if (!storeContext.currentPlaylist() || !movieId) {
return [];
}
const playlist = storeContext.currentPlaylist() as Playlist;
const queryParams = {
action: StalkerContentTypes.series.getContentAction,
type: 'series',
movie_id: movieId,
};
// Use makeAuthenticatedRequest for automatic retry on auth failure
let response: StalkerSeriesResponse<StalkerSeason>;
if (playlist.isFullStalkerPortal) {
// Full stalker portal - use authenticated request with retry
response = await stalkerSession.makeAuthenticatedRequest<StalkerSeriesResponse<StalkerSeason>>(
playlist,
queryParams
);
} else {
// Simple stalker portal - no auth needed
response = await dataService.sendIpcEvent<StalkerSeriesResponse<StalkerSeason>>(
STALKER_REQUEST,
{
url: playlist.portalUrl,
macAddress: playlist.macAddress,
params: queryParams,
}
);
}
const seasons = extractSeriesItems(response);
if (seasons.length === 0) {
logger.warn('Invalid seasons response', response);
return [];
}
return sortByNumericValue(seasons);
},
}),
/**
* Resource to fetch seasons for VOD items that are actually series (is_series=1)
* Used for Ministra plugin where VOD items can contain series/seasons
*/
vodSeriesSeasonsResource: resource({
params: () => ({
selectedItem: storeContext.selectedItem(),
selectedContentType: storeContext.selectedContentType(),
}),
loader: async ({
params,
}): Promise<StalkerVodSeriesSeason[]> => {
const item = params.selectedItem;
logger.debug('vodSeriesSeasonsResource loader called', {
item,
isSeries: item?.is_series,
params: () => ({
itemId: storeContext.selectedSerialId(),
currentPlaylist: storeContext.currentPlaylist(),
});
}),
loader: async ({
params,
}): Promise<StalkerSeason[]> => {
const playlist = params.currentPlaylist;
const movieId = toMovieId(params.itemId);
// Only fetch if item is a VOD series (has is_series flag)
if (
!storeContext.currentPlaylist() ||
params.selectedContentType !== 'vod' ||
!item ||
!item.is_series
) {
logger.debug(
'vodSeriesSeasonsResource skipped - conditions not met'
);
return [];
}
if (!playlist || !movieId) {
return [];
}
const playlist = storeContext.currentPlaylist() as Playlist;
const queryParams = {
action: StalkerPortalActions.GetOrderedList,
type: 'vod',
movie_id: item.id,
p: '1',
};
const response = await executeStalkerRequest<
StalkerSeriesResponse<StalkerSeason>
>(requestDeps, playlist, {
action: StalkerContentTypes.series
.getContentAction,
type: 'series',
movie_id: movieId,
});
let response: StalkerSeriesResponse<StalkerVodSeriesSeason>;
if (playlist.isFullStalkerPortal) {
response = await stalkerSession.makeAuthenticatedRequest<StalkerSeriesResponse<StalkerVodSeriesSeason>>(
playlist,
queryParams
const seasons = extractSeriesItems(response);
if (seasons.length === 0) {
logger.warn(
'Invalid seasons response',
response
);
} else {
response = await dataService.sendIpcEvent<StalkerSeriesResponse<StalkerVodSeriesSeason>>(
STALKER_REQUEST,
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',
{
url: playlist.portalUrl,
macAddress: playlist.macAddress,
params: queryParams,
item: selectedItem,
isSeries: selectedItem?.is_series,
currentPlaylist,
}
);
}
const seasonItems = extractSeriesItems(response);
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 [];
}
if (seasonItems.length === 0) {
logger.debug(
'vodSeriesSeasonsResource - no response data'
'vodSeriesSeasonsResource response data',
seasonItems
);
return [];
}
logger.debug('vodSeriesSeasonsResource response data', seasonItems);
const seasons = seasonItems.filter(
(item) => item.is_season === true
);
logger.debug(
'vodSeriesSeasonsResource filtered seasons',
seasons
);
// Filter for season items (is_season: true)
const seasons = seasonItems.filter(
(item: StalkerVodSeriesSeason) =>
item.is_season === true
);
logger.debug(
'vodSeriesSeasonsResource filtered seasons',
seasons
);
return sortVodSeriesSeasonsByNumber(seasons);
},
return sortVodSeriesSeasonsByNumber(seasons);
},
}),
};
}
),
withComputed((store) => {
const storeContext = store as unknown as StalkerSeriesStoreContext;
const storeContext = store as typeof store &
StalkerSeriesStoreContext;
return {
/** serials */
getSerialSeasonsResource: computed(() =>
storeContext.serialSeasonsResource.value()
),
isSerialSeasonsLoading: computed(() =>
storeContext.serialSeasonsResource.isLoading()
),
/** VOD series (Ministra plugin is_series=1) */
getVodSeriesSeasonsResource: computed(() =>
storeContext.vodSeriesSeasonsResource.value()
),
@@ -248,66 +224,44 @@ export function withStalkerSeries() {
dataService = inject(DataService),
stalkerSession = inject(StalkerSessionService)
) => {
const storeContext =
store as unknown as Pick<
StalkerSeriesStoreContext,
'currentPlaylist'
>;
const storeContext = store as typeof store &
Pick<StalkerSeriesStoreContext, 'currentPlaylist'>;
const requestDeps = {
dataService,
stalkerSession,
};
return {
/**
* Fetch episodes for a VOD series season (Ministra plugin)
* @param videoId The video_id from the season item
* @param seasonId The season id
* @returns Array of episode items
*/
async fetchVodSeriesEpisodes(
videoId: string,
seasonId: string
): Promise<StalkerVodSeriesEpisode[]> {
const playlist = storeContext.currentPlaylist() as Playlist;
if (!playlist) return [];
const playlist = storeContext.currentPlaylist();
if (!playlist) {
return [];
}
const queryParams = {
const response = await executeStalkerRequest<
StalkerSeriesResponse<StalkerVodSeriesEpisode>
>(requestDeps, playlist, {
action: StalkerPortalActions.GetOrderedList,
type: 'vod',
movie_id: videoId,
season_id: seasonId,
p: '1',
};
let response: StalkerSeriesResponse<StalkerVodSeriesEpisode>;
if (playlist.isFullStalkerPortal) {
response = await stalkerSession.makeAuthenticatedRequest<StalkerSeriesResponse<StalkerVodSeriesEpisode>>(
playlist,
queryParams
);
} else {
response = await dataService.sendIpcEvent<StalkerSeriesResponse<StalkerVodSeriesEpisode>>(
STALKER_REQUEST,
{
url: playlist.portalUrl,
macAddress: playlist.macAddress,
params: queryParams,
}
);
}
});
const episodeItems = extractSeriesItems(response);
if (episodeItems.length === 0) {
return [];
}
// Filter for episode items (is_episode: true) and sort by series_number
const episodes = sortEpisodesByNumber(
episodeItems.filter(
(item: StalkerVodSeriesEpisode) =>
item.is_episode === true
(item) => item.is_episode === true
)
);
// Store episodes and selected season
patchState(store, {
vodSeriesEpisodes: episodes,
selectedVodSeriesSeasonId: seasonId,
@@ -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'> {}
@@ -1,3 +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,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,
});
}