mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-08 17:06:15 -08:00
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:
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> {
|
||||
|
||||
+197
@@ -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'
|
||||
);
|
||||
});
|
||||
});
|
||||
+408
-312
@@ -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[]) {
|
||||
|
||||
+32
-48
@@ -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(
|
||||
|
||||
+33
-30
@@ -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,
|
||||
}
|
||||
|
||||
+178
@@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
+164
-380
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
+31
-10
@@ -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 || '',
|
||||
|
||||
+33
-49
@@ -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?.();
|
||||
},
|
||||
|
||||
+36
@@ -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
-51
@@ -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,
|
||||
}
|
||||
);
|
||||
}),
|
||||
}))
|
||||
);
|
||||
}
|
||||
+129
-175
@@ -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';
|
||||
|
||||
+76
@@ -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,
|
||||
})
|
||||
);
|
||||
}
|
||||
+136
@@ -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,
|
||||
});
|
||||
}
|
||||
Reference in new issue
Block a user