mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-10 10:06:15 -08:00
refactor(stalker): route renderer requests through session leases
This commit is contained in:
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;
|
||||
}
|
||||
+59
-23
@@ -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);
|
||||
}
|
||||
Reference in new issue
Block a user