refactor(stalker): stalker store split to feature-based composition

- Implemented `withStalkerEpg` for fetching channel EPG data.
- Added `withStalkerFavorites` for managing favorite items.
- Created `withStalkerPlayer` for playback functionalities including link generation and error handling.
- Developed `withStalkerPortal` for managing portal state and requests.
- Introduced `withStalkerRecent` for handling recently viewed items.
- Added `withStalkerSelection` for managing selection state and pagination.
- Implemented `withStalkerSeries` for managing series and episodes, including fetching and sorting functionalities.
- Created utility functions for content mapping and sorting.
- Updated index files to export new features and utilities.
This commit is contained in:
4gray committed 2026-02-14 15:42:46 +01:00
1 parent f0be21486d
commit 91aa6c02a0
14 files changed
+1592 -1202

No files matched your search

File diff suppressed because it is too large. Load diff
@@ -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,326 @@
import { computed, inject, resource } from '@angular/core';
import {
patchState,
signalStoreFeature,
withComputed,
withMethods,
withProps,
withState,
} from '@ngrx/signals';
import { DataService, StalkerSessionService } from 'services';
import {
Playlist,
STALKER_REQUEST,
StalkerPortalActions,
} from 'shared-interfaces';
import {
StalkerCategoryItem,
StalkerContentItem,
StalkerItvChannel,
StalkerVodSource,
} from '../../models';
import { StalkerContentTypes } from '../../stalker-content-types';
import { createLogger } from '../../../shared/utils/logger';
import { toStalkerContentItem, toStalkerItvChannel } from '../utils';
/**
* Content/categories/channels feature state.
*/
export interface StalkerContentState {
totalCount: number;
vodCategories: StalkerCategoryItem[];
seriesCategories: StalkerCategoryItem[];
itvCategories: StalkerCategoryItem[];
hasMoreChannels: boolean;
itvChannels: StalkerItvChannel[];
}
const initialContentState: StalkerContentState = {
totalCount: 0,
vodCategories: [],
seriesCategories: [],
itvCategories: [],
hasMoreChannels: false,
itvChannels: [],
};
export function withStalkerContent() {
const logger = createLogger('withStalkerContent');
return signalStoreFeature(
withState<StalkerContentState>(initialContentState),
withProps(
(
store,
dataService = inject(DataService),
stalkerSession = inject(StalkerSessionService)
) => ({
getCategoryResource: resource({
params: () => ({
contentType: (store as any).selectedContentType(),
action: StalkerPortalActions.GetCategories,
currentPlaylist: (store as any).currentPlaylist(),
}),
loader: async ({
params,
}): Promise<StalkerCategoryItem[]> => {
if (params.currentPlaylist === undefined) 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: any;
if (playlist.isFullStalkerPortal) {
// Full stalker portal - use authenticated request with retry
response =
await stalkerSession.makeAuthenticatedRequest(
playlist,
queryParams
);
} else {
// Simple stalker portal - no auth needed
response = await dataService.sendIpcEvent(
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)
);
patchState(store, {
[`${params.contentType}Categories`]: categories,
});
return categories;
},
}),
getContentResource: resource({
params: () => ({
contentType: (store as any).selectedContentType(),
category: (store as any).selectedCategoryId(),
action: StalkerPortalActions.GetOrderedList,
search: (store as any).searchPhrase(),
pageIndex: (store as any).page() + 1,
}),
loader: async ({
params,
}): Promise<StalkerContentItem[] | undefined> => {
if (
!params.category ||
params.category === null ||
params.category === ''
) {
return Promise.resolve(undefined);
}
const currentPlaylist = (store as any).currentPlaylist;
// Guard: ensure currentPlaylist is available (may not be during deep link init)
if (!currentPlaylist() || !currentPlaylist().portalUrl) {
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: any;
if (playlist.isFullStalkerPortal) {
// Full stalker portal - use authenticated request with retry
response =
await stalkerSession.makeAuthenticatedRequest(
playlist,
queryParams
);
} else {
// Simple stalker portal - no auth needed
response = await dataService.sendIpcEvent(
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 ((store as any).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,
],
});
}
// Update hasMoreItems based on total count and current items
const totalLoaded = store.itvChannels().length;
patchState(store, {
hasMoreChannels:
totalLoaded < (response.js.total_items ?? 0),
});
}
return newItems;
},
}),
})
),
withComputed((store) => {
const storeAny = store as any;
return {
getTotalPages: computed(() => {
return Math.ceil(store.totalCount() / storeAny.limit());
}),
getSelectedCategoryName: computed(() => {
const type = storeAny.selectedContentType();
const selectedCategoryId = storeAny.selectedCategoryId();
if (!selectedCategoryId) return '';
let categories = [];
if (type === 'vod') {
categories = store.vodCategories();
} else if (type === 'series') {
categories = store.seriesCategories();
} else if (type === 'itv') {
categories = store.itvCategories();
}
const category = categories.find(
(cat) =>
String(cat.category_id) ===
String(selectedCategoryId)
);
return category ? category.category_name : '';
}),
/** category content */
getPaginatedContent: computed(() =>
storeAny.getContentResource.value()
),
isPaginatedContentLoading: computed(() =>
storeAny.getContentResource.isLoading()
),
isPaginatedContentFailed: computed(() =>
storeAny.getContentResource.error()
),
/** category resource */
getCategoryResource: computed(() =>
storeAny.getCategoryResource.value()
),
isCategoryResourceLoading: computed(() =>
storeAny.getCategoryResource.isLoading()
),
isCategoryResourceFailed: computed(() =>
storeAny.getCategoryResource.error()
),
};
}),
withMethods((store) => ({
setCategories(
type: 'vod' | 'series' | 'itv',
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 });
}
},
resetCategories() {
patchState(store, {
vodCategories: [],
seriesCategories: [],
itvCategories: [],
});
},
setItvChannels(channels: StalkerItvChannel[]) {
patchState(store, { itvChannels: channels });
},
}))
);
}
@@ -0,0 +1,78 @@
import { inject } from '@angular/core';
import { signalStoreFeature, withMethods } from '@ngrx/signals';
import { DataService, StalkerSessionService } from 'services';
import {
EpgItem,
Playlist,
STALKER_REQUEST,
StalkerPortalActions,
} from 'shared-interfaces';
/**
* EPG concern methods.
*/
export function withStalkerEpg() {
return signalStoreFeature(
withMethods(
(
store,
dataService = inject(DataService),
stalkerSession = inject(StalkerSessionService)
) => {
const storeAny = store as any;
return {
async fetchChannelEpg(
channelId: number | string,
size = 10
): Promise<EpgItem[]> {
const playlist = storeAny.currentPlaylist() as Playlist;
if (!playlist) return [];
const queryParams: Record<string, string> = {
action: StalkerPortalActions.GetShortEpg,
type: 'itv',
ch_id: String(channelId),
size: String(size),
};
let response: any;
if (playlist.isFullStalkerPortal) {
response =
await stalkerSession.makeAuthenticatedRequest(
playlist,
queryParams
);
} else {
response = await dataService.sendIpcEvent(
STALKER_REQUEST,
{
url: playlist.portalUrl,
macAddress: playlist.macAddress,
params: queryParams,
}
);
}
const epgData = response?.js?.data ?? response?.js ?? [];
const items = Array.isArray(epgData) ? epgData : [];
return items.map((item) => ({
id: String(item.id ?? ''),
epg_id: '',
title: item.name ?? '',
description: item.descr ?? '',
lang: '',
start: item.time ?? '',
end: item.time_to ?? '',
stop: item.time_to ?? '',
channel_id: String(item.ch_id ?? channelId),
start_timestamp: String(item.start_timestamp ?? ''),
stop_timestamp: String(item.stop_timestamp ?? ''),
}));
},
};
}
)
);
}
@@ -0,0 +1,72 @@
import { inject } from '@angular/core';
import { MatSnackBar } from '@angular/material/snack-bar';
import { withMethods, signalStoreFeature } from '@ngrx/signals';
import { TranslateService } from '@ngx-translate/core';
import { PlaylistsService } from 'services';
/**
* Favorites concern methods.
*/
export function withStalkerFavorites() {
return signalStoreFeature(
withMethods(
(
store,
playlistService = inject(PlaylistsService),
snackBar = inject(MatSnackBar),
translate = inject(TranslateService)
) => {
const storeAny = store as any;
return {
addToFavorites(item: any, onDone?: () => void) {
playlistService
.addPortalFavorite(
storeAny.currentPlaylist()?._id,
{
...item,
category_id:
storeAny.selectedContentType(),
added_at: Date.now(),
id: item.stream_id ?? item.id,
}
)
.subscribe(() => {
snackBar.open(
translate.instant(
'PORTALS.ADDED_TO_FAVORITES'
),
null,
{
duration: 1000,
}
);
onDone?.();
});
},
removeFromFavorites(
favoriteId: string,
onDone?: () => void
) {
playlistService
.removeFromPortalFavorites(
storeAny.currentPlaylist()?._id,
favoriteId
)
.subscribe(() => {
snackBar.open(
translate.instant(
'PORTALS.REMOVED_FROM_FAVORITES'
),
null,
{
duration: 1000,
}
);
onDone?.();
});
},
};
}
)
);
}
@@ -0,0 +1,390 @@
import { inject } from '@angular/core';
import { MatSnackBar } from '@angular/material/snack-bar';
import { signalStoreFeature, withMethods } from '@ngrx/signals';
import { TranslateService } from '@ngx-translate/core';
import { DataService, PlaylistsService, StalkerSessionService } from 'services';
import { Playlist, STALKER_REQUEST, StalkerPortalActions } from 'shared-interfaces';
import { PlayerService } from '../../../services/player.service';
import { ContentType } from '../../../xtream/content-type.enum';
import { StalkerContentTypes } from '../../stalker-content-types';
import {
normalizeStalkerEntityId,
normalizeStalkerEntityIdAsNumber,
} from '../../stalker-vod.utils';
import { createLogger } from '../../../shared/utils/logger';
/**
* Playback/link/player concern methods.
*/
export function withStalkerPlayer() {
const logger = createLogger('withStalkerPlayer');
return signalStoreFeature(
withMethods(
(
store,
dataService = inject(DataService),
playlistService = inject(PlaylistsService),
playerService = inject(PlayerService),
stalkerSession = inject(StalkerSessionService),
snackBar = inject(MatSnackBar),
translate = inject(TranslateService)
) => {
const storeAny = store as any;
const fetchLinkToPlayInternal = async (
portalUrl: string,
macAddress: string,
cmd: string,
series?: number
) => {
const type = series
? ContentType.VODS
: storeAny.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[
storeAny.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 = storeAny.currentPlaylist() as Playlist;
let response: any;
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 = response.js.cmd as string;
// If cmd is empty, the content is not available
if (!url) {
throw new Error('nothing_to_play');
}
if (url.startsWith('ffmpeg')) {
url = url.split(' ')[1];
}
// 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('?')) {
// 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
url = `${portalUrlObj.origin}${basePath}${cmd}${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 fetchMovieFileIdInternal = async (
movieId: string
): Promise<string | null> => {
const playlist = storeAny.currentPlaylist() as Playlist;
if (!playlist) return null;
const queryParams = {
action: StalkerPortalActions.GetOrderedList,
type: 'vod',
movie_id: movieId,
p: '1',
};
let response: any;
if (playlist.isFullStalkerPortal) {
response = await stalkerSession.makeAuthenticatedRequest(
playlist,
queryParams
);
} else {
response = await dataService.sendIpcEvent(
STALKER_REQUEST,
{
url: playlist.portalUrl,
macAddress: playlist.macAddress,
params: queryParams,
}
);
}
// Extract id from the first data item
if (response?.js?.data?.[0]?.id) {
const fileId = response.js.data[0].id;
return String(fileId);
}
return null;
};
const addToRecentlyViewedInternal = (
item: any,
cmd?: string,
cover?: string,
title?: string
) => {
const playlistId = storeAny.currentPlaylist()?._id;
if (!playlistId) return;
playlistService
.addPortalRecentlyViewed(playlistId, {
...item,
id: item?.id,
cmd,
cover,
title,
category_id: storeAny.selectedContentType(),
added_at: Date.now(),
})
.subscribe();
};
return {
async fetchLinkToPlay(
portalUrl: string,
macAddress: string,
cmd: string,
series?: number
) {
return fetchLinkToPlayInternal(
portalUrl,
macAddress,
cmd,
series
);
},
async getExpireDate() {
const params = {
type: 'account_info',
action: 'get_main_info',
JsHttpRequest: '1-xml',
};
try {
// Use makeAuthenticatedRequest for automatic retry on auth failure
const playlist = storeAny.currentPlaylist() as Playlist;
let response: any;
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;
// Convert timestamp to readable date if it's a unix timestamp
if (expireDate && !isNaN(expireDate)) {
const date = new Date(expireDate * 1000); // Convert seconds to milliseconds
return date.toLocaleDateString();
}
return expireDate || 'Unknown';
}
return 'Unknown';
} 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);
},
/**
* 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,
thumbnail?: string,
episodeNum?: number,
episodeId?: number,
startTime?: number
) {
try {
const item = storeAny.selectedItem();
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)
);
if (fileId) {
cmdToUse = `/media/file_${fileId}.mpg`;
}
}
const playlist = storeAny.currentPlaylist();
const url = await fetchLinkToPlayInternal(
playlist.portalUrl,
playlist.macAddress,
cmdToUse,
episodeNum
);
if (typeof storeAny.addToRecentlyViewed === 'function') {
storeAny.addToRecentlyViewed({
...item,
id: item?.id,
cmd: cmd,
cover: thumbnail,
title,
});
} else {
addToRecentlyViewedInternal(
item,
cmd,
thumbnail,
title
);
}
const isEpisode =
episodeNum !== undefined ||
episodeId !== undefined;
const selectedItemId =
normalizeStalkerEntityIdAsNumber(item?.id) ?? 0;
const contentInfo = {
playlistId: playlist._id,
// For episodes, use episodeId if provided, otherwise fall back to item.id
contentXtreamId:
isEpisode && episodeId
? episodeId
: selectedItemId,
contentType: isEpisode ? 'episode' : 'vod',
seriesXtreamId: isEpisode
? selectedItemId
: undefined,
};
playerService.openPlayer(
url,
title,
thumbnail,
true,
false,
playlist?.userAgent,
playlist?.referrer,
playlist?.origin,
contentInfo,
startTime
);
} catch (error) {
logger.error('Failed to get playback URL', error);
const errorMessage =
error?.message === 'nothing_to_play'
? translate.instant(
'PORTALS.CONTENT_NOT_AVAILABLE'
)
: translate.instant(
'PORTALS.PLAYBACK_ERROR'
);
snackBar.open(errorMessage, null, {
duration: 3000,
});
}
},
};
}
)
);
}
@@ -0,0 +1,105 @@
import { inject } from '@angular/core';
import {
patchState,
signalStoreFeature,
withProps,
withMethods,
withState,
} from '@ngrx/signals';
import { Playlist, PlaylistMeta, STALKER_REQUEST } from 'shared-interfaces';
import { DataService, StalkerSessionService } from 'services';
import { createLogger } from '../../../shared/utils/logger';
/**
* Portal/session state and methods.
*/
export interface StalkerPortalState {
currentPlaylist: PlaylistMeta;
}
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, any>
) {
// Get token if it's a full stalker portal
let token: string | undefined;
let serialNumber: string | undefined;
if ((playlist as Playlist).isFullStalkerPortal) {
try {
const result = await stalkerSession.ensureToken(
playlist as Playlist
);
token = result.token ?? undefined;
serialNumber = (playlist as 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)) => ({
async setCurrentPlaylist(playlist: PlaylistMeta | 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 playlistId = String(playlist._id);
// Check if playlist exists in SQLite
const existing =
await window.electron.dbGetPlaylist(playlistId);
if (!existing) {
// Create playlist in SQLite
await window.electron.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,49 @@
import { inject } from '@angular/core';
import { signalStoreFeature, withMethods } from '@ngrx/signals';
import { PlaylistsService } from 'services';
import { createLogger } from '../../../shared/utils/logger';
/**
* Recently-viewed concern methods.
*/
export function withStalkerRecent() {
const logger = createLogger('withStalkerRecent');
return signalStoreFeature(
withMethods((store, playlistService = inject(PlaylistsService)) => {
const storeAny = store as any;
return {
addToRecentlyViewed(item: any) {
playlistService
.addPortalRecentlyViewed(
storeAny.currentPlaylist()?._id,
{
...item,
category_id: storeAny.selectedContentType(),
added_at: Date.now(),
}
)
.subscribe();
},
removeFromRecentlyViewed(
itemId: string | number,
onComplete?: () => void
) {
playlistService
.removeFromPortalRecentlyViewed(
storeAny.currentPlaylist()?._id,
itemId
)
.subscribe({
next: () => onComplete?.(),
error: (error) =>
logger.error(
'Failed to remove item from recently viewed',
error
),
});
},
};
})
);
}
@@ -0,0 +1,135 @@
import {
patchState,
signalStoreFeature,
withMethods,
withState,
} from '@ngrx/signals';
import { computed } from '@angular/core';
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) {
patchState(store, {
selectedCategoryId:
id !== null && id !== undefined
? String(id)
: null,
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) {
patchState(store, { limit });
},
setPage(page: number) {
patchState(store, { page });
},
setSearchPhrase(phrase: string) {
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,
});
},
/** getters */
getSelectedCategory: computed(() => {
const storeAny = store as any;
const categoryId = store.selectedCategoryId();
if (!categoryId) {
return {
id: 0,
name: 'All Items',
type: store.selectedContentType(),
};
}
// Get categories based on content type
const contentType = store.selectedContentType();
let categories: any[] = [];
const readCategories = (
getterName: 'vodCategories' | 'seriesCategories' | 'itvCategories'
) =>
typeof storeAny[getterName] === 'function'
? storeAny[getterName]()
: [];
if (contentType === 'vod') {
categories = readCategories('vodCategories');
} else if (contentType === 'series') {
categories = readCategories('seriesCategories');
} else if (contentType === 'itv') {
categories = readCategories('itvCategories');
}
return (
categories.find(
(c: any) =>
String(c.category_id) === String(categoryId)
) || { id: categoryId, name: '', type: contentType }
);
}),
}))
);
}
@@ -0,0 +1,275 @@
import { computed, inject } from '@angular/core';
import {
patchState,
signalStoreFeature,
withComputed,
withMethods,
withProps,
withState,
} from '@ngrx/signals';
import { DataService, StalkerSessionService } from 'services';
import { Playlist, STALKER_REQUEST, StalkerPortalActions } from 'shared-interfaces';
import {
StalkerVodSeriesEpisode,
StalkerVodSeriesSeason,
} from '../../models';
import { sortEpisodesByNumber } from '../utils';
import { resource } from '@angular/core';
import { StalkerContentTypes } from '../../stalker-content-types';
import { sortByNumericValue, sortVodSeriesSeasonsByNumber } from '../utils';
import { createLogger } from '../../../shared/utils/logger';
/**
* 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,
};
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(
(
store,
dataService = inject(DataService),
stalkerSession = inject(StalkerSessionService)
) => ({
serialSeasonsResource: resource({
params: () => ({
itemId: (store as any).selectedSerialId(),
}),
loader: async ({ params }) => {
const movieId = toMovieId(params.itemId);
// Guard: ensure currentPlaylist and itemId are available
if (!(store as any).currentPlaylist() || !movieId) {
return [];
}
const playlist = (store as any).currentPlaylist() as Playlist;
const queryParams = {
action: StalkerContentTypes.series.getContentAction,
type: 'series',
movie_id: movieId,
};
// Use makeAuthenticatedRequest for automatic retry on auth failure
let response: any;
if (playlist.isFullStalkerPortal) {
// Full stalker portal - use authenticated request with retry
response =
await stalkerSession.makeAuthenticatedRequest(
playlist,
queryParams
);
} else {
// Simple stalker portal - no auth needed
response = await dataService.sendIpcEvent(
STALKER_REQUEST,
{
url: playlist.portalUrl,
macAddress: playlist.macAddress,
params: queryParams,
}
);
}
// Guard: ensure response has expected structure
if (!response?.js?.data) {
logger.warn('Invalid seasons response', response);
return [];
}
return sortByNumericValue(response.js.data);
},
}),
/**
* 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: (store as any).selectedItem(),
selectedContentType:
(store as any).selectedContentType?.() ?? 'vod',
}),
loader: async ({
params,
}): Promise<StalkerVodSeriesSeason[]> => {
const item = params.selectedItem;
logger.debug('vodSeriesSeasonsResource loader called', {
item,
isSeries: item?.is_series,
currentPlaylist: (store as any).currentPlaylist(),
});
// Only fetch if item is a VOD series (has is_series flag)
if (
!(store as any).currentPlaylist() ||
params.selectedContentType !== 'vod' ||
!item ||
!item.is_series
) {
logger.debug(
'vodSeriesSeasonsResource skipped - conditions not met'
);
return [];
}
const playlist = (store as any).currentPlaylist() as Playlist;
const queryParams = {
action: StalkerPortalActions.GetOrderedList,
type: 'vod',
movie_id: item.id,
p: '1',
};
let response: any;
if (playlist.isFullStalkerPortal) {
response =
await stalkerSession.makeAuthenticatedRequest(
playlist,
queryParams
);
} else {
response = await dataService.sendIpcEvent(
STALKER_REQUEST,
{
url: playlist.portalUrl,
macAddress: playlist.macAddress,
params: queryParams,
}
);
}
if (!response?.js?.data) {
logger.debug(
'vodSeriesSeasonsResource - no response data'
);
return [];
}
logger.debug(
'vodSeriesSeasonsResource response data',
response.js.data
);
// Filter for season items (is_season: true)
const seasons = response.js.data.filter(
(item: StalkerVodSeriesSeason) =>
item.is_season === true
);
logger.debug(
'vodSeriesSeasonsResource filtered seasons',
seasons
);
return sortVodSeriesSeasonsByNumber(seasons);
},
}),
})
),
withComputed((store) => {
const storeAny = store as any;
return {
/** serials */
getSerialSeasonsResource: computed(() =>
storeAny.serialSeasonsResource.value()
),
isSerialSeasonsLoading: computed(() =>
storeAny.serialSeasonsResource.isLoading()
),
/** VOD series (Ministra plugin is_series=1) */
getVodSeriesSeasonsResource: computed(() =>
storeAny.vodSeriesSeasonsResource.value()
),
isVodSeriesSeasonsLoading: computed(() =>
storeAny.vodSeriesSeasonsResource.isLoading()
),
};
}),
withMethods(
(
store,
dataService = inject(DataService),
stalkerSession = inject(StalkerSessionService)
) => {
const storeAny = store as any;
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 = storeAny.currentPlaylist() as Playlist;
if (!playlist) return [];
const queryParams = {
action: StalkerPortalActions.GetOrderedList,
type: 'vod',
movie_id: videoId,
season_id: seasonId,
p: '1',
};
let response: any;
if (playlist.isFullStalkerPortal) {
response =
await stalkerSession.makeAuthenticatedRequest(
playlist,
queryParams
);
} else {
response = await dataService.sendIpcEvent(
STALKER_REQUEST,
{
url: playlist.portalUrl,
macAddress: playlist.macAddress,
params: queryParams,
}
);
}
if (!response?.js?.data) {
return [];
}
// Filter for episode items (is_episode: true) and sort by series_number
const episodes = sortEpisodesByNumber(
response.js.data.filter(
(item: StalkerVodSeriesEpisode) =>
item.is_episode === true
)
);
// Store episodes and selected season
patchState(store, {
vodSeriesEpisodes: episodes,
selectedVodSeriesSeasonId: seasonId,
});
return episodes;
},
};
}
)
);
}
+3
View File
@@ -0,0 +1,3 @@
export * from './features';
export * from './utils';
@@ -0,0 +1,3 @@
export * from './stalker-content-mappers';
export * from './stalker-sorting';
@@ -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,49 @@
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;
});
}