refactor(stalker): route renderer requests through session leases

This commit is contained in:
4gray committed 2026-07-27 10:58:14 +02:00
1 parent 91761f5abe
commit 474f4a86cf
8 files changed
+2047 -785

No files matched your search

@@ -3,6 +3,7 @@ export * from './lib/stores';
export * from './lib/stalker-content-types';
export * from './lib/stalker-itv-cache.service';
export * from './lib/stalker-live-playback.utils';
export * from './lib/stalker-request-adapter';
export * from './lib/stalker-series.adapters';
export * from './lib/stalker-session-descriptor';
export * from './lib/stalker-session.service';
@@ -0,0 +1,521 @@
/* eslint-disable max-lines -- operation matrix intentionally exercises the complete boundary */
import {
STALKER_SESSION_APPLICATION_OPERATIONS,
type StalkerSessionApplicationOperation,
} from '@iptvnator/shared/interfaces';
import {
adaptLegacyStalkerRequest,
mapStalkerSessionResultToLegacyResponse,
type StalkerLegacyRequestParameters,
} from './stalker-request-adapter';
describe('adaptLegacyStalkerRequest', () => {
it.each([
{
legacy: {
action: 'get_categories',
type: 'vod',
},
operation: STALKER_SESSION_APPLICATION_OPERATIONS.CatalogCategories,
parameters: {
contentType: 'vod',
},
},
{
legacy: {
action: 'get_genres',
type: 'itv',
},
operation: STALKER_SESSION_APPLICATION_OPERATIONS.CatalogGenres,
parameters: {},
},
{
legacy: {
action: 'get_ordered_list',
category: '7',
genre: '9',
movie_id: '42',
p: '3',
search: 'needle',
season_id: '4',
sortby: 'added',
type: 'vod',
},
operation: STALKER_SESSION_APPLICATION_OPERATIONS.CatalogItems,
parameters: {
category: '7',
contentType: 'vod',
genre: '9',
itemId: '42',
page: 3,
query: 'needle',
seasonId: '4',
sortBy: 'added',
},
},
{
legacy: {
action: 'get_all_channels',
type: 'itv',
},
operation:
STALKER_SESSION_APPLICATION_OPERATIONS.CatalogAllChannels,
parameters: {},
},
{
legacy: {
action: 'get_ordered_list',
category: '*',
genre: '0',
max_page_items: 100,
p: 2,
search: 'film',
sortby: 'added',
type: 'vod',
},
operation: STALKER_SESSION_APPLICATION_OPERATIONS.CatalogSearch,
parameters: {
category: '*',
contentType: 'vod',
page: 2,
query: 'film',
},
},
{
legacy: {
action: 'get_ordered_list',
movie_id: '51',
type: 'series',
},
operation: STALKER_SESSION_APPLICATION_OPERATIONS.SeriesSeasons,
parameters: {
seriesId: '51',
},
},
{
legacy: {
action: 'get_ordered_list',
movie_id: '51',
p: '1',
season_id: '51:2',
type: 'vod',
},
operation: STALKER_SESSION_APPLICATION_OPERATIONS.SeriesEpisodes,
parameters: {
seasonId: '51:2',
seriesId: '51',
},
},
{
legacy: {
action: 'get_short_epg',
ch_id: '11',
size: '7',
type: 'itv',
},
operation: STALKER_SESSION_APPLICATION_OPERATIONS.ShortEpg,
parameters: {
channelId: '11',
limit: 7,
},
},
{
legacy: {
action: 'get_epg_info',
from_timestamp: '100',
period: '168',
to_timestamp: 200,
type: 'itv',
},
operation: STALKER_SESSION_APPLICATION_OPERATIONS.EpgInfo,
parameters: {
fromTimestamp: 100,
periodHours: 168,
toTimestamp: 200,
},
},
{
legacy: {
action: 'create_link',
cmd: '/media/file_4.mpg',
disable_ad: '0',
download: '0',
JsHttpRequest: '1-xml',
series: '3',
type: 'vod',
},
operation: STALKER_SESSION_APPLICATION_OPERATIONS.CreateLink,
parameters: {
command: '/media/file_4.mpg',
contentType: 'episode',
episodeNumber: 3,
},
},
{
legacy: {
action: 'favorites',
favorite: '1',
item_id: '9',
type: 'vod',
},
operation: STALKER_SESSION_APPLICATION_OPERATIONS.Favorites,
parameters: {
contentType: 'vod',
favorite: true,
itemId: '9',
},
},
{
legacy: {
action: 'get_main_info',
JsHttpRequest: '1-xml',
type: 'account_info',
},
operation: STALKER_SESSION_APPLICATION_OPERATIONS.MainInfo,
parameters: {},
},
] as const)(
'maps a legacy call to $operation without raw wire/auth fields',
({ legacy, operation, parameters }) => {
const adapted = adaptLegacyStalkerRequest(
legacy as unknown as StalkerLegacyRequestParameters
);
expect(adapted).toEqual({
operation,
parameters,
});
expect(JSON.stringify(adapted)).not.toMatch(
/"action"|"token"|"JsHttpRequest"|"mac"|"password"/
);
}
);
it('keeps a movie file lookup as catalog detail instead of misclassifying it as a season request', () => {
expect(
adaptLegacyStalkerRequest({
action: 'get_ordered_list',
movie_id: '42',
p: '1',
type: 'vod',
})
).toEqual({
operation: STALKER_SESSION_APPLICATION_OPERATIONS.CatalogItems,
parameters: {
contentType: 'vod',
itemId: '42',
page: 1,
},
});
});
it.each(['handshake', 'get_profile', 'do_auth'])(
'rejects the raw auth action %s',
(action) => {
expect(() =>
adaptLegacyStalkerRequest({
action,
type: 'stb',
})
).toThrow('stalker-request-adapter-raw-auth-action');
}
);
it.each([
'token',
'prehash',
'auth_second_step',
'login',
'password',
'mac',
'serial',
'device_id',
'signature',
'metrics',
'stb_lang',
'timezone',
])('rejects the reserved legacy parameter %s', (parameter) => {
expect(() =>
adaptLegacyStalkerRequest({
action: 'get_ordered_list',
[parameter]: 'renderer-owned-value',
type: 'vod',
})
).toThrow(`stalker-request-adapter-reserved-parameter:${parameter}`);
});
it('rejects unsupported or operation-incompatible legacy fields', () => {
expect(() =>
adaptLegacyStalkerRequest({
action: 'unknown-action',
type: 'vod',
})
).toThrow('stalker-request-adapter-unsupported-operation');
expect(() =>
adaptLegacyStalkerRequest({
action: 'get_categories',
arbitrary: 'value',
type: 'vod',
})
).toThrow('stalker-request-adapter-unsupported-parameter:arbitrary');
expect(() =>
adaptLegacyStalkerRequest({
action: 'create_link',
cmd: '/movie',
JsHttpRequest: 'renderer-owned-value',
type: 'vod',
})
).toThrow('stalker-request-adapter-reserved-parameter:JsHttpRequest');
});
});
describe('mapStalkerSessionResultToLegacyResponse', () => {
it('maps categories back to the existing category envelope', () => {
expect(
mapStalkerSessionResultToLegacyResponse(
STALKER_SESSION_APPLICATION_OPERATIONS.CatalogCategories,
{
items: [
{
alias: 'movies',
censored: true,
id: '7',
name: 'Movies',
parentId: '1',
},
],
}
)
).toEqual({
js: [
{
alias: 'movies',
censored: 1,
id: '7',
parent_id: '1',
title: 'Movies',
},
],
});
});
it.each([
STALKER_SESSION_APPLICATION_OPERATIONS.CatalogItems,
STALKER_SESSION_APPLICATION_OPERATIONS.CatalogAllChannels,
STALKER_SESSION_APPLICATION_OPERATIONS.CatalogSearch,
] as const)('maps %s back to the ordered-list envelope', (operation) => {
expect(
mapStalkerSessionResultToLegacyResponse(operation, {
hasMore: true,
items: [
{
command: '/movie/4',
contentType: 'vod',
id: '4',
info: {
movieImage: '/poster.jpg',
originalName: 'Original',
},
isSeries: true,
name: 'Movie',
posterUrl: '/cover.jpg',
rating: 7.5,
seriesNumbers: [1, 2],
},
],
page: 2,
pageSize: 10,
total: 25,
totalPages: 3,
})
).toEqual({
js: {
cur_page: 2,
data: [
{
cmd: '/movie/4',
cover: '/cover.jpg',
id: '4',
info: {
movie_image: '/poster.jpg',
o_name: 'Original',
},
is_series: 1,
name: 'Movie',
rating_imdb: 7.5,
series: [1, 2],
},
],
max_page_items: 10,
total_items: 25,
total_pages: 3,
},
});
});
it('maps season and episode detail results back to their current list shapes', () => {
expect(
mapStalkerSessionResultToLegacyResponse(
STALKER_SESSION_APPLICATION_OPERATIONS.SeriesSeasons,
{
seasons: [
{
command: '/series/42',
episodeNumbers: [3, 4],
id: '42:2',
number: 2,
title: 'Season 2',
},
],
}
)
).toEqual({
js: [
{
cmd: '/series/42',
id: '42:2',
name: 'Season 2',
season_number: 2,
series: [3, 4],
},
],
});
expect(
mapStalkerSessionResultToLegacyResponse(
STALKER_SESSION_APPLICATION_OPERATIONS.SeriesEpisodes,
{
episodes: [
{
command: '/episode/3',
episodeNumber: 3,
id: 'e3',
seasonId: '42:2',
seasonNumber: 2,
seriesId: '42',
thumbnailUrl: '/episode.jpg',
title: 'Episode 3',
},
],
}
)
).toEqual({
js: {
data: [
{
cmd: '/episode/3',
cover: '/episode.jpg',
episode_num: 3,
id: 'e3',
name: 'Episode 3',
season_id: '42:2',
season_number: 2,
series_id: '42',
series_number: 3,
},
],
},
});
});
it.each([
STALKER_SESSION_APPLICATION_OPERATIONS.ShortEpg,
STALKER_SESSION_APPLICATION_OPERATIONS.EpgInfo,
] as const)('maps %s back to the current EPG array', (operation) => {
expect(
mapStalkerSessionResultToLegacyResponse(operation, {
programs: [
{
channelId: '11',
description: 'Summary',
endsAt: 200,
id: 'p1',
startsAt: 100,
title: 'News',
},
],
})
).toEqual({
js: [
{
ch_id: '11',
descr: 'Summary',
id: 'p1',
name: 'News',
start_timestamp: 100,
stop_timestamp: 200,
},
],
});
});
it('maps create-link, favorites, and main-info results without exposing opaque playback context data', () => {
const createLink = mapStalkerSessionResultToLegacyResponse(
STALKER_SESSION_APPLICATION_OPERATIONS.CreateLink,
{
playbackContextRef: 'opaque-playback-context',
streamUrl: 'https://media.example/stream',
userAgent: 'main-owned-user-agent',
}
);
expect(createLink).toEqual({
js: {
cmd: 'https://media.example/stream',
},
});
expect(JSON.stringify(createLink)).not.toContain(
'opaque-playback-context'
);
expect(JSON.stringify(createLink)).not.toContain(
'main-owned-user-agent'
);
expect(
mapStalkerSessionResultToLegacyResponse(
STALKER_SESSION_APPLICATION_OPERATIONS.Favorites,
{
favorite: true,
success: true,
}
)
).toEqual({
js: true,
});
expect(
mapStalkerSessionResultToLegacyResponse(
STALKER_SESSION_APPLICATION_OPERATIONS.MainInfo,
{
account: {
accountBalance: '2.50',
expiresAt: '2030-01-01',
name: 'demo',
status: 'active',
tariffPlan: 'Basic',
},
}
)
).toEqual({
js: {
account_info: {
account_balance: '2.50',
expire_date: '2030-01-01',
login: 'demo',
status: 'active',
tariff_plan_name: 'Basic',
},
},
});
});
it('rejects a result/operation mismatch instead of guessing a legacy shape', () => {
expect(() =>
mapStalkerSessionResultToLegacyResponse(
'unsupported' as StalkerSessionApplicationOperation,
{} as never
)
).toThrow('stalker-request-adapter-unsupported-operation');
});
});
@@ -0,0 +1,677 @@
/* eslint-disable max-lines -- exhaustive legacy/typed boundary stays auditable in one module */
import {
STALKER_RESERVED_REQUEST_PARAMETERS,
validateStalkerApplicationRequest,
} from '@iptvnator/portal/stalker/protocol';
import {
STALKER_SESSION_APPLICATION_OPERATIONS,
type StalkerSessionApplicationOperation,
type StalkerSessionCatalogInfo,
type StalkerSessionCatalogItem,
type StalkerSessionContentType,
type StalkerSessionEntityId,
type StalkerSessionOperationParameters,
type StalkerSessionOperationResult,
} from '@iptvnator/shared/interfaces';
export type StalkerLegacyRequestParameters = Readonly<
Record<string, string | number>
>;
export type StalkerAdaptedRequest = {
[Operation in StalkerSessionApplicationOperation]: {
readonly operation: Operation;
readonly parameters: StalkerSessionOperationParameters<Operation>;
};
}[StalkerSessionApplicationOperation];
const RAW_AUTH_ACTIONS = new Set(['do_auth', 'get_profile', 'handshake']);
const RESERVED_PARAMETERS = new Set<string>(
STALKER_RESERVED_REQUEST_PARAMETERS
);
const CONTENT_TYPES = new Set<StalkerSessionContentType>([
'itv',
'radio',
'series',
'vod',
]);
const JS_HTTP_REQUEST = '1-xml';
const SEARCH_PAGE_SIZE = 100;
export function adaptLegacyStalkerRequest(
legacy: StalkerLegacyRequestParameters
): StalkerAdaptedRequest {
const action = readRequiredString(legacy['action']);
if (RAW_AUTH_ACTIONS.has(action)) {
throw adapterError('raw-auth-action');
}
assertNoReservedParameters(legacy);
switch (action) {
case 'get_categories':
assertOnlyParameters(legacy, ['action', 'type']);
return adapted(
STALKER_SESSION_APPLICATION_OPERATIONS.CatalogCategories,
{
contentType: readContentType(legacy['type'], [
'radio',
'series',
'vod',
]) as 'radio' | 'series' | 'vod',
}
);
case 'get_genres':
assertOnlyParameters(legacy, ['action', 'type']);
assertExactValue(legacy['type'], 'itv');
return adapted(
STALKER_SESSION_APPLICATION_OPERATIONS.CatalogGenres,
{}
);
case 'get_ordered_list':
return adaptOrderedList(legacy);
case 'get_all_channels':
assertOnlyParameters(legacy, ['action', 'type']);
assertExactValue(legacy['type'], 'itv');
return adapted(
STALKER_SESSION_APPLICATION_OPERATIONS.CatalogAllChannels,
{}
);
case 'search':
return adaptSearch(legacy);
case 'get_short_epg':
return adaptShortEpg(legacy);
case 'get_epg_info':
return adaptEpgInfo(legacy);
case 'create_link':
return adaptCreateLink(legacy);
case 'favorites':
return adaptFavorites(legacy);
case 'get_main_info':
assertOnlyParameters(legacy, ['action', 'JsHttpRequest', 'type']);
assertJsHttpRequest(legacy);
assertExactValue(legacy['type'], 'account_info');
return adapted(STALKER_SESSION_APPLICATION_OPERATIONS.MainInfo, {});
default:
throw adapterError('unsupported-operation');
}
}
export function mapStalkerSessionResultToLegacyResponse<
Operation extends StalkerSessionApplicationOperation,
>(
operation: Operation,
result: StalkerSessionOperationResult<Operation>
): unknown {
switch (operation) {
case STALKER_SESSION_APPLICATION_OPERATIONS.CatalogCategories:
case STALKER_SESSION_APPLICATION_OPERATIONS.CatalogGenres: {
const categories = operationResult<
| typeof STALKER_SESSION_APPLICATION_OPERATIONS.CatalogCategories
| typeof STALKER_SESSION_APPLICATION_OPERATIONS.CatalogGenres
>(result);
return {
js: categories.items.map((item) =>
compact({
id: item.id,
title: item.name,
alias: item.alias,
censored: booleanFlag(item.censored),
parent_id: item.parentId,
})
),
};
}
case STALKER_SESSION_APPLICATION_OPERATIONS.CatalogItems:
case STALKER_SESSION_APPLICATION_OPERATIONS.CatalogAllChannels:
case STALKER_SESSION_APPLICATION_OPERATIONS.CatalogSearch: {
const catalog = operationResult<
| typeof STALKER_SESSION_APPLICATION_OPERATIONS.CatalogItems
| typeof STALKER_SESSION_APPLICATION_OPERATIONS.CatalogAllChannels
| typeof STALKER_SESSION_APPLICATION_OPERATIONS.CatalogSearch
>(result);
return {
js: compact({
data: catalog.items.map(mapCatalogItemToLegacy),
cur_page: catalog.page,
max_page_items: catalog.pageSize,
total_items: catalog.total,
total_pages: catalog.totalPages,
}),
};
}
case STALKER_SESSION_APPLICATION_OPERATIONS.SeriesSeasons: {
const seasons =
operationResult<
typeof STALKER_SESSION_APPLICATION_OPERATIONS.SeriesSeasons
>(result);
return {
js: seasons.seasons.map((season) =>
compact({
id: season.id,
season_number: season.number,
name: season.title,
cmd: season.command,
series: season.episodeNumbers,
})
),
};
}
case STALKER_SESSION_APPLICATION_OPERATIONS.SeriesEpisodes: {
const episodes =
operationResult<
typeof STALKER_SESSION_APPLICATION_OPERATIONS.SeriesEpisodes
>(result);
return {
js: {
data: episodes.episodes.map((episode) =>
compact({
id: episode.id,
series_id: episode.seriesId,
season_id: episode.seasonId,
season_number: episode.seasonNumber,
series_number: episode.episodeNumber,
episode_num: episode.episodeNumber,
name: episode.title,
cmd: episode.command,
cover: episode.thumbnailUrl,
description: episode.description,
duration: episode.durationSeconds,
})
),
},
};
}
case STALKER_SESSION_APPLICATION_OPERATIONS.ShortEpg:
case STALKER_SESSION_APPLICATION_OPERATIONS.EpgInfo: {
const epg = operationResult<
| typeof STALKER_SESSION_APPLICATION_OPERATIONS.ShortEpg
| typeof STALKER_SESSION_APPLICATION_OPERATIONS.EpgInfo
>(result);
return {
js: epg.programs.map((program) =>
compact({
id: program.id,
ch_id: program.channelId,
name: program.title,
descr: program.description,
start_timestamp: program.startsAt,
stop_timestamp: program.endsAt,
})
),
};
}
case STALKER_SESSION_APPLICATION_OPERATIONS.CreateLink: {
const link =
operationResult<
typeof STALKER_SESSION_APPLICATION_OPERATIONS.CreateLink
>(result);
return {
js: {
cmd: link.streamUrl,
},
};
}
case STALKER_SESSION_APPLICATION_OPERATIONS.Favorites: {
const favorites =
operationResult<
typeof STALKER_SESSION_APPLICATION_OPERATIONS.Favorites
>(result);
return {
js: favorites.success,
};
}
case STALKER_SESSION_APPLICATION_OPERATIONS.MainInfo: {
const mainInfo =
operationResult<
typeof STALKER_SESSION_APPLICATION_OPERATIONS.MainInfo
>(result);
return {
js: {
account_info: compact({
login: mainInfo.account.name,
status: mainInfo.account.status,
tariff_plan_name: mainInfo.account.tariffPlan,
account_balance: mainInfo.account.accountBalance,
expire_date: mainInfo.account.expiresAt,
}),
},
};
}
default:
throw adapterError('unsupported-operation');
}
}
function adaptOrderedList(
legacy: StalkerLegacyRequestParameters
): StalkerAdaptedRequest {
const contentType = readContentType(legacy['type']);
const itemId = optionalEntityId(legacy['movie_id']);
const seasonId = optionalEntityId(legacy['season_id']);
const page = optionalPositiveInteger(legacy['p']);
if (legacy['max_page_items'] !== undefined) {
return adaptSearch(legacy);
}
if (
contentType === 'vod' &&
itemId !== undefined &&
seasonId !== undefined &&
page === 1
) {
assertOnlyParameters(legacy, [
'action',
'movie_id',
'p',
'season_id',
'type',
]);
return adapted(STALKER_SESSION_APPLICATION_OPERATIONS.SeriesEpisodes, {
seasonId,
seriesId: itemId,
});
}
if (contentType === 'series' && itemId !== undefined) {
assertOnlyParameters(legacy, ['action', 'movie_id', 'type']);
return adapted(STALKER_SESSION_APPLICATION_OPERATIONS.SeriesSeasons, {
seriesId: itemId,
});
}
assertOnlyParameters(legacy, [
'action',
'category',
'genre',
'movie_id',
'p',
'search',
'season_id',
'sortby',
'type',
]);
return adapted(
STALKER_SESSION_APPLICATION_OPERATIONS.CatalogItems,
compact({
contentType,
category: optionalEntityId(legacy['category']),
genre: optionalEntityId(legacy['genre']),
page,
query: optionalString(legacy['search']),
sortBy: optionalString(legacy['sortby']),
itemId,
seasonId,
})
);
}
function adaptSearch(
legacy: StalkerLegacyRequestParameters
): StalkerAdaptedRequest {
assertOnlyParameters(legacy, [
'action',
'category',
'genre',
'max_page_items',
'p',
'query',
'search',
'sortby',
'type',
]);
if (legacy['max_page_items'] !== undefined) {
assertExactPositiveInteger(legacy['max_page_items'], SEARCH_PAGE_SIZE);
}
const query = readRequiredString(legacy['query'] ?? legacy['search']);
return adapted(
STALKER_SESSION_APPLICATION_OPERATIONS.CatalogSearch,
compact({
contentType: readContentType(legacy['type']),
query,
category: optionalEntityId(legacy['category']),
page: optionalPositiveInteger(legacy['p']),
})
);
}
function adaptShortEpg(
legacy: StalkerLegacyRequestParameters
): StalkerAdaptedRequest {
assertOnlyParameters(legacy, [
'action',
'ch_id',
'JsHttpRequest',
'size',
'type',
]);
assertJsHttpRequest(legacy);
assertExactValue(legacy['type'], 'itv');
return adapted(
STALKER_SESSION_APPLICATION_OPERATIONS.ShortEpg,
compact({
channelId: readEntityId(legacy['ch_id']),
limit: optionalPositiveInteger(legacy['size']),
})
);
}
function adaptEpgInfo(
legacy: StalkerLegacyRequestParameters
): StalkerAdaptedRequest {
assertOnlyParameters(legacy, [
'action',
'from_timestamp',
'JsHttpRequest',
'period',
'to_timestamp',
'type',
]);
assertJsHttpRequest(legacy);
assertExactValue(legacy['type'], 'itv');
return adapted(
STALKER_SESSION_APPLICATION_OPERATIONS.EpgInfo,
compact({
fromTimestamp: optionalNonNegativeInteger(legacy['from_timestamp']),
periodHours: optionalPositiveInteger(legacy['period']),
toTimestamp: optionalNonNegativeInteger(legacy['to_timestamp']),
})
);
}
function adaptCreateLink(
legacy: StalkerLegacyRequestParameters
): StalkerAdaptedRequest {
assertOnlyParameters(legacy, [
'action',
'cmd',
'disable_ad',
'download',
'item_id',
'JsHttpRequest',
'movie_id',
'season_number',
'series',
'series_id',
'type',
]);
assertJsHttpRequest(legacy);
assertOptionalExactNonNegativeInteger(legacy['disable_ad'], 0);
assertOptionalExactNonNegativeInteger(legacy['download'], 0);
const episodeNumber = optionalPositiveInteger(legacy['series']);
const contentType =
episodeNumber === undefined
? readContentType(legacy['type'])
: 'episode';
return adapted(
STALKER_SESSION_APPLICATION_OPERATIONS.CreateLink,
compact({
contentType,
command: readRequiredString(legacy['cmd']),
itemId: optionalEntityId(legacy['item_id'] ?? legacy['movie_id']),
seriesId: optionalEntityId(legacy['series_id']),
seasonNumber: optionalPositiveInteger(legacy['season_number']),
episodeNumber,
})
);
}
function adaptFavorites(
legacy: StalkerLegacyRequestParameters
): StalkerAdaptedRequest {
assertOnlyParameters(legacy, ['action', 'favorite', 'item_id', 'type']);
return adapted(STALKER_SESSION_APPLICATION_OPERATIONS.Favorites, {
contentType: readContentType(legacy['type']),
favorite: readBoolean(legacy['favorite']),
itemId: readEntityId(legacy['item_id']),
});
}
function adapted<Operation extends StalkerSessionApplicationOperation>(
operation: Operation,
parameters: StalkerSessionOperationParameters<Operation>
): Extract<StalkerAdaptedRequest, { operation: Operation }> {
const validation = validateStalkerApplicationRequest({
operation,
parameters: parameters as unknown as Readonly<Record<string, unknown>>,
});
if (validation.kind === 'rejected') {
const suffix =
validation.field === undefined ? '' : `:${validation.field}`;
throw adapterError(`${validation.reason}${suffix}`);
}
return {
operation,
parameters,
} as Extract<StalkerAdaptedRequest, { operation: Operation }>;
}
function mapCatalogItemToLegacy(item: StalkerSessionCatalogItem) {
return compact({
id: item.id,
actors: item.actors,
name: item.name,
title: item.title,
o_name: item.originalName,
description: item.description,
director: item.director,
cmd: item.command,
cover: item.posterUrl,
logo: item.logoUrl,
screenshot_uri: item.screenshotUrl,
category_id: item.categoryId,
tv_genre_id: item.genreId,
video_id: item.videoId,
number: item.channelNumber,
has_files: booleanFlag(item.hasFiles),
series: item.seriesNumbers,
year: item.year,
rating_imdb: item.rating,
duration: item.durationSeconds,
is_series: booleanFlag(item.isSeries),
info: mapCatalogInfoToLegacy(item.info),
});
}
function mapCatalogInfoToLegacy(
info: StalkerSessionCatalogInfo | undefined
): Record<string, unknown> | undefined {
if (info === undefined) {
return undefined;
}
const mapped = compact({
actors: info.actors,
description: info.description,
director: info.director,
genre: info.genre,
movie_image: info.movieImage,
name: info.name,
o_name: info.originalName,
releasedate: info.releaseDate,
rating_imdb: info.ratingImdb,
rating_kinopoisk: info.ratingKinopoisk,
});
return Object.keys(mapped).length === 0 ? undefined : mapped;
}
function assertNoReservedParameters(
legacy: StalkerLegacyRequestParameters
): void {
for (const parameter of Object.keys(legacy)) {
if (parameter === 'action') {
continue;
}
if (parameter === 'JsHttpRequest') {
if (legacy[parameter] !== JS_HTTP_REQUEST) {
throw adapterError(`reserved-parameter:${parameter}`);
}
continue;
}
if (RESERVED_PARAMETERS.has(parameter.toLowerCase())) {
throw adapterError(`reserved-parameter:${parameter}`);
}
}
}
function assertOnlyParameters(
legacy: StalkerLegacyRequestParameters,
allowed: readonly string[]
): void {
const allowedSet = new Set(allowed);
for (const parameter of Object.keys(legacy)) {
if (!allowedSet.has(parameter)) {
throw adapterError(`unsupported-parameter:${parameter}`);
}
}
}
function assertJsHttpRequest(legacy: StalkerLegacyRequestParameters): void {
if (
legacy['JsHttpRequest'] !== undefined &&
legacy['JsHttpRequest'] !== JS_HTTP_REQUEST
) {
throw adapterError('reserved-parameter:JsHttpRequest');
}
}
function readContentType(
value: unknown,
allowed: readonly StalkerSessionContentType[] = [
'itv',
'radio',
'series',
'vod',
]
): StalkerSessionContentType {
if (
typeof value !== 'string' ||
!CONTENT_TYPES.has(value as StalkerSessionContentType) ||
!allowed.includes(value as StalkerSessionContentType)
) {
throw adapterError('invalid-parameters');
}
return value as StalkerSessionContentType;
}
function readEntityId(value: unknown): StalkerSessionEntityId {
const result = optionalEntityId(value);
if (result === undefined) {
throw adapterError('invalid-parameters');
}
return result;
}
function optionalEntityId(value: unknown): StalkerSessionEntityId | undefined {
if (typeof value === 'string') {
return value.trim().length === 0 ? undefined : value;
}
return typeof value === 'number' && Number.isFinite(value)
? value
: undefined;
}
function readRequiredString(value: unknown): string {
const result = optionalString(value);
if (result === undefined) {
throw adapterError('invalid-parameters');
}
return result;
}
function optionalString(value: unknown): string | undefined {
if (typeof value !== 'string') {
return undefined;
}
const trimmed = value.trim();
return trimmed.length === 0 ? undefined : trimmed;
}
function readBoolean(value: unknown): boolean {
if (value === 1 || value === '1') {
return true;
}
if (value === 0 || value === '0') {
return false;
}
throw adapterError('invalid-parameters');
}
function optionalPositiveInteger(value: unknown): number | undefined {
if (value === undefined) {
return undefined;
}
const result = readFiniteNumber(value);
if (!Number.isSafeInteger(result) || result <= 0) {
throw adapterError('invalid-parameters');
}
return result;
}
function optionalNonNegativeInteger(value: unknown): number | undefined {
if (value === undefined) {
return undefined;
}
const result = readFiniteNumber(value);
if (!Number.isSafeInteger(result) || result < 0) {
throw adapterError('invalid-parameters');
}
return result;
}
function readFiniteNumber(value: unknown): number {
const result =
typeof value === 'number'
? value
: typeof value === 'string' && value.trim().length > 0
? Number(value)
: Number.NaN;
if (!Number.isFinite(result)) {
throw adapterError('invalid-parameters');
}
return result;
}
function assertExactValue(value: unknown, expected: string): void {
if (value !== expected) {
throw adapterError('invalid-parameters');
}
}
function assertExactPositiveInteger(value: unknown, expected: number): void {
if (optionalPositiveInteger(value) !== expected) {
throw adapterError('invalid-parameters');
}
}
function assertOptionalExactNonNegativeInteger(
value: unknown,
expected: number
): void {
if (value !== undefined && optionalNonNegativeInteger(value) !== expected) {
throw adapterError('invalid-parameters');
}
}
function booleanFlag(value: boolean | undefined): number | undefined {
return value === undefined ? undefined : value ? 1 : 0;
}
function operationResult<Operation extends StalkerSessionApplicationOperation>(
result: StalkerSessionOperationResult<StalkerSessionApplicationOperation>
): StalkerSessionOperationResult<Operation> {
return result as StalkerSessionOperationResult<Operation>;
}
function compact<T extends object>(value: T): T {
const result: Record<string, unknown> = {};
for (const [key, entry] of Object.entries(value)) {
if (entry !== undefined) {
result[key] = entry;
}
}
return result as T;
}
function adapterError(reason: string): Error {
return new Error(`stalker-request-adapter-${reason}`);
}
@@ -55,9 +55,7 @@ export function mapPlaylistToStalkerSessionConnection(
playlistRef,
profilePreset: mapProfilePreset(playlist.stalkerProfilePreset),
sourceUrl,
...(learnedEndpointHint === undefined
? {}
: { learnedEndpointHint }),
...(learnedEndpointHint === undefined ? {} : { learnedEndpointHint }),
...(identityOverrides === undefined ? {} : { identityOverrides }),
...(transportConfiguration === undefined
? {}
@@ -115,9 +113,7 @@ function mapTransportConfiguration(
playlist: Playlist
): StalkerSessionTransportConfiguration | undefined {
if (playlist.stalkerTransportConfiguration !== undefined) {
return copyPresentStringValues(
playlist.stalkerTransportConfiguration
);
return copyPresentStringValues(playlist.stalkerTransportConfiguration);
}
return copyPresentStringValues({
@@ -128,13 +124,13 @@ function mapTransportConfiguration(
}
function copyPresentStringValues<T extends object>(source: T): T | undefined {
const result = Object.fromEntries(
Object.entries(source).filter(
([, value]) =>
typeof value === 'string' && value.trim().length > 0
)
) as T;
return Object.keys(result).length > 0 ? result : undefined;
const result: Record<string, unknown> = {};
for (const [key, value] of Object.entries(source)) {
if (typeof value === 'string' && value.trim().length > 0) {
result[key] = value;
}
}
return Object.keys(result).length > 0 ? (result as T) : undefined;
}
function mapSavedCredentials(
@@ -1,222 +1,427 @@
/* eslint-disable max-lines -- facade boundary matrix covers all typed outcomes and controls */
import { TestBed } from '@angular/core/testing';
import { DataService } from '@iptvnator/services';
import { Playlist } from '@iptvnator/shared/interfaces';
import {
STALKER_SERIAL_NUMBER,
StalkerProfileResponse,
STALKER_SESSION_APPLICATION_OPERATIONS,
type Playlist,
type StalkerSessionConnectionOutcome,
type StalkerSessionControlOutcome,
type StalkerSessionRequestOutcome,
} from '@iptvnator/shared/interfaces';
import {
STALKER_SESSION_BRIDGE,
type StalkerSessionBridge,
StalkerSessionOutcomeError,
StalkerSessionService,
} from './stalker-session.service';
type ExpectedStalkerPortalIdentity = {
serialNumber?: string;
deviceId1?: string;
deviceId2?: string;
signature1?: string;
signature2?: string;
const PLAYLIST: Playlist = {
_id: 'playlist-1',
autoRefresh: false,
count: 0,
importDate: '2026-07-27T00:00:00.000Z',
isFullStalkerPortal: true,
lastUsage: '2026-07-27T00:00:00.000Z',
macAddress: '00-1a-79-aa-bb-cc',
password: 'saved-password-must-not-cross',
portalUrl: 'https://portal.example/stalker_portal/server/load.php',
stalkerSourceUrl: 'https://portal.example/c/',
stalkerToken: 'legacy-token-must-not-cross',
title: 'Portal',
username: 'saved-user-must-not-cross',
};
type GetProfileWithIdentity = (
portalUrl: string,
macAddress: string,
token: string,
identity: ExpectedStalkerPortalIdentity,
handshakeRandom: string
) => Promise<StalkerProfileResponse>;
const READY: StalkerSessionConnectionOutcome = {
capabilities: {
authenticatedSession: true,
playbackContext: true,
},
connectionMode: 'persisted-open',
endpoint: 'https://portal.example/stalker_portal/server/load.php',
kind: 'ready',
landingUrl: 'https://portal.example/c/',
leaseRef: 'opaque-lease-1',
persistenceDraft: {
isFullStalkerPortal: true,
portalUrl: 'https://portal.example/stalker_portal/server/load.php',
stalkerLandingUrl: 'https://portal.example/c/',
stalkerRecipeClassifierVersion: 1,
stalkerRequestRecipe: 'full-session',
},
recipe: 'full-session',
requestId: 'request-open-1',
};
describe('StalkerSessionService identity payloads', () => {
const portalUrl =
'https://portal.example.com/stalker_portal/server/load.php';
const macAddress = '00:1A:79:AA:BB:CC';
const PROVISIONAL_READY: StalkerSessionConnectionOutcome = {
...READY,
attemptRef: 'opaque-attempt-1',
connectionMode: 'provisional',
provisionalReason: 'edit',
requestId: 'request-provisional-1',
};
function createBridge(): jest.Mocked<StalkerSessionBridge> {
return {
stalkerSessionContinue: jest.fn(),
stalkerSessionControl: jest.fn(),
stalkerSessionOpen: jest.fn(),
stalkerSessionRequest: jest.fn(),
};
}
describe('StalkerSessionService', () => {
let bridge: jest.Mocked<StalkerSessionBridge>;
let service: StalkerSessionService;
let dataService: { sendIpcEvent: jest.Mock };
beforeEach(() => {
Object.defineProperty(globalThis, 'crypto', {
configurable: true,
value: {
subtle: {
digest: jest.fn(
async () => new Uint8Array(20).fill(1).buffer
),
},
},
});
dataService = {
sendIpcEvent: jest.fn().mockResolvedValue({ js: {} }),
};
bridge = createBridge();
TestBed.configureTestingModule({
providers: [
StalkerSessionService,
{ provide: DataService, useValue: dataService },
{
provide: STALKER_SESSION_BRIDGE,
useValue: bridge,
},
],
});
service = TestBed.inject(StalkerSessionService);
});
afterEach(() => {
jest.restoreAllMocks();
});
it('opens a typed connection descriptor without forwarding legacy tokens or credentials', async () => {
bridge.stalkerSessionOpen.mockResolvedValue(READY);
it('omits SN, device IDs, and signatures from get_profile when identity is blank', async () => {
const getProfile =
service.getProfile as unknown as GetProfileWithIdentity;
await expect(service.open(PLAYLIST)).resolves.toBe(READY);
await getProfile.call(
service,
portalUrl,
macAddress,
'token-1',
{},
'random-1'
);
const payload = lastStalkerPayload();
expect(payload.serialNumber).toBeUndefined();
expect(payload.params).not.toHaveProperty('sn');
expect(payload.params).not.toHaveProperty('device_id');
expect(payload.params).not.toHaveProperty('device_id2');
expect(payload.params).not.toHaveProperty('signature');
expect(payload.params).not.toHaveProperty('signature2');
expect(JSON.parse(String(payload.params.metrics))).not.toHaveProperty(
'sn'
);
});
it('sends provided SN, device IDs, and signatures exactly in get_profile', async () => {
const getProfile =
service.getProfile as unknown as GetProfileWithIdentity;
await getProfile.call(
service,
portalUrl,
macAddress,
'token-1',
{
serialNumber: 'CUSTOMSN123',
deviceId1: 'DEVICE-ID-1',
deviceId2: 'DEVICE-ID-2',
signature1: 'SIGNATURE-1',
signature2: 'SIGNATURE-2',
},
'random-1'
);
const payload = lastStalkerPayload();
expect(payload.serialNumber).toBe('CUSTOMSN123');
expect(payload.params).toEqual(
expect.objectContaining({
sn: 'CUSTOMSN123',
device_id: 'DEVICE-ID-1',
device_id2: 'DEVICE-ID-2',
signature: 'SIGNATURE-1',
signature2: 'SIGNATURE-2',
})
);
expect(JSON.parse(String(payload.params.metrics))).toEqual(
expect.objectContaining({
sn: 'CUSTOMSN123',
})
);
});
it('passes stored playlist identity into ensureToken re-authentication', async () => {
const authenticate = jest
.spyOn(service, 'authenticate')
.mockResolvedValue({ token: 'fresh-token' });
await service.ensureToken({
_id: 'playlist-1',
portalUrl,
macAddress,
isFullStalkerPortal: true,
stalkerSerialNumber: 'CUSTOMSN123',
stalkerDeviceId1: 'DEVICE-ID-1',
stalkerDeviceId2: 'DEVICE-ID-2',
stalkerSignature1: 'SIGNATURE-1',
stalkerSignature2: 'SIGNATURE-2',
} as Playlist);
expect(authenticate).toHaveBeenCalledWith(portalUrl, macAddress, {
serialNumber: 'CUSTOMSN123',
deviceId1: 'DEVICE-ID-1',
deviceId2: 'DEVICE-ID-2',
signature1: 'SIGNATURE-1',
signature2: 'SIGNATURE-2',
});
});
it('treats the legacy default serial number as absent during ensureToken', async () => {
const authenticate = jest
.spyOn(service, 'authenticate')
.mockResolvedValue({ token: 'fresh-token' });
const result = await service.ensureToken({
_id: 'playlist-1',
portalUrl,
macAddress,
isFullStalkerPortal: true,
stalkerSerialNumber: STALKER_SERIAL_NUMBER,
} as Playlist);
expect(result.serialNumber).toBeUndefined();
expect(authenticate).toHaveBeenCalledWith(portalUrl, macAddress, {});
});
it('passes an explicit serial into the initial handshake request', async () => {
dataService.sendIpcEvent
.mockResolvedValueOnce({
js: {
token: 'token-1',
random: 'random-1',
expect(bridge.stalkerSessionOpen).toHaveBeenCalledWith({
descriptor: {
connectionMode: 'persisted-open',
learnedEndpointHint:
'https://portal.example/stalker_portal/server/load.php',
macAddress: '00:1A:79:AA:BB:CC',
playlistRef: 'playlist-1',
profilePreset: {
id: 'mag250-public-5_1-minimal-v1',
version: 1,
},
})
.mockResolvedValueOnce({ js: {} });
await service.authenticate(portalUrl, macAddress, {
serialNumber: 'CUSTOMSN123',
sourceUrl: 'https://portal.example/c/',
},
});
const handshakePayload = dataService.sendIpcEvent.mock.calls[0][1];
expect(handshakePayload.params.action).toBe('handshake');
expect(handshakePayload.serialNumber).toBe('CUSTOMSN123');
expect(
JSON.stringify(bridge.stalkerSessionOpen.mock.calls[0])
).not.toContain('legacy-token-must-not-cross');
expect(
JSON.stringify(bridge.stalkerSessionOpen.mock.calls[0])
).not.toContain('saved-password-must-not-cross');
expect(
JSON.stringify(bridge.stalkerSessionOpen.mock.calls[0])
).not.toContain('saved-user-must-not-cross');
expect(service.getLeaseRef('playlist-1')).toBe('opaque-lease-1');
});
it('does not log credentials from portal request errors', async () => {
const token = 'stalker-error-token-secret';
const consoleError = jest
.spyOn(console, 'error')
.mockImplementation(() => undefined);
dataService.sendIpcEvent.mockRejectedValue(
new Error(
`Request failed: https://portal.example/api?token=${token}&action=get_profile`
)
);
it('continues a challenge through the typed bridge and associates its ready lease with the playlist', async () => {
bridge.stalkerSessionContinue.mockResolvedValue(READY);
await expect(
service.getProfile(portalUrl, macAddress, token, {}, 'random-1')
).rejects.toThrow('Request failed');
service.continue('playlist-1', {
challengeRef: 'opaque-challenge-1',
response: {
kind: 'credentials',
password: 'portal-password',
username: 'portal-user',
},
})
).resolves.toBe(READY);
const output = consoleError.mock.calls
.flatMap((call) =>
call.map((value) =>
value instanceof Error
? `${value.message}\n${value.stack ?? ''}`
: JSON.stringify(value)
)
)
.join('\n');
expect(output).not.toContain(token);
expect(output).toContain('get_profile');
expect(bridge.stalkerSessionContinue).toHaveBeenCalledWith({
challengeRef: 'opaque-challenge-1',
response: {
kind: 'credentials',
password: 'portal-password',
username: 'portal-user',
},
});
expect(service.getLeaseRef('playlist-1')).toBe('opaque-lease-1');
});
function lastStalkerPayload(): {
params: Record<string, unknown>;
serialNumber?: string;
} {
return dataService.sendIpcEvent.mock.calls.at(-1)?.[1];
}
it('submits saved credentials only through continue after Electron requests them', async () => {
bridge.stalkerSessionOpen.mockResolvedValue({
attemptNumber: 1,
challengeRef: 'opaque-credentials-challenge',
kind: 'credentials-required',
requestId: 'request-credentials-1',
});
bridge.stalkerSessionContinue.mockResolvedValue(READY);
await expect(service.open(PLAYLIST)).resolves.toBe(READY);
expect(bridge.stalkerSessionContinue).toHaveBeenCalledWith({
challengeRef: 'opaque-credentials-challenge',
response: {
kind: 'credentials',
password: 'saved-password-must-not-cross',
username: 'saved-user-must-not-cross',
},
});
expect(service.getLeaseRef('playlist-1')).toBe('opaque-lease-1');
});
it('returns a fresh challenge instead of resubmitting rejected saved credentials', async () => {
const rejectedChallenge: StalkerSessionConnectionOutcome = {
attemptNumber: 2,
challengeRef: 'opaque-fresh-challenge',
kind: 'credentials-required',
requestId: 'request-credentials-2',
savedCredentialsRejected: true,
};
bridge.stalkerSessionOpen.mockResolvedValue(rejectedChallenge);
await expect(service.open(PLAYLIST)).resolves.toBe(rejectedChallenge);
expect(bridge.stalkerSessionContinue).not.toHaveBeenCalled();
});
it('keeps a provisional lease separate until commit and drops it on discard', async () => {
bridge.stalkerSessionOpen.mockResolvedValue(PROVISIONAL_READY);
bridge.stalkerSessionControl.mockResolvedValue({
action: 'commit',
kind: 'success',
requestId: 'request-commit-1',
});
await service.open(PLAYLIST, {
connectionMode: 'provisional',
provisionalReason: 'edit',
});
expect(service.getLeaseRef('playlist-1')).toBeUndefined();
await service.commit('opaque-attempt-1');
expect(service.getLeaseRef('playlist-1')).toBe('opaque-lease-1');
bridge.stalkerSessionOpen.mockResolvedValue({
...PROVISIONAL_READY,
attemptRef: 'opaque-attempt-2',
leaseRef: 'opaque-lease-2',
});
bridge.stalkerSessionControl.mockResolvedValue({
action: 'discard',
kind: 'success',
requestId: 'request-discard-2',
});
await service.open(PLAYLIST, {
connectionMode: 'provisional',
provisionalReason: 'edit',
});
await service.discard('opaque-attempt-2');
expect(service.getLeaseRef('playlist-1')).toBe('opaque-lease-1');
});
it('passes an operation-specific typed request through unchanged', async () => {
const outcome: StalkerSessionRequestOutcome<'get-short-epg'> = {
kind: 'success',
operation: STALKER_SESSION_APPLICATION_OPERATIONS.ShortEpg,
payload: {
programs: [],
},
requestId: 'request-epg-1',
};
bridge.stalkerSessionRequest.mockResolvedValue(outcome);
await expect(
service.request({
leaseRef: 'opaque-lease-1',
operation: STALKER_SESSION_APPLICATION_OPERATIONS.ShortEpg,
parameters: {
channelId: '42',
limit: 10,
},
})
).resolves.toBe(outcome);
expect(bridge.stalkerSessionRequest).toHaveBeenCalledWith({
leaseRef: 'opaque-lease-1',
operation: STALKER_SESSION_APPLICATION_OPERATIONS.ShortEpg,
parameters: {
channelId: '42',
limit: 10,
},
});
});
it.each([
{
call: (target: StalkerSessionService) =>
target.activate('opaque-lease-1'),
request: {
action: 'activate',
leaseRef: 'opaque-lease-1',
},
},
{
call: (target: StalkerSessionService) =>
target.deactivate('opaque-lease-1'),
request: {
action: 'deactivate',
leaseRef: 'opaque-lease-1',
},
},
{
call: (target: StalkerSessionService) =>
target.close('opaque-lease-1'),
request: {
action: 'close',
leaseRef: 'opaque-lease-1',
},
},
{
call: (target: StalkerSessionService) =>
target.forceRedetect('opaque-lease-1'),
request: {
action: 'force-redetect',
leaseRef: 'opaque-lease-1',
},
},
{
call: (target: StalkerSessionService) =>
target.commit('opaque-attempt-1'),
request: {
action: 'commit',
attemptRef: 'opaque-attempt-1',
},
},
{
call: (target: StalkerSessionService) =>
target.discard('opaque-attempt-1'),
request: {
action: 'discard',
attemptRef: 'opaque-attempt-1',
},
},
] as const)(
'routes $request.action through session control',
async ({ call, request }) => {
const outcome: StalkerSessionControlOutcome = {
action: request.action,
kind: 'success',
requestId: `request-${request.action}`,
};
bridge.stalkerSessionControl.mockResolvedValue(outcome);
await expect(call(service)).resolves.toBe(outcome);
expect(bridge.stalkerSessionControl).toHaveBeenCalledWith(request);
}
);
it('adapts a current raw application call to a typed lease request and maps the result back to the legacy response shape', async () => {
bridge.stalkerSessionOpen.mockResolvedValue(READY);
bridge.stalkerSessionRequest.mockResolvedValue({
kind: 'success',
operation: STALKER_SESSION_APPLICATION_OPERATIONS.CatalogCategories,
payload: {
items: [
{
censored: true,
id: '7',
name: 'Movies',
},
],
},
requestId: 'request-categories-1',
});
await expect(
service.makeAuthenticatedRequest(PLAYLIST, {
action: 'get_categories',
type: 'vod',
})
).resolves.toEqual({
js: [
{
censored: 1,
id: '7',
title: 'Movies',
},
],
});
expect(bridge.stalkerSessionRequest).toHaveBeenCalledWith({
leaseRef: 'opaque-lease-1',
operation: STALKER_SESSION_APPLICATION_OPERATIONS.CatalogCategories,
parameters: {
contentType: 'vod',
},
});
});
it('surfaces non-success outcomes without attempting renderer-side authentication', async () => {
const failure: StalkerSessionConnectionOutcome = {
kind: 'failure',
reason: 'account-access-denied',
requestId: 'request-open-failure',
retryable: false,
stage: 'profile-first',
};
bridge.stalkerSessionOpen.mockResolvedValue(failure);
await expect(
service.makeAuthenticatedRequest(PLAYLIST, {
action: 'get_categories',
type: 'vod',
})
).rejects.toEqual(new StalkerSessionOutcomeError(failure));
expect(bridge.stalkerSessionRequest).not.toHaveBeenCalled();
});
it('fails closed when the typed Electron bridge is unavailable', async () => {
TestBed.resetTestingModule();
TestBed.configureTestingModule({
providers: [
StalkerSessionService,
{
provide: STALKER_SESSION_BRIDGE,
useValue: undefined,
},
],
});
service = TestBed.inject(StalkerSessionService);
expect(service.supportsTypedSessions()).toBe(false);
await expect(service.open(PLAYLIST)).rejects.toThrow(
'stalker-session-bridge-unavailable'
);
});
it('exposes no renderer token cache, raw auth steps, watchdog timers, or token getter', () => {
const intervalSpy = jest.spyOn(globalThis, 'setInterval');
TestBed.resetTestingModule();
TestBed.configureTestingModule({
providers: [
StalkerSessionService,
{
provide: STALKER_SESSION_BRIDGE,
useValue: bridge,
},
],
});
service = TestBed.inject(StalkerSessionService);
const forbiddenSurface = [
'authenticate',
'clearCachedToken',
'doAuth',
'ensureToken',
'getCachedToken',
'getProfile',
'performHandshake',
'setActiveWatchdogPlaylist',
'setCachedToken',
];
for (const name of forbiddenSurface) {
expect(name in service).toBe(false);
}
expect(intervalSpy).not.toHaveBeenCalled();
});
});
@@ -1,616 +1,426 @@
import { Injectable, inject } from '@angular/core';
import { Playlist, STALKER_REQUEST } from '@iptvnator/shared/interfaces';
import { DataService } from '@iptvnator/services';
import { createLogger } from '@iptvnator/portal/shared/util';
import { Injectable, InjectionToken, inject } from '@angular/core';
import {
getStalkerPortalIdentityFromPlaylist,
LEGACY_DEFAULT_STALKER_SERIAL,
type ElectronBridgeApi,
type Playlist,
type StalkerSessionApplicationOperation,
type StalkerSessionAttemptRef,
type StalkerSessionConnectionOutcome,
type StalkerSessionContinueRequest,
type StalkerSessionControlOutcome,
type StalkerSessionControlRequest,
type StalkerSessionLeaseRef,
type StalkerSessionRequest,
type StalkerSessionRequestOutcome,
} from '@iptvnator/shared/interfaces';
import {
type StalkerAdaptedRequest,
adaptLegacyStalkerRequest,
mapStalkerSessionResultToLegacyResponse,
} from './stalker-request-adapter';
import {
mapPlaylistToStalkerSessionConnection,
type StalkerSavedCredentials,
type StalkerSessionDescriptorMappingOptions as LocalStalkerSessionDescriptorMappingOptions,
} from './stalker-session-descriptor';
export {
getStalkerPortalIdentityFromPlaylist,
normalizeStalkerPortalIdentity,
type StalkerPortalIdentity,
} from './stalker-identity.utils';
export { getStalkerPortalIdentityFromPlaylist, normalizeStalkerPortalIdentity };
export type { StalkerPortalIdentity };
/**
* SHA1 hash using native Web Crypto API
* Produces correct 40-character hex hash matching real Stalker clients
*/
async function sha1(str: string): Promise<string> {
const encoder = new TextEncoder();
const data = encoder.encode(str);
const hashBuffer = await crypto.subtle.digest('SHA-1', data);
const hashArray = Array.from(new Uint8Array(hashBuffer));
return hashArray.map((b) => b.toString(16).padStart(2, '0')).join('');
}
/**
* Generates SHA1 prehash from MAC address
* This must match what real Stalker clients send
*/
async function generatePrehash(macAddress: string): Promise<string> {
// Use MAC address with colons, uppercase - this is what most clients use
const str = macAddress.toUpperCase();
return (await sha1(str)).toUpperCase();
}
/**
* Generates a random string for metrics
*/
function generateRandom(): string {
const chars = 'abcdef0123456789';
let result = '';
for (let i = 0; i < 40; i++) {
result += chars.charAt(Math.floor(Math.random() * chars.length));
}
return result;
}
export type { StalkerPortalIdentity } from './stalker-identity.utils';
export const STALKER_SERIAL_NUMBER = LEGACY_DEFAULT_STALKER_SERIAL;
const STALKER_WATCHDOG_INTERVAL_MS = 25_000;
export interface StalkerHandshakeResponse {
js: {
token: string;
not_valid?: number;
random?: string;
};
}
type SessionBridgeMethod =
| 'stalkerSessionContinue'
| 'stalkerSessionControl'
| 'stalkerSessionOpen'
| 'stalkerSessionRequest';
export interface StalkerProfileResponse {
js: {
id?: string;
name?: string;
mac?: string;
status?: number;
msg?: string;
block_msg?: string;
account_info?: {
login?: string;
expire_date?: number;
tariff_plan_name?: string;
status?: number;
};
};
}
export type StalkerSessionBridge = Required<
Pick<ElectronBridgeApi, SessionBridgeMethod>
>;
interface StalkerAuthConfirmationResponse {
js?: boolean;
export const STALKER_SESSION_BRIDGE = new InjectionToken<
StalkerSessionBridge | undefined
>('STALKER_SESSION_BRIDGE', {
factory: resolveStalkerSessionBridge,
providedIn: 'root',
});
type StalkerSessionNonSuccessOutcome =
| StalkerSessionConnectionOutcome
| Exclude<
StalkerSessionRequestOutcome<StalkerSessionApplicationOperation>,
{ kind: 'success' }
>;
export class StalkerSessionOutcomeError extends Error {
constructor(readonly outcome: StalkerSessionNonSuccessOutcome) {
super(`stalker-session-outcome:${outcome.kind}`);
this.name = 'StalkerSessionOutcomeError';
}
}
/**
* Service to manage Stalker portal session tokens.
* Handles handshake authentication for full stalker portals (/stalker_portal/c URLs).
* Persists tokens during session and handles re-authentication on auth failures.
* Renderer facade for main-owned Stalker sessions.
*
* The only retained session state is the association between a playlist row and
* an opaque main-process lease. Protocol state, credentials, cookies, refresh
* work, playback headers, and bearer material are never retained here.
*/
@Injectable({
providedIn: 'root',
})
export class StalkerSessionService {
private dataService = inject(DataService);
private readonly logger = createLogger('StalkerSession');
// In-memory token cache for current session (keyed by playlist ID)
private tokenCache = new Map<string, string>();
// Pending authentication promises to prevent race conditions
// When multiple requests need a token simultaneously, they all wait for the same auth
private pendingAuth = new Map<
private readonly bridge = inject(STALKER_SESSION_BRIDGE);
private readonly leaseByPlaylistRef = new Map<
string,
Promise<{ token: string; serialNumber?: string }>
StalkerSessionLeaseRef
>();
private watchdogIntervals = new Map<
private readonly playlistRefByLease = new Map<
StalkerSessionLeaseRef,
string
>();
private readonly provisionalByAttemptRef = new Map<
StalkerSessionAttemptRef,
{
readonly leaseRef: StalkerSessionLeaseRef;
readonly playlistRef: string;
}
>();
private readonly attemptRefByProvisionalLease = new Map<
StalkerSessionLeaseRef,
StalkerSessionAttemptRef
>();
private readonly openByPlaylistRef = new Map<
string,
ReturnType<typeof setInterval>
Promise<StalkerSessionConnectionOutcome>
>();
private watchdogPlaylists = new Map<string, Playlist>();
private watchdogInFlight = new Set<string>();
private activeWatchdogPlaylistId: string | null = null;
/**
* Checks if a URL is a full stalker portal URL (requires handshake)
* Full stalker portal URLs contain /stalker_portal/ in the path
*/
supportsTypedSessions(): boolean {
return this.bridge !== undefined;
}
isFullStalkerPortal(url: string): boolean {
return (
url.includes('/stalker_portal/') || url.includes('/server/load.php')
);
}
/**
* Gets the cached token for a playlist, or null if not cached
*/
getCachedToken(playlistId: string): string | null {
return this.tokenCache.get(playlistId) || null;
getLeaseRef(playlistRef: string): StalkerSessionLeaseRef | undefined {
return this.leaseByPlaylistRef.get(playlistRef);
}
/**
* Sets a token in the cache
*/
setCachedToken(playlistId: string, token: string): void {
this.tokenCache.set(playlistId, token);
}
/**
* Clears the cached token for a playlist (e.g., on auth failure)
*/
clearCachedToken(playlistId: string): void {
this.tokenCache.delete(playlistId);
}
/**
* Sets which playlist should receive periodic watchdog pings.
* This keeps some Ministra/Stalker sessions alive for live playback.
*/
setActiveWatchdogPlaylist(playlist?: Playlist | null): void {
const nextPlaylistId = playlist?._id ?? null;
if (
this.activeWatchdogPlaylistId &&
this.activeWatchdogPlaylistId !== nextPlaylistId
) {
this.stopWatchdog(this.activeWatchdogPlaylistId);
}
this.activeWatchdogPlaylistId = nextPlaylistId;
if (
!playlist ||
!playlist.isFullStalkerPortal ||
!playlist.portalUrl ||
!playlist.macAddress
) {
if (nextPlaylistId) {
this.stopWatchdog(nextPlaylistId);
async open(
playlist: Playlist,
options: LocalStalkerSessionDescriptorMappingOptions = {}
): Promise<StalkerSessionConnectionOutcome> {
const { descriptor, savedCredentials } =
mapPlaylistToStalkerSessionConnection(playlist, options);
const tracksPersistedOpen =
descriptor.connectionMode === 'persisted-open';
if (tracksPersistedOpen) {
const pending = this.openByPlaylistRef.get(descriptor.playlistRef);
if (pending !== undefined) {
return pending;
}
return;
}
this.startWatchdog(playlist);
}
private startWatchdog(playlist: Playlist): void {
const playlistId = playlist._id;
this.watchdogPlaylists.set(playlistId, playlist);
if (this.watchdogIntervals.has(playlistId)) {
return;
const bridge = this.requireBridge();
const request = this.openWithSavedCredentials(
bridge,
{ descriptor },
savedCredentials
);
if (tracksPersistedOpen) {
this.openByPlaylistRef.set(descriptor.playlistRef, request);
}
void this.sendWatchdogPing(playlistId, '1');
const intervalId = setInterval(() => {
void this.sendWatchdogPing(playlistId, '0');
}, STALKER_WATCHDOG_INTERVAL_MS);
this.watchdogIntervals.set(playlistId, intervalId);
}
private stopWatchdog(playlistId: string): void {
const intervalId = this.watchdogIntervals.get(playlistId);
if (intervalId) {
clearInterval(intervalId);
this.watchdogIntervals.delete(playlistId);
}
this.watchdogPlaylists.delete(playlistId);
this.watchdogInFlight.delete(playlistId);
}
private async sendWatchdogPing(
playlistId: string,
init: '0' | '1'
): Promise<void> {
if (this.watchdogInFlight.has(playlistId)) {
return;
}
const playlist = this.watchdogPlaylists.get(playlistId);
if (
!playlist ||
!playlist.portalUrl ||
!playlist.macAddress ||
!playlist.isFullStalkerPortal
) {
this.stopWatchdog(playlistId);
return;
}
this.watchdogInFlight.add(playlistId);
try {
await this.makeAuthenticatedRequest(
playlist,
{
type: 'watchdog',
action: 'get_events',
event_active_id: '0',
cur_play_type: '0',
init,
JsHttpRequest: '1-xml',
},
false
);
} catch (error) {
// Keep failures non-fatal; next interval can recover after token refresh.
this.logger.warn('Watchdog ping failed:', error);
const outcome = await request;
this.rememberConnectionOutcome(descriptor.playlistRef, outcome);
return outcome;
} finally {
this.watchdogInFlight.delete(playlistId);
}
}
/**
* Performs handshake to get a session token for a full stalker portal
* Returns both the token and the random value for use in subsequent requests
*/
async performHandshake(
portalUrl: string,
macAddress: string,
identity: StalkerPortalIdentity = {}
): Promise<{ token: string; random: string }> {
const normalizedIdentity = normalizeStalkerPortalIdentity(identity);
const prehash = await generatePrehash(macAddress);
const params: Record<string, string> = {
type: 'stb',
action: 'handshake',
token: '',
prehash,
JsHttpRequest: '1-xml',
};
try {
const response: StalkerHandshakeResponse =
await this.dataService.sendIpcEvent<StalkerHandshakeResponse>(
STALKER_REQUEST,
{
url: portalUrl,
macAddress,
params,
...(normalizedIdentity.serialNumber
? { serialNumber: normalizedIdentity.serialNumber }
: {}),
}
);
if (response?.js?.token) {
return {
token: response.js.token,
random: response.js.random || generateRandom(),
};
if (
tracksPersistedOpen &&
this.openByPlaylistRef.get(descriptor.playlistRef) === request
) {
this.openByPlaylistRef.delete(descriptor.playlistRef);
}
this.logger.error('No token in response');
throw new Error('Handshake failed: No token received');
} catch (error) {
this.logger.error('Handshake error:', error);
throw error;
}
}
/**
* Gets account profile information to validate the portal and check subscription
* Based on working implementation from stalker-to-m3u repo
*/
async getProfile(
portalUrl: string,
macAddress: string,
token: string,
identity: StalkerPortalIdentity,
handshakeRandom: string
): Promise<StalkerProfileResponse> {
const normalizedIdentity = normalizeStalkerPortalIdentity(identity);
async continue(
playlistRef: string,
request: StalkerSessionContinueRequest
): Promise<StalkerSessionConnectionOutcome> {
const outcome =
await this.requireBridge().stalkerSessionContinue(request);
this.rememberConnectionOutcome(playlistRef, outcome);
return outcome;
}
// Build metrics JSON matching working app
const metrics: Record<string, string> = {
mac: macAddress,
model: 'MAG250',
type: 'STB',
random: handshakeRandom,
...(normalizedIdentity.serialNumber
? { sn: normalizedIdentity.serialNumber }
: {}),
};
request<Operation extends StalkerSessionApplicationOperation>(
request: StalkerSessionRequest<Operation>
): Promise<StalkerSessionRequestOutcome<Operation>> {
return this.requireBridge().stalkerSessionRequest(request);
}
// Generate prehash for get_profile (same as handshake)
const prehash = await generatePrehash(macAddress);
async control(
request: StalkerSessionControlRequest
): Promise<StalkerSessionControlOutcome> {
const playlistRef =
'leaseRef' in request
? this.playlistRefByLease.get(request.leaseRef)
: undefined;
const provisional =
'attemptRef' in request
? this.provisionalByAttemptRef.get(request.attemptRef)
: undefined;
const outcome =
await this.requireBridge().stalkerSessionControl(request);
// Profile request matching working StalkerTV app
// auth_second_step=1, includes metrics, prehash, and device_id params
const params: Record<string, string> = {
type: 'stb',
action: 'get_profile',
hd: '1',
not_valid_token: '0',
video_out: 'hdmi',
auth_second_step: '1',
num_banks: '2',
metrics: JSON.stringify(metrics),
...(normalizedIdentity.serialNumber
? { sn: normalizedIdentity.serialNumber }
: {}),
...(normalizedIdentity.deviceId1
? { device_id: normalizedIdentity.deviceId1 }
: {}),
...(normalizedIdentity.deviceId2
? { device_id2: normalizedIdentity.deviceId2 }
: {}),
...(normalizedIdentity.signature1
? { signature: normalizedIdentity.signature1 }
: {}),
...(normalizedIdentity.signature2
? { signature2: normalizedIdentity.signature2 }
: {}),
prehash: prehash,
stb_type: '',
JsHttpRequest: '1-xml',
};
try {
const response: StalkerProfileResponse =
await this.dataService.sendIpcEvent<StalkerProfileResponse>(
STALKER_REQUEST,
{
url: portalUrl,
macAddress,
params,
token,
...(normalizedIdentity.serialNumber
? { serialNumber: normalizedIdentity.serialNumber }
: {}),
}
);
return response;
} catch (error) {
this.logger.error('Get profile error:', error);
throw error;
if (outcome.kind === 'success' && request.action === 'close') {
this.forgetLease(request.leaseRef);
this.forgetProvisionalLease(request.leaseRef);
} else if (
outcome.kind === 'success' &&
request.action === 'commit' &&
provisional !== undefined
) {
this.rememberLease(provisional.playlistRef, provisional.leaseRef);
this.forgetProvisionalAttempt(request.attemptRef);
} else if (outcome.kind === 'success' && request.action === 'discard') {
this.forgetProvisionalAttempt(request.attemptRef);
} else if (playlistRef !== undefined && outcome.kind === 'ready') {
this.rememberConnectionOutcome(playlistRef, outcome);
} else if (provisional !== undefined && outcome.kind === 'ready') {
this.rememberConnectionOutcome(provisional.playlistRef, outcome);
}
return outcome;
}
activate(
leaseRef: StalkerSessionLeaseRef
): Promise<StalkerSessionControlOutcome> {
return this.control({
action: 'activate',
leaseRef,
});
}
deactivate(
leaseRef: StalkerSessionLeaseRef
): Promise<StalkerSessionControlOutcome> {
return this.control({
action: 'deactivate',
leaseRef,
});
}
close(
leaseRef: StalkerSessionLeaseRef
): Promise<StalkerSessionControlOutcome> {
return this.control({
action: 'close',
leaseRef,
});
}
forceRedetect(
leaseRef: StalkerSessionLeaseRef
): Promise<StalkerSessionControlOutcome> {
return this.control({
action: 'force-redetect',
leaseRef,
});
}
commit(
attemptRef: StalkerSessionAttemptRef
): Promise<StalkerSessionControlOutcome> {
return this.control({
action: 'commit',
attemptRef,
});
}
discard(
attemptRef: StalkerSessionAttemptRef
): Promise<StalkerSessionControlOutcome> {
return this.control({
action: 'discard',
attemptRef,
});
}
/**
* Performs do_auth to authenticate the session after handshake
*/
async doAuth(
portalUrl: string,
macAddress: string,
token: string
): Promise<boolean> {
const params: Record<string, string> = {
type: 'stb',
action: 'do_auth',
login: '',
password: '',
JsHttpRequest: '1-xml',
};
try {
const response =
await this.dataService.sendIpcEvent<StalkerAuthConfirmationResponse>(
STALKER_REQUEST,
{
url: portalUrl,
macAddress,
params,
token,
}
);
// do_auth returns { js: true } on success
if (response?.js === true) {
return true;
}
return false;
} catch (error) {
this.logger.error('do_auth error:', error);
throw error;
}
}
/**
* Performs full authentication flow: handshake -> get_profile (NO do_auth based on working traces)
* Returns the token and account info if successful
*/
async authenticate(
portalUrl: string,
macAddress: string,
identity: StalkerPortalIdentity = {}
): Promise<{
token: string;
accountInfo?: StalkerProfileResponse['js']['account_info'];
}> {
const normalizedIdentity = normalizeStalkerPortalIdentity(identity);
// Step 1: Handshake to get token and random
const { token, random } = await this.performHandshake(
portalUrl,
macAddress,
normalizedIdentity
);
// Step 2: Get profile to activate token and get account info
// The random from handshake must be used in auth_second_step
try {
const profileResponse = await this.getProfile(
portalUrl,
macAddress,
token,
normalizedIdentity,
random
);
// Check for profile-level errors
if (profileResponse?.js?.msg || profileResponse?.js?.block_msg) {
const errorMsg =
profileResponse.js.msg ||
profileResponse.js.block_msg ||
'Unknown profile error';
this.logger.error('Profile error:', errorMsg);
throw new Error(`Profile error: ${errorMsg}`);
}
return {
token,
accountInfo: profileResponse?.js?.account_info,
};
} catch (error) {
// Profile fetch failed - this is a real error, propagate it
this.logger.error('Profile fetch failed:', error);
throw error;
}
}
/**
* Ensures a valid token exists for a playlist, performing full auth if needed
* IMPORTANT: Based on working traces, each session needs handshake + get_profile
* Returns the token to use for requests, and the serial number to store
*/
async ensureToken(
playlist: Playlist
): Promise<{ token: string | null; serialNumber?: string }> {
// If not a full stalker portal, no token needed
if (!playlist.isFullStalkerPortal) {
return { token: null };
}
const identity = getStalkerPortalIdentityFromPlaylist(playlist);
// Check in-memory cache first (valid for current session only)
const cachedToken = this.getCachedToken(playlist._id);
if (cachedToken) {
return { token: cachedToken, serialNumber: identity.serialNumber };
}
// Check if there's already a pending authentication for this playlist
// This prevents race conditions when multiple resources request a token simultaneously
const pendingPromise = this.pendingAuth.get(playlist._id);
if (pendingPromise) {
this.logger.debug('Waiting for pending authentication...');
return pendingPromise;
}
// No cached token - need to do full authentication (handshake + get_profile)
// Don't trust stored tokens as they may be from a different session
if (!playlist.portalUrl || !playlist.macAddress) {
this.logger.error('Missing portal URL or MAC address');
throw new Error('Portal URL and MAC address are required');
}
const portalUrl = playlist.portalUrl;
const macAddress = playlist.macAddress;
// Create the authentication promise and store it to prevent concurrent auth attempts
// Use async/await wrapper to properly clean up on both success and failure
const authPromise = (async () => {
try {
const { token } = await this.authenticate(
portalUrl,
macAddress,
identity
);
this.setCachedToken(playlist._id, token);
return { token, serialNumber: identity.serialNumber };
} finally {
// Clean up pending promise regardless of success/failure
this.pendingAuth.delete(playlist._id);
}
})();
// Store the pending promise so other concurrent requests can wait on it
this.pendingAuth.set(playlist._id, authPromise);
return authPromise;
}
/**
* Checks if a response or error indicates an authorization failure
*/
private isAuthorizationError(responseOrError: unknown): boolean {
if (!responseOrError) return false;
const response = responseOrError as Record<string, unknown>;
// Convert response to string for pattern matching
const responseStr = JSON.stringify(responseOrError).toLowerCase();
// Check for "Authorization failed. XX" pattern (like "Authorization failed. 75")
if (/authorization\s*failed\.?\s*\d*/i.test(responseStr)) {
return true;
}
// Check for common auth failure indicators
const jsData = response?.['js'] as Record<string, unknown>;
const errorMessage =
(response?.['message'] as string)?.toLowerCase?.() ||
jsData?.['error']?.toString?.().toLowerCase?.() ||
jsData?.['msg']?.toString?.().toLowerCase?.() ||
'';
return (
errorMessage.includes('authorization') ||
errorMessage.includes('unauthorized') ||
errorMessage.includes('auth failed') ||
errorMessage.includes('invalid token') ||
response?.['status'] === 401 ||
jsData?.['error'] === 'Authorization failed'
);
}
/**
* Wrapper for making stalker requests with automatic token handling and retry on auth failure
* This should be used by all stalker API calls to ensure proper auth handling
* Temporary application-call compatibility seam.
*
* Existing callers may keep their raw catalog request shape while the
* adapter converts it to an allowlisted operation. This method never sends
* those raw parameters to Electron and never performs renderer-side auth.
*/
async makeAuthenticatedRequest<T>(
playlist: Playlist,
params: Record<string, string | number>,
retryOnAuthFailure = true
parameters: Readonly<Record<string, string | number>>
): Promise<T> {
// Get token (will wait if auth is in progress)
const { token, serialNumber } = await this.ensureToken(playlist);
const adapted = adaptLegacyStalkerRequest(parameters);
const leaseRef = await this.resolveLease(playlist);
const result = await this.executeAdaptedRequest(leaseRef, adapted);
return result as T;
}
try {
const response = await this.dataService.sendIpcEvent<T>(
STALKER_REQUEST,
{
url: playlist.portalUrl,
macAddress: playlist.macAddress,
params,
token,
...(serialNumber ? { serialNumber } : {}),
private async resolveLease(
playlist: Playlist
): Promise<StalkerSessionLeaseRef> {
const retained = this.getLeaseRef(playlist._id);
if (retained !== undefined) {
return retained;
}
const outcome = await this.open(playlist);
if (outcome.kind !== 'ready' || outcome.recipe !== 'full-session') {
throw new StalkerSessionOutcomeError(outcome);
}
return outcome.leaseRef;
}
private async openWithSavedCredentials(
bridge: StalkerSessionBridge,
request: Parameters<StalkerSessionBridge['stalkerSessionOpen']>[0],
savedCredentials: StalkerSavedCredentials | undefined
): Promise<StalkerSessionConnectionOutcome> {
const outcome = await bridge.stalkerSessionOpen(request);
if (
outcome.kind !== 'credentials-required' ||
outcome.savedCredentialsRejected === true ||
savedCredentials === undefined
) {
return outcome;
}
return bridge.stalkerSessionContinue({
challengeRef: outcome.challengeRef,
response: {
kind: 'credentials',
password: savedCredentials.password,
username: savedCredentials.username,
},
});
}
private async executeAdaptedRequest(
leaseRef: StalkerSessionLeaseRef,
adaptedRequest: StalkerAdaptedRequest
): Promise<unknown> {
const outcome = await this.request({
leaseRef,
operation: adaptedRequest.operation,
parameters: adaptedRequest.parameters,
} as StalkerSessionRequest);
if (outcome.kind !== 'success') {
throw new StalkerSessionOutcomeError(outcome);
}
return mapStalkerSessionResultToLegacyResponse(
outcome.operation,
outcome.payload
);
}
private rememberConnectionOutcome(
playlistRef: string,
outcome: StalkerSessionConnectionOutcome
): void {
if (outcome.kind !== 'ready') {
return;
}
if (outcome.recipe !== 'full-session') {
if (outcome.connectionMode === 'persisted-open') {
const previousLease = this.leaseByPlaylistRef.get(playlistRef);
if (previousLease !== undefined) {
this.forgetLease(previousLease);
}
}
return;
}
if (outcome.connectionMode === 'provisional') {
this.provisionalByAttemptRef.set(outcome.attemptRef, {
leaseRef: outcome.leaseRef,
playlistRef,
});
this.attemptRefByProvisionalLease.set(
outcome.leaseRef,
outcome.attemptRef
);
return;
}
// Check for authorization failure in response
if (this.isAuthorizationError(response)) {
if (retryOnAuthFailure && playlist.isFullStalkerPortal) {
// Clear cached token to force re-authentication
this.clearCachedToken(playlist._id);
// Retry once with fresh authentication
return this.makeAuthenticatedRequest<T>(
playlist,
params,
false
);
}
this.rememberLease(playlistRef, outcome.leaseRef);
}
throw new Error('Authorization failed after retry');
}
private rememberLease(
playlistRef: string,
leaseRef: StalkerSessionLeaseRef
): void {
const previousLease = this.leaseByPlaylistRef.get(playlistRef);
if (previousLease !== undefined && previousLease !== leaseRef) {
this.playlistRefByLease.delete(previousLease);
}
this.leaseByPlaylistRef.set(playlistRef, leaseRef);
this.playlistRefByLease.set(leaseRef, playlistRef);
}
return response;
} catch (error) {
// Check if error indicates auth failure
if (
this.isAuthorizationError(error) &&
retryOnAuthFailure &&
playlist.isFullStalkerPortal
) {
// Clear cached token and retry with new handshake
this.clearCachedToken(playlist._id);
return this.makeAuthenticatedRequest<T>(
playlist,
params,
false
);
}
throw error;
private forgetLease(leaseRef: StalkerSessionLeaseRef): void {
const playlistRef = this.playlistRefByLease.get(leaseRef);
if (playlistRef !== undefined) {
this.leaseByPlaylistRef.delete(playlistRef);
}
this.playlistRefByLease.delete(leaseRef);
}
private forgetProvisionalLease(leaseRef: StalkerSessionLeaseRef): void {
const attemptRef = this.attemptRefByProvisionalLease.get(leaseRef);
if (attemptRef !== undefined) {
this.forgetProvisionalAttempt(attemptRef);
}
}
private forgetProvisionalAttempt(
attemptRef: StalkerSessionAttemptRef
): void {
const provisional = this.provisionalByAttemptRef.get(attemptRef);
if (provisional !== undefined) {
this.attemptRefByProvisionalLease.delete(provisional.leaseRef);
}
this.provisionalByAttemptRef.delete(attemptRef);
}
private requireBridge(): StalkerSessionBridge {
if (this.bridge === undefined) {
throw new Error('stalker-session-bridge-unavailable');
}
return this.bridge;
}
}
function resolveStalkerSessionBridge(): StalkerSessionBridge | undefined {
if (typeof window === 'undefined') {
return undefined;
}
const electron = (
window as unknown as {
electron?: ElectronBridgeApi;
}
).electron;
if (
typeof electron?.stalkerSessionOpen !== 'function' ||
typeof electron.stalkerSessionContinue !== 'function' ||
typeof electron.stalkerSessionRequest !== 'function' ||
typeof electron.stalkerSessionControl !== 'function'
) {
return undefined;
}
return electron as StalkerSessionBridge;
}
@@ -1,41 +1,50 @@
import { PlaylistMeta, STALKER_REQUEST } from '@iptvnator/shared/interfaces';
import {
type PlaylistMeta,
STALKER_REQUEST,
} from '@iptvnator/shared/interfaces';
import {
executeStalkerRequest,
type StalkerRequestDeps,
} from './stalker-request.utils';
const CATEGORY_PARAMS = {
type: 'itv',
action: 'get_genres',
type: 'itv',
};
function createDeps(): StalkerRequestDeps {
function createDeps(typedSessionsAvailable: boolean): StalkerRequestDeps {
return {
dataService: {
sendIpcEvent: jest.fn().mockResolvedValue({ js: [] }),
},
stalkerSession: {
makeAuthenticatedRequest: jest.fn().mockResolvedValue({ js: [] }),
supportsTypedSessions: jest
.fn()
.mockReturnValue(typedSessionsAvailable),
},
} as unknown as StalkerRequestDeps;
}
function fullPlaylist(): PlaylistMeta {
return {
_id: 'stalker-full',
isFullStalkerPortal: true,
macAddress: 'has-mac-address',
portalUrl: 'https://portal.example.test/stalker_portal/server/load.php',
stalkerDeviceId1: 'has-device-id-1',
stalkerDeviceId2: 'has-device-id-2',
stalkerSerialNumber: 'has-serial',
stalkerSignature1: 'has-signature-1',
stalkerSignature2: 'has-signature-2',
title: 'Full Stalker Portal',
} as PlaylistMeta;
}
describe('executeStalkerRequest', () => {
it('routes full Stalker portal category requests through the authenticated session path', async () => {
const deps = createDeps();
const playlist = {
_id: 'stalker-full',
title: 'Full Stalker Portal',
portalUrl:
'https://portal.example.test/stalker_portal/server/load.php',
macAddress: 'has-mac-address',
isFullStalkerPortal: true,
stalkerSerialNumber: 'has-serial',
stalkerDeviceId1: 'has-device-id-1',
stalkerDeviceId2: 'has-device-id-2',
stalkerSignature1: 'has-signature-1',
stalkerSignature2: 'has-signature-2',
} as PlaylistMeta;
it('routes a full Electron portal through the typed session compatibility adapter', async () => {
const deps = createDeps(true);
const playlist = fullPlaylist();
await executeStalkerRequest(deps, playlist, CATEGORY_PARAMS);
@@ -51,14 +60,14 @@ describe('executeStalkerRequest', () => {
expect(deps.dataService.sendIpcEvent).not.toHaveBeenCalled();
});
it('keeps lightweight Stalker portal requests on the IPC path', async () => {
const deps = createDeps();
it('keeps a simple/stateless portal byte-compatible on STALKER_REQUEST even when typed sessions exist', async () => {
const deps = createDeps(true);
const playlist = {
_id: 'stalker-basic',
title: 'Basic Stalker Portal',
portalUrl: 'https://portal.example.test/load.php',
macAddress: 'has-mac-address',
isFullStalkerPortal: false,
macAddress: 'has-mac-address',
portalUrl: 'https://portal.example.test/load.php',
title: 'Basic Stalker Portal',
} as PlaylistMeta;
await executeStalkerRequest(deps, playlist, CATEGORY_PARAMS);
@@ -71,6 +80,33 @@ describe('executeStalkerRequest', () => {
params: CATEGORY_PARAMS,
}
);
expect(
(deps.dataService.sendIpcEvent as jest.Mock).mock.calls[0]?.[1]
?.params
).toBe(CATEGORY_PARAMS);
expect(
deps.stalkerSession.makeAuthenticatedRequest
).not.toHaveBeenCalled();
});
it('keeps the PWA adapter byte-compatible on STALKER_REQUEST even for a persisted full-portal row', async () => {
const deps = createDeps(false);
const playlist = fullPlaylist();
await executeStalkerRequest(deps, playlist, CATEGORY_PARAMS);
expect(deps.dataService.sendIpcEvent).toHaveBeenCalledWith(
STALKER_REQUEST,
{
url: playlist.portalUrl,
macAddress: playlist.macAddress,
params: CATEGORY_PARAMS,
}
);
expect(
(deps.dataService.sendIpcEvent as jest.Mock).mock.calls[0]?.[1]
?.params
).toBe(CATEGORY_PARAMS);
expect(
deps.stalkerSession.makeAuthenticatedRequest
).not.toHaveBeenCalled();
@@ -1,5 +1,9 @@
import { DataService } from '@iptvnator/services';
import { Playlist, PlaylistMeta, STALKER_REQUEST } from '@iptvnator/shared/interfaces';
import {
Playlist,
PlaylistMeta,
STALKER_REQUEST,
} from '@iptvnator/shared/interfaces';
import { StalkerSessionService } from '../../stalker-session.service';
export interface StalkerRequestDeps {
@@ -19,7 +23,10 @@ export async function executeStalkerRequest<T>(
playlist: PlaylistMeta,
params: Record<string, string | number>
): Promise<T> {
if (playlist.isFullStalkerPortal) {
if (
playlist.isFullStalkerPortal &&
supportsTypedSessions(deps.stalkerSession)
) {
return deps.stalkerSession.makeAuthenticatedRequest<T>(
toStalkerSessionPlaylist(playlist),
params
@@ -32,3 +39,12 @@ export async function executeStalkerRequest<T>(
params,
});
}
function supportsTypedSessions(stalkerSession: StalkerSessionService): boolean {
const probe = (
stalkerSession as Partial<
Pick<StalkerSessionService, 'supportsTypedSessions'>
>
).supportsTypedSessions;
return typeof probe !== 'function' || probe.call(stalkerSession);
}