mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-08 17:06:15 -08:00
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:
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;
|
||||
},
|
||||
};
|
||||
}
|
||||
)
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
});
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user