mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-16 22:26:16 -08:00
feat(stalker): route feature playback through session facade
This commit is contained in:
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>();
|
||||
|
||||
+76
-5
@@ -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');
|
||||
});
|
||||
});
|
||||
+50
-24
@@ -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,
|
||||
}),
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
+46
-18
@@ -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();
|
||||
});
|
||||
});
|
||||
+47
-72
@@ -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
|
||||
);
|
||||
}
|
||||
},
|
||||
})
|
||||
)
|
||||
}
|
||||
},
|
||||
}))
|
||||
);
|
||||
}
|
||||
+75
-4
@@ -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',
|
||||
}),
|
||||
]);
|
||||
});
|
||||
});
|
||||
+61
-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'
|
||||
|
||||
+181
-18
@@ -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({
|
||||
|
||||
+101
-3
@@ -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,
|
||||
|
||||
+95
@@ -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)
|
||||
|
||||
Reference in new issue
Block a user