feat(stalker): route feature playback through session facade

This commit is contained in:
4gray committed 2026-07-27 11:20:14 +02:00
1 parent a6fd436e35
commit 0022604c47
15 files changed
+1009 -231

No files matched your search

@@ -10,7 +10,10 @@ import {
DataService,
PlaylistsService,
} from '@iptvnator/services';
import { Playlist } from '@iptvnator/shared/interfaces';
import {
Playlist,
STALKER_SESSION_APPLICATION_OPERATIONS,
} from '@iptvnator/shared/interfaces';
import { UnifiedCollectionItem } from '@iptvnator/portal/shared/util';
import {
ResolvedLiveCollectionDetail,
@@ -23,7 +26,14 @@ describe('StreamResolverService', () => {
let xtreamApi: { getShortEpg: jest.Mock };
let xtreamUrl: { constructLiveUrl: jest.Mock };
let dataService: { sendIpcEvent: jest.Mock };
let stalkerSession: { makeAuthenticatedRequest: jest.Mock };
let stalkerSession: {
supportsTypedSessions: jest.Mock;
getLeaseRef: jest.Mock;
open: jest.Mock;
request: jest.Mock;
requestForPlaylist: jest.Mock;
makeAuthenticatedRequest: jest.Mock;
};
let epgBridge: Partial<EpgRuntimeBridgeService>;
beforeEach(() => {
@@ -40,6 +50,11 @@ describe('StreamResolverService', () => {
sendIpcEvent: jest.fn(),
};
stalkerSession = {
supportsTypedSessions: jest.fn().mockReturnValue(true),
getLeaseRef: jest.fn(),
open: jest.fn(),
request: jest.fn(),
requestForPlaylist: jest.fn(),
makeAuthenticatedRequest: jest.fn(),
};
epgBridge = {
@@ -642,6 +657,65 @@ describe('StreamResolverService', () => {
);
});
it('returns the opaque full-portal playback context without renderer auth metadata', async () => {
playlistsService.getPlaylistById.mockReturnValue(
of({
_id: 'stalker-1',
title: 'Full Stalker',
count: 0,
importDate: '2026-07-27T00:00:00.000Z',
lastUsage: '',
autoRefresh: false,
portalUrl: 'https://stalker.example.com/server/load.php',
macAddress: '00:11:22:33:44:55',
isFullStalkerPortal: true,
userAgent: 'renderer-user-agent',
referrer: 'https://stalker.example.com/c/',
origin: 'https://stalker.example.com',
} satisfies Playlist)
);
stalkerSession.requestForPlaylist.mockResolvedValue({
streamUrl: 'https://cdn.example.com/live/77.m3u8',
playbackContextRef: 'opaque-playback-context',
});
const playback = await service.resolvePlayback({
uid: 'stalker::stalker-1::77',
name: 'Full Stalker Live',
contentType: 'live',
sourceType: 'stalker',
playlistId: 'stalker-1',
playlistName: 'Full Stalker',
stalkerId: '77',
stalkerCmd: 'ffmpeg http://stalker/live/77',
} satisfies UnifiedCollectionItem);
expect(stalkerSession.requestForPlaylist).toHaveBeenCalledWith(
expect.objectContaining({
_id: 'stalker-1',
isFullStalkerPortal: true,
}),
STALKER_SESSION_APPLICATION_OPERATIONS.CreateLink,
{
command: 'ffmpeg http://stalker/live/77',
contentType: 'itv',
}
);
expect(stalkerSession.request).not.toHaveBeenCalled();
expect(dataService.sendIpcEvent).not.toHaveBeenCalled();
expect(playback).toEqual(
expect.objectContaining({
streamUrl: 'https://cdn.example.com/live/77.m3u8',
playbackContextRef: 'opaque-playback-context',
isLive: true,
})
);
expect(playback).not.toHaveProperty('headers');
expect(playback).not.toHaveProperty('userAgent');
expect(playback).not.toHaveProperty('referer');
expect(playback).not.toHaveProperty('origin');
});
it('uses direct Stalker radio HTTP commands without create_link', async () => {
playlistsService.getPlaylistById.mockReturnValue(
of({
@@ -10,24 +10,23 @@ import {
EpgProgram,
Playlist,
ResolvedPortalPlayback,
STALKER_REQUEST,
StalkerPortalActions,
} from '@iptvnator/shared/interfaces';
import {
XtreamApiService,
XtreamUrlService,
} from '@iptvnator/portal/xtream/data-access';
import { StalkerSessionService } from '@iptvnator/portal/stalker/data-access';
import {
executeStalkerRequest,
fetchStalkerPlayback,
StalkerSessionService,
} from '@iptvnator/portal/stalker/data-access';
import { UnifiedCollectionItem } from '@iptvnator/portal/shared/util';
type PlaylistWithChannels = Playlist & {
readonly playlist?: { readonly items?: Channel[] };
};
interface StalkerCreateLinkResponse {
readonly js?: { readonly cmd?: string };
}
interface StalkerEpgEntry {
readonly id?: string | number;
readonly name?: string;
@@ -336,36 +335,41 @@ export class StreamResolverService {
}
const contentType = item.radio === 'true' ? 'radio' : 'itv';
const params = {
action: StalkerPortalActions.CreateLink,
cmd: item.stalkerCmd ?? '',
type: contentType,
disable_ad: '0',
download: '0',
JsHttpRequest: '1-xml',
const requestPlaylist: Playlist = {
_id: item.playlistId,
title: item.playlistName,
count: 0,
autoRefresh: false,
importDate: '',
lastUsage: '',
...playlist,
portalUrl,
macAddress,
isFullStalkerPortal:
playlist?.isFullStalkerPortal ??
this.stalkerSession.isFullStalkerPortal(portalUrl),
};
let response: StalkerCreateLinkResponse | undefined;
if (playlist?.isFullStalkerPortal && playlist) {
response = await this.stalkerSession.makeAuthenticatedRequest(
playlist,
params
);
} else {
response = await this.dataService.sendIpcEvent(STALKER_REQUEST, {
url: portalUrl,
macAddress,
params,
});
}
const rawCmd = response?.js?.cmd ?? '';
const playbackLink = await fetchStalkerPlayback(
{
dataService: this.dataService,
stalkerSession: this.stalkerSession,
},
{
playlist: requestPlaylist,
selectedContentType: contentType,
forcedContentType: contentType,
cmd: item.stalkerCmd ?? '',
}
);
return {
streamUrl: this.normalizeStalkerCmd(rawCmd),
streamUrl: playbackLink.streamUrl,
title: item.name,
thumbnail: item.logo ?? null,
isLive: item.radio === 'true' ? undefined : true,
...(playbackLink.playbackContextRef
? { playbackContextRef: playbackLink.playbackContextRef }
: {}),
};
}
@@ -939,19 +943,14 @@ export class StreamResolverService {
size: String(size),
};
let response: StalkerEpgResponse;
if (playlist.isFullStalkerPortal) {
response = await this.stalkerSession.makeAuthenticatedRequest(
playlist,
params
);
} else {
response = await this.dataService.sendIpcEvent(STALKER_REQUEST, {
url: playlist.portalUrl,
macAddress: playlist.macAddress,
params,
});
}
const response = await executeStalkerRequest<StalkerEpgResponse>(
{
dataService: this.dataService,
stalkerSession: this.stalkerSession,
},
playlist,
params
);
const epgData = Array.isArray(response?.js)
? response.js
@@ -0,0 +1,91 @@
import { DataService } from '@iptvnator/services';
import {
PlaylistMeta,
STALKER_REQUEST,
StalkerPortalActions,
} from '@iptvnator/shared/interfaces';
import { StalkerSessionService } from './stalker-session.service';
import { loadFullItvChannelList } from './stalker-itv-channel-loader';
const PLAYLIST = {
_id: 'playlist-1',
title: 'Demo Stalker',
count: 0,
autoRefresh: false,
importDate: '2026-07-27T00:00:00.000Z',
portalUrl: 'https://portal.example/server/load.php',
macAddress: '00:1A:79:00:00:01',
isFullStalkerPortal: false,
} as PlaylistMeta;
describe('loadFullItvChannelList', () => {
it('uses declared totalPages when total is absent', async () => {
const sendIpcEvent = jest
.fn()
.mockResolvedValueOnce({ js: { data: [] } })
.mockResolvedValueOnce({
js: {
data: [
{ id: '1', cmd: 'cmd-1', name: 'One' },
{ id: '2', cmd: 'cmd-2', name: 'Two' },
],
max_page_items: 2,
total_pages: 3,
},
})
.mockResolvedValueOnce({
js: {
data: [
{ id: '3', cmd: 'cmd-3', name: 'Three' },
{ id: '4', cmd: 'cmd-4', name: 'Four' },
],
max_page_items: 2,
total_pages: 3,
},
})
.mockResolvedValueOnce({
js: {
data: [{ id: '5', cmd: 'cmd-5', name: 'Five' }],
max_page_items: 2,
total_pages: 3,
},
});
const result = await loadFullItvChannelList(
{
dataService: {
sendIpcEvent,
} as unknown as DataService,
stalkerSession: {
supportsTypedSessions: jest.fn().mockReturnValue(true),
makeAuthenticatedRequest: jest.fn(),
} as unknown as StalkerSessionService,
},
PLAYLIST,
jest.fn(),
{
info: jest.fn(),
warn: jest.fn(),
}
);
expect(
Array.isArray(result) ? result.map((item) => item.id) : result
).toEqual(['1', '2', '3', '4', '5']);
expect(sendIpcEvent).toHaveBeenCalledTimes(4);
expect(sendIpcEvent).toHaveBeenNthCalledWith(1, STALKER_REQUEST, {
url: PLAYLIST.portalUrl,
macAddress: PLAYLIST.macAddress,
params: {
action: StalkerPortalActions.GetAllChannels,
type: 'itv',
},
});
expect(
sendIpcEvent.mock.calls
.slice(1)
.map(([, payload]) => payload.params.p)
.sort()
).toEqual([1, 2, 3]);
});
});
@@ -35,6 +35,10 @@ interface StalkerChannelListResponse {
data?: unknown;
total_items?: number | string;
max_page_items?: number | string;
total_pages?: number | string;
total?: number | string;
pageSize?: number | string;
totalPages?: number | string;
};
}
@@ -112,7 +116,12 @@ async function crawlOrderedPages(
onProgress: (loaded: number, total: number) => void,
logger: StalkerItvLoadLogger
): Promise<StalkerItvLoadOutcome> {
const firstResponse = await fetchOrderedPageWithRetry(deps, playlist, 1, logger);
const firstResponse = await fetchOrderedPageWithRetry(
deps,
playlist,
1,
logger
);
if (firstResponse === null) {
return 'error';
}
@@ -124,14 +133,31 @@ async function crawlOrderedPages(
return 'unsupported';
}
const pageSize =
toPositiveInt(
firstResponse?.js?.max_page_items ?? firstResponse?.js?.pageSize
) ??
(firstPage.length || FALLBACK_PAGE_SIZE);
const declaredTotal = toPositiveInt(
firstResponse?.js?.total_items ?? firstResponse?.js?.total
);
const declaredTotalPages = toPositiveInt(
firstResponse?.js?.total_pages ?? firstResponse?.js?.totalPages
);
const totalItems = Math.min(
toPositiveInt(firstResponse?.js?.total_items) ?? firstPage.length,
declaredTotal ??
(declaredTotalPages
? declaredTotalPages * pageSize
: firstPage.length),
MAX_CHANNELS
);
const pageSize =
toPositiveInt(firstResponse?.js?.max_page_items) ??
(firstPage.length || FALLBACK_PAGE_SIZE);
const totalPages = Math.max(1, Math.ceil(totalItems / pageSize));
const totalPages = Math.max(
1,
Math.min(
declaredTotalPages ?? Math.ceil(totalItems / pageSize),
Math.ceil(MAX_CHANNELS / pageSize)
)
);
const items: unknown[] = [];
const seen = new Set<string>();
@@ -6,7 +6,11 @@ import { TranslateService } from '@ngx-translate/core';
import { PORTAL_PLAYER } from '@iptvnator/portal/shared/util';
import { DataService, PlaylistsService } from '@iptvnator/services';
import { of } from 'rxjs';
import { PlaylistMeta, StalkerPortalActions } from '@iptvnator/shared/interfaces';
import {
PlaylistMeta,
STALKER_SESSION_APPLICATION_OPERATIONS,
StalkerPortalActions,
} from '@iptvnator/shared/interfaces';
import { StalkerSessionService } from '../../stalker-session.service';
import { withStalkerPlayer } from './with-stalker-player.feature';
@@ -54,6 +58,9 @@ const TestPlayerStore = signalStore(
},
}),
withMethods((store) => ({
setCurrentPlaylist(playlist: PlaylistMeta) {
patchState(store, { currentPlaylist: playlist });
},
setSelectedContentType(type: 'vod' | 'series' | 'itv' | 'radio') {
patchState(store, { selectedContentType: type });
},
@@ -83,6 +90,15 @@ describe('withStalkerPlayer', () => {
let ngrxStore: {
dispatch: jest.Mock;
};
let stalkerSession: {
supportsTypedSessions: jest.Mock;
getLeaseRef: jest.Mock;
open: jest.Mock;
request: jest.Mock;
requestForPlaylist: jest.Mock;
makeAuthenticatedRequest: jest.Mock;
getCachedToken: jest.Mock;
};
beforeEach(() => {
dataService = {
@@ -96,6 +112,15 @@ describe('withStalkerPlayer', () => {
ngrxStore = {
dispatch: jest.fn(),
};
stalkerSession = {
supportsTypedSessions: jest.fn().mockReturnValue(true),
getLeaseRef: jest.fn(),
open: jest.fn(),
request: jest.fn(),
requestForPlaylist: jest.fn(),
makeAuthenticatedRequest: jest.fn(),
getCachedToken: jest.fn(),
};
TestBed.configureTestingModule({
providers: [
@@ -113,10 +138,7 @@ describe('withStalkerPlayer', () => {
},
{
provide: StalkerSessionService,
useValue: {
getCachedToken: jest.fn(),
makeAuthenticatedRequest: jest.fn(),
},
useValue: stalkerSession,
},
{
provide: MatSnackBar,
@@ -284,4 +306,53 @@ describe('withStalkerPlayer', () => {
})
);
});
it('uses an opaque playback context for full-portal ITV without renderer auth headers', async () => {
store.setCurrentPlaylist({
...PLAYLIST,
isFullStalkerPortal: true,
userAgent: 'renderer-user-agent',
referrer: 'https://portal.example/referrer',
origin: 'https://portal.example',
});
store.setSelectedContentType('itv');
stalkerSession.requestForPlaylist.mockResolvedValue({
streamUrl: 'https://cdn.example/live.m3u8',
playbackContextRef: 'opaque-playback-context',
});
const playback = await store.resolveItvPlayback({
id: 'itv-1',
cmd: 'ffmpeg http://portal.example/live/1',
name: 'Full Portal Live',
});
expect(stalkerSession.requestForPlaylist).toHaveBeenCalledWith(
expect.objectContaining({
_id: PLAYLIST._id,
isFullStalkerPortal: true,
lastUsage: '',
}),
STALKER_SESSION_APPLICATION_OPERATIONS.CreateLink,
{
command: 'ffmpeg http://portal.example/live/1',
contentType: 'itv',
}
);
expect(stalkerSession.request).not.toHaveBeenCalled();
expect(stalkerSession.getCachedToken).not.toHaveBeenCalled();
expect(dataService.sendIpcEvent).not.toHaveBeenCalled();
expect(playback).toEqual(
expect.objectContaining({
streamUrl: 'https://cdn.example/live.m3u8',
playbackContextRef: 'opaque-playback-context',
title: 'Full Portal Live',
isLive: true,
})
);
expect(playback).not.toHaveProperty('headers');
expect(playback).not.toHaveProperty('userAgent');
expect(playback).not.toHaveProperty('referer');
expect(playback).not.toHaveProperty('origin');
});
});
@@ -30,6 +30,7 @@ import {
dispatchStalkerPlaylistMetaUpdate,
fetchStalkerExpireDate,
fetchStalkerMovieFileId,
fetchStalkerPlayback,
fetchStalkerPlaybackLink,
normalizeStalkerPlaybackCommand,
shouldResolveMovieFileId,
@@ -178,7 +179,7 @@ export function withStalkerPlayer() {
}
}
const streamUrl = await fetchStalkerPlaybackLink(
const playbackLink = await fetchStalkerPlayback(
requestDeps,
{
playlist,
@@ -197,13 +198,20 @@ export function withStalkerPlayer() {
normalizeStalkerEntityIdAsNumber(item?.id) ?? 0;
return {
streamUrl,
streamUrl: playbackLink.streamUrl,
title: title ?? '',
thumbnail,
startTime,
userAgent: playlist.userAgent,
referer: playlist.referrer,
origin: playlist.origin,
...(playbackLink.playbackContextRef
? {
playbackContextRef:
playbackLink.playbackContextRef,
}
: {
userAgent: playlist.userAgent,
referer: playlist.referrer,
origin: playlist.origin,
}),
contentInfo: {
playlistId: playlist._id,
contentXtreamId:
@@ -230,7 +238,7 @@ export function withStalkerPlayer() {
throw new Error('nothing_to_play');
}
const streamUrl = await fetchStalkerPlaybackLink(
const playbackLink = await fetchStalkerPlayback(
requestDeps,
{
playlist,
@@ -241,18 +249,6 @@ export function withStalkerPlayer() {
}
);
const token = stalkerSession.getCachedToken(playlist._id);
const headers = buildStalkerExternalPlaybackHeaders(
playlist,
token,
streamUrl
);
const crossOriginStream = isCrossOriginStalkerStream(
playlist,
streamUrl
);
const portalOrigin = getStalkerPortalOrigin(playlist);
recordRecentlyViewed(
item,
item.cmd,
@@ -260,11 +256,32 @@ export function withStalkerPlayer() {
item.o_name || item.name || item.title
);
return {
streamUrl,
const playback: ResolvedPortalPlayback = {
streamUrl: playbackLink.streamUrl,
title: item.o_name || item.name || item.title || '',
thumbnail: item.logo ?? item.cover ?? null,
isLive: true,
};
if (playbackLink.playbackContextRef) {
return {
...playback,
playbackContextRef: playbackLink.playbackContextRef,
};
}
const headers = buildStalkerExternalPlaybackHeaders(
playlist,
undefined,
playbackLink.streamUrl
);
const crossOriginStream = isCrossOriginStalkerStream(
playlist,
playbackLink.streamUrl
);
const portalOrigin = getStalkerPortalOrigin(playlist);
return {
...playback,
headers,
userAgent:
headers['User-Agent'] ||
@@ -292,11 +309,14 @@ export function withStalkerPlayer() {
}
let streamUrl = normalizeStalkerPlaybackCommand(item.cmd);
let playbackContextRef:
| ResolvedPortalPlayback['playbackContextRef']
| undefined;
if (
!streamUrl.startsWith('http://') &&
!streamUrl.startsWith('https://')
) {
streamUrl = await fetchStalkerPlaybackLink(
const playbackLink = await fetchStalkerPlayback(
requestDeps,
{
playlist,
@@ -306,6 +326,8 @@ export function withStalkerPlayer() {
forcedContentType: 'radio',
}
);
streamUrl = playbackLink.streamUrl;
playbackContextRef = playbackLink.playbackContextRef;
}
if (!streamUrl) {
@@ -323,9 +345,13 @@ export function withStalkerPlayer() {
streamUrl,
title: item.o_name || item.name || item.title || '',
thumbnail: item.logo ?? item.cover ?? null,
userAgent: playlist.userAgent,
referer: playlist.referrer,
origin: playlist.origin,
...(playbackContextRef
? { playbackContextRef }
: {
userAgent: playlist.userAgent,
referer: playlist.referrer,
origin: playlist.origin,
}),
};
};
@@ -34,9 +34,14 @@ describe('withStalkerPortal', () => {
let runtime: {
supportsStalkerPlaylistSqliteSync: boolean;
};
let dataService: {
sendIpcEvent: jest.Mock;
};
let stalkerSession: {
ensureToken: jest.Mock;
setActiveWatchdogPlaylist: jest.Mock;
supportsTypedSessions: jest.Mock;
makeAuthenticatedRequest: jest.Mock;
};
beforeEach(() => {
@@ -53,9 +58,14 @@ describe('withStalkerPortal', () => {
runtime = {
supportsStalkerPlaylistSqliteSync: true,
};
dataService = {
sendIpcEvent: jest.fn(),
};
stalkerSession = {
ensureToken: jest.fn(),
setActiveWatchdogPlaylist: jest.fn(),
supportsTypedSessions: jest.fn().mockReturnValue(true),
makeAuthenticatedRequest: jest.fn(),
};
TestBed.configureTestingModule({
@@ -63,9 +73,7 @@ describe('withStalkerPortal', () => {
TestPortalStore,
{
provide: DataService,
useValue: {
sendIpcEvent: jest.fn(),
},
useValue: dataService,
},
{
provide: RuntimeCapabilitiesService,
@@ -92,12 +100,7 @@ describe('withStalkerPortal', () => {
url: 'http://demo.example/stalker_portal/server/load.php',
type: 'stalker',
});
expect(stalkerSession.setActiveWatchdogPlaylist).toHaveBeenCalledWith(
expect.objectContaining({
macAddress: '00:1A:79:00:00:01',
portalUrl: 'http://demo.example/stalker_portal/server/load.php',
})
);
expect(stalkerSession.setActiveWatchdogPlaylist).not.toHaveBeenCalled();
});
it('does not touch SQLite when the Electron bridge is partial', async () => {
@@ -110,20 +113,45 @@ describe('withStalkerPortal', () => {
});
it('sends Stalker requests through DataService without requiring the SQLite bridge', async () => {
const dataService = TestBed.inject(DataService) as unknown as {
sendIpcEvent: jest.Mock;
};
dataService.sendIpcEvent.mockResolvedValue({ js: { data: [] } });
await store.makeStalkerRequest(PLAYLIST, { action: 'get_profile' });
expect(dataService.sendIpcEvent).toHaveBeenCalledWith(
STALKER_REQUEST,
expect.objectContaining({
macAddress: '00:1A:79:00:00:01',
params: { action: 'get_profile' },
url: 'http://demo.example/stalker_portal/server/load.php',
expect(dataService.sendIpcEvent).toHaveBeenCalledWith(STALKER_REQUEST, {
macAddress: '00:1A:79:00:00:01',
params: { action: 'get_profile' },
url: 'http://demo.example/stalker_portal/server/load.php',
});
expect(stalkerSession.ensureToken).not.toHaveBeenCalled();
expect(stalkerSession.makeAuthenticatedRequest).not.toHaveBeenCalled();
});
it('routes a full portal request through the typed session facade', async () => {
const fullPlaylist = {
...PLAYLIST,
isFullStalkerPortal: true,
};
const response = { js: [{ id: 'vod' }] };
stalkerSession.makeAuthenticatedRequest.mockResolvedValue(response);
await expect(
store.makeStalkerRequest(fullPlaylist, {
action: 'get_categories',
type: 'vod',
})
).resolves.toBe(response);
expect(stalkerSession.makeAuthenticatedRequest).toHaveBeenCalledWith(
expect.objectContaining({
...fullPlaylist,
lastUsage: '',
}),
{
action: 'get_categories',
type: 'vod',
}
);
expect(stalkerSession.ensureToken).not.toHaveBeenCalled();
expect(dataService.sendIpcEvent).not.toHaveBeenCalled();
});
});
@@ -6,11 +6,11 @@ import {
withProps,
withState,
} from '@ngrx/signals';
import { PlaylistMeta, STALKER_REQUEST } from '@iptvnator/shared/interfaces';
import { PlaylistMeta } from '@iptvnator/shared/interfaces';
import { createLogger } from '@iptvnator/portal/shared/util';
import { DataService, RuntimeCapabilitiesService } from '@iptvnator/services';
import { StalkerSessionService } from '../../stalker-session.service';
import { toStalkerSessionPlaylist } from '../utils';
import { executeStalkerRequest } from '../utils';
type StalkerPortalWindow = Window & {
electron: {
@@ -47,87 +47,62 @@ export function withStalkerPortal() {
stalkerSession = inject(StalkerSessionService)
) => ({
/**
* Helper to make stalker requests with automatic token handling
* Routes full portals through the main-owned session facade.
*/
async makeStalkerRequest(
playlist: PlaylistMeta,
params: Record<string, string | number>
) {
// Get token if it's a full stalker portal
let token: string | undefined;
let serialNumber: string | undefined;
if (playlist.isFullStalkerPortal) {
try {
const result = await stalkerSession.ensureToken(
toStalkerSessionPlaylist(playlist)
);
token = result.token ?? undefined;
serialNumber = result.serialNumber;
} catch (error) {
logger.error('Failed to get stalker token', error);
}
}
return dataService.sendIpcEvent(STALKER_REQUEST, {
url: playlist.portalUrl,
macAddress: playlist.macAddress,
params,
token,
serialNumber,
});
return executeStalkerRequest(
{
dataService,
stalkerSession,
},
playlist,
params
);
},
})
),
withMethods(
(
store,
stalkerSession = inject(StalkerSessionService),
runtime = inject(RuntimeCapabilitiesService)
) => ({
async setCurrentPlaylist(playlist: PlaylistMeta | undefined) {
stalkerSession.setActiveWatchdogPlaylist(
playlist
? toStalkerSessionPlaylist(playlist)
: undefined
);
patchState(store, { currentPlaylist: playlist });
withMethods((store, runtime = inject(RuntimeCapabilitiesService)) => ({
async setCurrentPlaylist(playlist: PlaylistMeta | undefined) {
patchState(store, { currentPlaylist: playlist });
// Ensure Stalker playlist exists in SQLite for playback positions
// Only sync if this is actually a Stalker playlist (has macAddress and portalUrl)
if (
playlist &&
runtime.supportsStalkerPlaylistSqliteSync &&
playlist._id &&
playlist.macAddress &&
playlist.portalUrl
) {
try {
const electronApi = (window as StalkerPortalWindow)
.electron;
// Ensure Stalker playlist exists in SQLite for playback positions
// Only sync if this is actually a Stalker playlist (has macAddress and portalUrl)
if (
playlist &&
runtime.supportsStalkerPlaylistSqliteSync &&
playlist._id &&
playlist.macAddress &&
playlist.portalUrl
) {
try {
const electronApi = (window as StalkerPortalWindow)
.electron;
const playlistId = String(playlist._id);
// Check if playlist exists in SQLite
const existing =
await electronApi.dbGetPlaylist(playlistId);
if (!existing) {
// Create playlist in SQLite
await electronApi.dbCreatePlaylist({
id: playlistId,
name: playlist.title || '',
macAddress: playlist.macAddress || '',
url: playlist.portalUrl || '',
type: 'stalker',
});
}
} catch (error) {
logger.error(
'Error syncing Stalker playlist to SQLite',
error
);
const playlistId = String(playlist._id);
// Check if playlist exists in SQLite
const existing =
await electronApi.dbGetPlaylist(playlistId);
if (!existing) {
// Create playlist in SQLite
await electronApi.dbCreatePlaylist({
id: playlistId,
name: playlist.title || '',
macAddress: playlist.macAddress || '',
url: playlist.portalUrl || '',
type: 'stalker',
});
}
} catch (error) {
logger.error(
'Error syncing Stalker playlist to SQLite',
error
);
}
},
})
)
}
},
}))
);
}
@@ -1,7 +1,11 @@
import { TestBed } from '@angular/core/testing';
import { patchState, signalStore, withMethods, withState } from '@ngrx/signals';
import { DataService, TmdbEnrichmentService } from '@iptvnator/services';
import { PlaylistMeta, StalkerPortalActions } from '@iptvnator/shared/interfaces';
import {
PlaylistMeta,
STALKER_SESSION_APPLICATION_OPERATIONS,
StalkerPortalActions,
} from '@iptvnator/shared/interfaces';
import { StalkerSessionService } from '../../stalker-session.service';
import { withStalkerSelection } from './with-stalker-selection.feature';
import { withStalkerSeries } from './with-stalker-series.feature';
@@ -77,11 +81,27 @@ describe('withStalkerSeries serialSeasonsResource gating', () => {
let dataService: {
sendIpcEvent: jest.Mock<Promise<unknown>, unknown[]>;
};
let stalkerSession: {
supportsTypedSessions: jest.Mock;
getLeaseRef: jest.Mock;
open: jest.Mock;
request: jest.Mock;
requestForPlaylist: jest.Mock;
makeAuthenticatedRequest: jest.Mock;
};
beforeEach(() => {
dataService = {
sendIpcEvent: jest.fn().mockResolvedValue({ js: [] }),
};
stalkerSession = {
supportsTypedSessions: jest.fn().mockReturnValue(true),
getLeaseRef: jest.fn(),
open: jest.fn(),
request: jest.fn(),
requestForPlaylist: jest.fn(),
makeAuthenticatedRequest: jest.fn(),
};
TestBed.configureTestingModule({
providers: [
@@ -89,9 +109,7 @@ describe('withStalkerSeries serialSeasonsResource gating', () => {
{ provide: DataService, useValue: dataService },
{
provide: StalkerSessionService,
useValue: {
makeAuthenticatedRequest: jest.fn(),
},
useValue: stalkerSession,
},
{
provide: TmdbEnrichmentService,
@@ -221,4 +239,57 @@ describe('withStalkerSeries serialSeasonsResource gating', () => {
expect(seriesRequestCalls(dataService.sendIpcEvent)).toHaveLength(1);
});
it('uses the explicit typed VOD-series seasons operation for a full portal', async () => {
stalkerSession.requestForPlaylist.mockResolvedValue({
seasons: [
{
id: '11:1',
number: 1,
isSeason: true,
videoId: '11',
title: 'Season 1',
},
],
});
store.setCurrentPlaylist({
...PLAYLIST,
isFullStalkerPortal: true,
});
store.setSelectedContentType('vod');
store.setSelectedItem({
id: '11',
name: 'VOD series',
is_series: '1',
});
await waitForCondition(
() => stalkerSession.requestForPlaylist.mock.calls.length > 0
);
await flushResources();
expect(stalkerSession.requestForPlaylist).toHaveBeenCalledWith(
expect.objectContaining({
_id: PLAYLIST._id,
isFullStalkerPortal: true,
lastUsage: '',
}),
STALKER_SESSION_APPLICATION_OPERATIONS.SeriesSeasons,
{
contentType: 'vod',
seriesId: '11',
}
);
expect(stalkerSession.request).not.toHaveBeenCalled();
expect(stalkerSession.makeAuthenticatedRequest).not.toHaveBeenCalled();
expect(dataService.sendIpcEvent).not.toHaveBeenCalled();
expect(store.getVodSeriesSeasonsResource()).toEqual([
expect.objectContaining({
id: '11:1',
is_season: true,
season_number: '1',
video_id: '11',
}),
]);
});
});
@@ -9,7 +9,11 @@ import {
} from '@ngrx/signals';
import { createLogger } from '@iptvnator/portal/shared/util';
import { DataService } from '@iptvnator/services';
import { StalkerPortalActions } from '@iptvnator/shared/interfaces';
import {
PlaylistMeta,
STALKER_SESSION_APPLICATION_OPERATIONS,
StalkerPortalActions,
} from '@iptvnator/shared/interfaces';
import {
StalkerSeason,
StalkerVodSeriesEpisode,
@@ -23,6 +27,8 @@ import {
sortByNumericValue,
sortEpisodesByNumber,
sortVodSeriesSeasonsByNumber,
StalkerRequestDeps,
toStalkerSessionPlaylist,
} from '../utils';
/**
@@ -72,6 +78,55 @@ function toMovieId(value: unknown): string {
return raw.includes(':') ? raw.split(':')[0] : raw;
}
async function fetchVodSeriesSeasons(
deps: StalkerRequestDeps,
playlist: PlaylistMeta,
seriesId: string | number
): Promise<StalkerVodSeriesSeason[]> {
if (
playlist.isFullStalkerPortal &&
supportsTypedSessions(deps.stalkerSession)
) {
const result = await deps.stalkerSession.requestForPlaylist(
toStalkerSessionPlaylist(playlist),
STALKER_SESSION_APPLICATION_OPERATIONS.SeriesSeasons,
{
contentType: 'vod',
seriesId,
}
);
return result.seasons.map((season) => ({
id: String(season.id),
video_id: String(season.videoId ?? seriesId),
season_number: String(season.number),
...(season.title ? { name: season.title } : {}),
...(season.isSeason !== undefined
? { is_season: season.isSeason }
: {}),
}));
}
const response = await executeStalkerRequest<
StalkerSeriesResponse<StalkerVodSeriesSeason>
>(deps, playlist, {
action: StalkerPortalActions.GetOrderedList,
type: 'vod',
movie_id: seriesId,
p: '1',
});
return extractSeriesItems(response);
}
function supportsTypedSessions(stalkerSession: StalkerSessionService): boolean {
const probe = (
stalkerSession as Partial<
Pick<StalkerSessionService, 'supportsTypedSessions'>
>
).supportsTypedSessions;
return typeof probe !== 'function' || probe.call(stalkerSession);
}
export function withStalkerSeries() {
const logger = createLogger('withStalkerSeries');
@@ -163,16 +218,11 @@ export function withStalkerSeries() {
return [];
}
const response = await executeStalkerRequest<
StalkerSeriesResponse<StalkerVodSeriesSeason>
>(requestDeps, currentPlaylist, {
action: StalkerPortalActions.GetOrderedList,
type: 'vod',
movie_id: selectedItem.id,
p: '1',
});
const seasonItems = extractSeriesItems(response);
const seasonItems = await fetchVodSeriesSeasons(
requestDeps,
currentPlaylist,
selectedItem.id
);
if (seasonItems.length === 0) {
logger.debug(
'vodSeriesSeasonsResource - no response data'
@@ -1,8 +1,14 @@
import { PlaylistMeta, StalkerPortalActions } from '@iptvnator/shared/interfaces';
import {
PlaylistMeta,
STALKER_REQUEST,
STALKER_SESSION_APPLICATION_OPERATIONS,
StalkerPortalActions,
} from '@iptvnator/shared/interfaces';
import { StalkerSessionService } from '../../stalker-session.service';
import {
fetchStalkerExpireDate,
fetchStalkerMovieFileId,
fetchStalkerPlayback,
fetchStalkerPlaybackLink,
shouldResolveMovieFileId,
} from './stalker-player-request.utils';
@@ -18,17 +24,34 @@ const PLAYLIST = {
isFullStalkerPortal: false,
} as PlaylistMeta;
const FULL_PLAYLIST = {
...PLAYLIST,
isFullStalkerPortal: true,
} as PlaylistMeta;
describe('stalker-player-request.utils', () => {
let dataService: {
sendIpcEvent: jest.Mock<Promise<unknown>, unknown[]>;
};
let stalkerSession: Pick<StalkerSessionService, 'makeAuthenticatedRequest'>;
let stalkerSession: {
supportsTypedSessions: jest.Mock;
getLeaseRef: jest.Mock;
open: jest.Mock;
request: jest.Mock;
requestForPlaylist: jest.Mock;
makeAuthenticatedRequest: jest.Mock;
};
beforeEach(() => {
dataService = {
sendIpcEvent: jest.fn(),
};
stalkerSession = {
supportsTypedSessions: jest.fn().mockReturnValue(true),
getLeaseRef: jest.fn(),
open: jest.fn(),
request: jest.fn(),
requestForPlaylist: jest.fn(),
makeAuthenticatedRequest: jest.fn(),
};
});
@@ -51,27 +74,134 @@ describe('stalker-player-request.utils', () => {
}
);
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(dataService.sendIpcEvent).toHaveBeenCalledWith(STALKER_REQUEST, {
url: PLAYLIST.portalUrl,
macAddress: PLAYLIST.macAddress,
params: {
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('uses the playlist-aware typed facade and returns the opaque playback context', async () => {
stalkerSession.requestForPlaylist.mockResolvedValue({
streamUrl: '/media/video_77.mpg',
playbackContextRef: 'playback-context-1',
});
const playback = await fetchStalkerPlayback(
{
dataService: dataService as never,
stalkerSession:
stalkerSession as unknown as StalkerSessionService,
},
{
playlist: FULL_PLAYLIST,
selectedContentType: 'series',
cmd: '/media/source.mpg',
series: 3,
}
);
expect(stalkerSession.requestForPlaylist).toHaveBeenCalledWith(
expect.objectContaining({
...FULL_PLAYLIST,
lastUsage: '',
}),
STALKER_SESSION_APPLICATION_OPERATIONS.CreateLink,
{
contentType: 'episode',
command: '/media/source.mpg',
episodeNumber: 3,
}
);
expect(stalkerSession.getLeaseRef).not.toHaveBeenCalled();
expect(stalkerSession.open).not.toHaveBeenCalled();
expect(stalkerSession.request).not.toHaveBeenCalled();
expect(stalkerSession.makeAuthenticatedRequest).not.toHaveBeenCalled();
expect(dataService.sendIpcEvent).not.toHaveBeenCalled();
expect(playback).toEqual({
streamUrl: 'http://demo.example/stalker_portal/media/video_77.mpg',
playbackContextRef: 'playback-context-1',
});
});
it('keeps a full-portal PWA request on the exact legacy IPC payload', async () => {
stalkerSession.supportsTypedSessions.mockReturnValue(false);
dataService.sendIpcEvent.mockResolvedValue({
js: {
cmd: 'ffmpeg https://cdn.example/live.m3u8',
},
});
await expect(
fetchStalkerPlayback(
{
dataService: dataService as never,
stalkerSession:
stalkerSession as unknown as StalkerSessionService,
},
{
playlist: FULL_PLAYLIST,
selectedContentType: 'itv',
cmd: 'ffmpeg http://portal/live/1',
}
)
).resolves.toEqual({
streamUrl: 'https://cdn.example/live.m3u8',
});
expect(dataService.sendIpcEvent).toHaveBeenCalledWith(STALKER_REQUEST, {
url: FULL_PLAYLIST.portalUrl,
macAddress: FULL_PLAYLIST.macAddress,
params: {
action: StalkerPortalActions.CreateLink,
cmd: 'ffmpeg http://portal/live/1',
type: 'itv',
disable_ad: '0',
download: '0',
JsHttpRequest: '1-xml',
},
});
expect(stalkerSession.requestForPlaylist).not.toHaveBeenCalled();
expect(stalkerSession.getLeaseRef).not.toHaveBeenCalled();
expect(stalkerSession.open).not.toHaveBeenCalled();
expect(stalkerSession.request).not.toHaveBeenCalled();
});
it('fails closed when a full-session create-link omits the opaque context', async () => {
stalkerSession.requestForPlaylist.mockResolvedValue({
streamUrl: 'https://cdn.example/live.m3u8',
});
await expect(
fetchStalkerPlayback(
{
dataService: dataService as never,
stalkerSession:
stalkerSession as unknown as StalkerSessionService,
},
{
playlist: FULL_PLAYLIST,
selectedContentType: 'itv',
cmd: 'ffmpeg http://portal/live/1',
}
)
).rejects.toThrow('stalker-playback-context-missing');
expect(dataService.sendIpcEvent).not.toHaveBeenCalled();
expect(stalkerSession.request).not.toHaveBeenCalled();
});
it('detects file-id fallback candidates and reads the movie file id', async () => {
expect(
shouldResolveMovieFileId(
@@ -113,6 +243,39 @@ describe('stalker-player-request.utils', () => {
);
});
it('keeps the full-portal movie file lookup on typed CatalogItems', async () => {
stalkerSession.requestForPlaylist.mockResolvedValue({
items: [{ id: 77, contentType: 'vod' }],
});
await expect(
fetchStalkerMovieFileId(
{
dataService: dataService as never,
stalkerSession:
stalkerSession as unknown as StalkerSessionService,
},
FULL_PLAYLIST,
'22'
)
).resolves.toBe('77');
expect(stalkerSession.requestForPlaylist).toHaveBeenCalledWith(
expect.objectContaining({
_id: FULL_PLAYLIST._id,
lastUsage: '',
}),
STALKER_SESSION_APPLICATION_OPERATIONS.CatalogItems,
{
contentType: 'vod',
itemId: '22',
page: 1,
}
);
expect(stalkerSession.makeAuthenticatedRequest).not.toHaveBeenCalled();
expect(dataService.sendIpcEvent).not.toHaveBeenCalled();
});
it('returns a localized expire date string from account info', async () => {
const expireDate = 1_713_139_200;
dataService.sendIpcEvent.mockResolvedValue({
@@ -1,13 +1,18 @@
import { DataService } from '@iptvnator/services';
import {
PlaylistMeta,
STALKER_SESSION_APPLICATION_OPERATIONS,
StalkerPlaybackContextRef,
StalkerPortalActions,
StalkerPortalItem,
} from '@iptvnator/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';
import {
executeStalkerRequest,
toStalkerSessionPlaylist,
} from './stalker-request.utils';
export interface StalkerPlayerResponse {
js?: {
@@ -25,6 +30,11 @@ export interface StalkerPlayerRequestDeps {
stalkerSession: StalkerSessionService;
}
export interface StalkerPlaybackLink {
streamUrl: string;
playbackContextRef?: StalkerPlaybackContextRef;
}
export interface StalkerPlayableItemLike extends StalkerPortalItem {
cmd?: string;
has_files?: unknown;
@@ -116,6 +126,12 @@ export function shouldResolveMovieFileId(
);
}
/**
* String-only compatibility path retained for downloads. Playback callers
* must use {@link fetchStalkerPlayback} so the opaque context reaches native
* playback. Authenticated full-portal downloads need a separate main-owned
* download-context contract before this compatibility method can be removed.
*/
export async function fetchStalkerPlaybackLink(
deps: StalkerPlayerRequestDeps,
options: {
@@ -126,8 +142,49 @@ export async function fetchStalkerPlaybackLink(
forcedContentType?: StalkerContentType;
}
): Promise<string> {
return (await fetchStalkerPlayback(deps, options)).streamUrl;
}
export async function fetchStalkerPlayback(
deps: StalkerPlayerRequestDeps,
options: {
playlist: PlaylistMeta;
selectedContentType: StalkerContentType;
cmd: string;
series?: number;
forcedContentType?: StalkerContentType;
}
): Promise<StalkerPlaybackLink> {
const contentType =
options.forcedContentType ?? options.selectedContentType;
if (
options.playlist.isFullStalkerPortal &&
supportsTypedSessions(deps.stalkerSession)
) {
const isEpisode = options.series !== undefined;
const payload = await deps.stalkerSession.requestForPlaylist(
toStalkerSessionPlaylist(options.playlist),
STALKER_SESSION_APPLICATION_OPERATIONS.CreateLink,
{
contentType: isEpisode ? 'episode' : contentType,
command: options.cmd,
...(isEpisode
? { episodeNumber: options.series as number }
: {}),
}
);
if (!payload.playbackContextRef) {
throw new Error('stalker-playback-context-missing');
}
return normalizePlaybackLink(
options.playlist.portalUrl ?? '',
options.cmd,
payload.streamUrl,
payload.playbackContextRef
);
}
const response = await executeStalkerRequest<StalkerPlayerResponse>(
deps,
options.playlist,
@@ -146,17 +203,41 @@ export async function fetchStalkerPlaybackLink(
throw new Error(response.js.error);
}
const streamUrl = resolveStalkerPlaybackUrl(
return normalizePlaybackLink(
options.playlist.portalUrl ?? '',
options.cmd,
response.js?.cmd ?? ''
);
}
function normalizePlaybackLink(
portalUrl: string,
originalCmd: string,
responseCmd: string,
playbackContextRef?: StalkerPlaybackContextRef
): StalkerPlaybackLink {
const streamUrl = resolveStalkerPlaybackUrl(
portalUrl,
originalCmd,
responseCmd
);
if (!streamUrl) {
throw new Error('nothing_to_play');
}
return streamUrl;
return {
streamUrl,
...(playbackContextRef ? { playbackContextRef } : {}),
};
}
function supportsTypedSessions(stalkerSession: StalkerSessionService): boolean {
const probe = (
stalkerSession as Partial<
Pick<StalkerSessionService, 'supportsTypedSessions'>
>
).supportsTypedSessions;
return typeof probe !== 'function' || probe.call(stalkerSession);
}
export async function fetchStalkerMovieFileId(
@@ -164,6 +245,23 @@ export async function fetchStalkerMovieFileId(
playlist: PlaylistMeta,
movieId: string
): Promise<string | null> {
if (
playlist.isFullStalkerPortal &&
supportsTypedSessions(deps.stalkerSession)
) {
const result = await deps.stalkerSession.requestForPlaylist(
toStalkerSessionPlaylist(playlist),
STALKER_SESSION_APPLICATION_OPERATIONS.CatalogItems,
{
contentType: 'vod',
itemId: movieId,
page: 1,
}
);
const fileId = result.items[0]?.id;
return fileId == null ? null : String(fileId);
}
const response = await executeStalkerRequest<StalkerPlayerResponse>(
deps,
playlist,
@@ -0,0 +1,95 @@
import { DataService } from '@iptvnator/services';
import {
Playlist,
STALKER_REQUEST,
StalkerPortalActions,
} from '@iptvnator/shared/interfaces';
import { StalkerSessionService } from '@iptvnator/portal/stalker/data-access';
import { executeStalkerSearchRequest } from './stalker-search-request.utils';
const REQUEST_PARAMS = {
action: StalkerPortalActions.GetOrderedList,
type: 'vod',
sortby: 'added',
search: 'matrix',
p: 1,
max_page_items: 100,
category: '*',
genre: '0',
};
function playlist(portalUrl: string): Playlist {
return {
_id: 'stalker-1',
title: 'Stalker',
count: 0,
autoRefresh: false,
importDate: '2026-07-27T00:00:00.000Z',
lastUsage: '',
portalUrl,
macAddress: '00:1A:79:00:00:01',
};
}
describe('executeStalkerSearchRequest', () => {
it('routes a detected full portal through the typed session facade', async () => {
const dataService = {
sendIpcEvent: jest.fn(),
};
const stalkerSession = {
isFullStalkerPortal: jest.fn().mockReturnValue(true),
supportsTypedSessions: jest.fn().mockReturnValue(true),
makeAuthenticatedRequest: jest
.fn()
.mockResolvedValue({ js: { data: [] } }),
};
await executeStalkerSearchRequest(
{
dataService: dataService as unknown as DataService,
stalkerSession:
stalkerSession as unknown as StalkerSessionService,
},
playlist('https://portal.example/server/load.php'),
REQUEST_PARAMS
);
expect(stalkerSession.makeAuthenticatedRequest).toHaveBeenCalledWith(
expect.objectContaining({
_id: 'stalker-1',
isFullStalkerPortal: true,
}),
REQUEST_PARAMS
);
expect(dataService.sendIpcEvent).not.toHaveBeenCalled();
});
it('keeps simple portal search on the exact legacy IPC payload', async () => {
const dataService = {
sendIpcEvent: jest.fn().mockResolvedValue({ js: { data: [] } }),
};
const stalkerSession = {
isFullStalkerPortal: jest.fn().mockReturnValue(false),
supportsTypedSessions: jest.fn().mockReturnValue(true),
makeAuthenticatedRequest: jest.fn(),
};
const simplePlaylist = playlist('https://portal.example/portal.php');
await executeStalkerSearchRequest(
{
dataService: dataService as unknown as DataService,
stalkerSession:
stalkerSession as unknown as StalkerSessionService,
},
simplePlaylist,
REQUEST_PARAMS
);
expect(dataService.sendIpcEvent).toHaveBeenCalledWith(STALKER_REQUEST, {
url: simplePlaylist.portalUrl,
macAddress: simplePlaylist.macAddress,
params: REQUEST_PARAMS,
});
expect(stalkerSession.makeAuthenticatedRequest).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,26 @@
import {
executeStalkerRequest,
StalkerRequestDeps,
} from '@iptvnator/portal/stalker/data-access';
import { Playlist } from '@iptvnator/shared/interfaces';
export async function executeStalkerSearchRequest<T>(
deps: StalkerRequestDeps,
playlist: Playlist,
params: Record<string, string | number>
): Promise<T> {
const isFullPortal =
playlist.isFullStalkerPortal ??
deps.stalkerSession.isFullStalkerPortal(playlist.portalUrl ?? '');
return executeStalkerRequest<T>(
deps,
isFullPortal
? {
...playlist,
isFullStalkerPortal: true,
}
: playlist,
params
);
}
@@ -19,7 +19,6 @@ import {
PlaybackPositionData,
Playlist,
ResolvedPortalPlayback,
STALKER_REQUEST,
StalkerPortalActions,
VodDetailsItem,
} from '@iptvnator/shared/interfaces';
@@ -55,6 +54,7 @@ import {
toggleStalkerVodFavorite,
} from '@iptvnator/portal/stalker/data-access';
import { StalkerVodPlaybackController } from '../stalker-vod-playback-controller';
import { executeStalkerSearchRequest } from './stalker-search-request.utils';
interface StalkerFilter {
key: StalkerSearchContentType;
@@ -191,30 +191,15 @@ export class StalkerSearchComponent {
...(contentType === 'vod' ? { genre: '0' } : {}),
};
// Full portals need the handshake token. The persisted flag can
// be missing on the active-playlist meta object, so fall back
// to URL detection — otherwise the portal answers
// "Authorization failed." and the search looks empty.
const isFullPortal =
(playlist as Playlist).isFullStalkerPortal ??
this.stalkerSession.isFullStalkerPortal(portalUrl);
const response = isFullPortal
? await this.stalkerSession.makeAuthenticatedRequest<StalkerSearchResponse>(
{
...(playlist as Playlist),
isFullStalkerPortal: true,
},
requestParams
)
: await this.dataService.sendIpcEvent<StalkerSearchResponse>(
STALKER_REQUEST,
{
url: portalUrl,
macAddress,
params: requestParams,
}
);
const response =
await executeStalkerSearchRequest<StalkerSearchResponse>(
{
dataService: this.dataService,
stalkerSession: this.stalkerSession,
},
playlist as Playlist,
requestParams
);
const items = response.js?.data || [];
return items.map((item: StalkerVodSource) =>
this.processItemUrls(item, portalUrl)