feat(stalker): map typed portal operations in main

This commit is contained in:
4gray committed 2026-07-27 10:32:14 +02:00
1 parent df92bae87e
commit d5acb329e0
3 files changed
+1267 -4

No files matched your search

@@ -0,0 +1,410 @@
import {
STALKER_SESSION_APPLICATION_OPERATIONS,
type StalkerSessionApplicationOperation,
type StalkerSessionOperationParameters,
} from '@iptvnator/shared/interfaces';
import { mapRawOperation } from './stalker-operation-adapter';
function mapRemote<Operation extends StalkerSessionApplicationOperation>(
operation: Operation,
parameters: StalkerSessionOperationParameters<Operation>
) {
const mapped = mapRawOperation(operation, parameters);
if (mapped.kind !== 'remote') {
throw new Error('expected-remote-operation');
}
return mapped;
}
describe('stalker-operation-adapter', () => {
it.each([
{
operation: STALKER_SESSION_APPLICATION_OPERATIONS.CatalogCategories,
parameters: { contentType: 'vod' },
wire: {
action: 'get_categories',
JsHttpRequest: '1-xml',
type: 'vod',
},
},
{
operation: STALKER_SESSION_APPLICATION_OPERATIONS.CatalogGenres,
parameters: {},
wire: {
action: 'get_genres',
JsHttpRequest: '1-xml',
type: 'itv',
},
},
{
operation: STALKER_SESSION_APPLICATION_OPERATIONS.CatalogItems,
parameters: {
category: '7',
contentType: 'series',
genre: '9',
itemId: '42',
page: 3,
query: 'needle',
seasonId: '4',
sortBy: 'added',
},
wire: {
action: 'get_ordered_list',
category: '7',
genre: '9',
JsHttpRequest: '1-xml',
movie_id: '42',
p: 3,
search: 'needle',
season_id: '4',
sortby: 'added',
type: 'series',
},
},
{
operation:
STALKER_SESSION_APPLICATION_OPERATIONS.CatalogAllChannels,
parameters: {},
wire: {
action: 'get_all_channels',
JsHttpRequest: '1-xml',
type: 'itv',
},
},
{
operation: STALKER_SESSION_APPLICATION_OPERATIONS.CatalogSearch,
parameters: {
category: '*',
contentType: 'vod',
page: 2,
query: 'film',
},
wire: {
action: 'get_ordered_list',
category: '*',
JsHttpRequest: '1-xml',
max_page_items: 100,
p: 2,
search: 'film',
type: 'vod',
},
},
{
operation: STALKER_SESSION_APPLICATION_OPERATIONS.SeriesSeasons,
parameters: { seriesId: '51' },
wire: {
action: 'get_ordered_list',
JsHttpRequest: '1-xml',
movie_id: '51',
type: 'series',
},
},
{
operation: STALKER_SESSION_APPLICATION_OPERATIONS.SeriesEpisodes,
parameters: { seasonId: '51:2', seriesId: '51' },
wire: {
action: 'get_ordered_list',
JsHttpRequest: '1-xml',
movie_id: '51',
p: 1,
season_id: '51:2',
type: 'vod',
},
},
{
operation: STALKER_SESSION_APPLICATION_OPERATIONS.ShortEpg,
parameters: { channelId: '11', limit: 7 },
wire: {
action: 'get_short_epg',
ch_id: '11',
JsHttpRequest: '1-xml',
size: 7,
type: 'itv',
},
},
{
operation: STALKER_SESSION_APPLICATION_OPERATIONS.EpgInfo,
parameters: {
fromTimestamp: 100,
periodHours: 168,
toTimestamp: 200,
},
wire: {
action: 'get_epg_info',
from_timestamp: 100,
JsHttpRequest: '1-xml',
period: 168,
to_timestamp: 200,
type: 'itv',
},
},
{
operation: STALKER_SESSION_APPLICATION_OPERATIONS.CreateLink,
parameters: {
command: '/media/file_4.mpg',
contentType: 'episode',
episodeNumber: 3,
itemId: '4',
seasonNumber: 2,
seriesId: '9',
},
wire: {
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.MainInfo,
parameters: {},
wire: {
action: 'get_main_info',
JsHttpRequest: '1-xml',
type: 'account_info',
},
},
] as const)(
'maps $operation to an exact main-owned wire request',
({ operation, parameters, wire }) => {
expect(
mapRemote(
operation,
parameters as StalkerSessionOperationParameters<
typeof operation
>
).parameters
).toEqual(wire);
}
);
it('keeps favorites local and never constructs a portal request', () => {
expect(
mapRawOperation(STALKER_SESSION_APPLICATION_OPERATIONS.Favorites, {
contentType: 'vod',
favorite: true,
itemId: '9',
})
).toEqual({
kind: 'local',
result: {
favorite: true,
success: true,
},
});
});
it('normalizes catalog pages through an allowlist and drops secret-shaped raw fields', () => {
const mapped = mapRemote(
STALKER_SESSION_APPLICATION_OPERATIONS.CatalogItems,
{
contentType: 'vod',
page: 2,
}
);
expect(
mapped.mapResult({
js: {
cur_page: '2',
data: [
{
actors: 'A, B',
cmd: 'ffmpeg http://media.invalid/4.ts',
id: '4',
info: {
description: 'Plot',
movie_image: '/poster.jpg',
token: 'must-not-cross',
},
is_series: '1',
name: 'Movie',
rating_imdb: '7.5',
screenshot_uri: '/shot.jpg',
series: ['1', 2, 'bad'],
token: 'must-not-cross',
},
],
max_page_items: '10',
total_items: '25',
},
})
).toEqual({
hasMore: true,
items: [
{
actors: 'A, B',
command: 'ffmpeg http://media.invalid/4.ts',
contentType: 'vod',
id: '4',
info: {
description: 'Plot',
movieImage: '/poster.jpg',
},
isSeries: true,
name: 'Movie',
rating: 7.5,
seriesNumbers: [1, 2],
screenshotUrl: '/shot.jpg',
},
],
page: 2,
total: 25,
});
});
it('normalizes categories, seasons, episodes, EPG, links, and account summaries', () => {
const categories = mapRemote(
STALKER_SESSION_APPLICATION_OPERATIONS.CatalogCategories,
{ contentType: 'series' }
).mapResult({
js: [{ censored: '1', id: '7', title: 'Drama' }],
});
expect(categories).toEqual({
items: [{ censored: true, id: '7', name: 'Drama' }],
});
const seasons = mapRemote(
STALKER_SESSION_APPLICATION_OPERATIONS.SeriesSeasons,
{ seriesId: '42' }
).mapResult({
js: [
{
cmd: '/series/42',
id: '42:2',
name: 'Season 2',
series: ['3', 4],
},
],
});
expect(seasons).toEqual({
seasons: [
{
command: '/series/42',
episodeNumbers: [3, 4],
id: '42:2',
number: 2,
title: 'Season 2',
},
],
});
const episodes = mapRemote(
STALKER_SESSION_APPLICATION_OPERATIONS.SeriesEpisodes,
{ seasonId: '42:2', seriesId: '42' }
).mapResult({
js: {
data: [
{
cmd: '/episode/3',
cover: '/episode.jpg',
episode_num: '3',
id: 'e3',
name: 'Episode 3',
},
],
},
});
expect(episodes).toEqual({
episodes: [
{
command: '/episode/3',
episodeNumber: 3,
id: 'e3',
seasonId: '42:2',
seasonNumber: 2,
seriesId: '42',
thumbnailUrl: '/episode.jpg',
title: 'Episode 3',
},
],
});
const epg = mapRemote(STALKER_SESSION_APPLICATION_OPERATIONS.ShortEpg, {
channelId: '11',
}).mapResult({
js: [
{
ch_id: '11',
descr: 'Summary',
id: 'p1',
name: 'News',
start_timestamp: '100',
stop_timestamp: 200,
},
],
});
expect(epg).toEqual({
programs: [
{
channelId: '11',
description: 'Summary',
endsAt: 200,
id: 'p1',
startsAt: 100,
title: 'News',
},
],
});
const link = mapRemote(
STALKER_SESSION_APPLICATION_OPERATIONS.CreateLink,
{
command: 'http://media.invalid/live',
contentType: 'itv',
}
).mapResult({
js: { cmd: 'ffmpeg http://media.invalid/live?token=synthetic' },
});
expect(link).toEqual({
streamUrl: 'http://media.invalid/live?token=synthetic',
});
const mainInfo = mapRemote(
STALKER_SESSION_APPLICATION_OPERATIONS.MainInfo,
{}
).mapResult({
js: {
account_info: {
account_balance: '2.50',
expire_date: '2030-01-01',
login: 'demo',
status: 1,
tariff_plan_name: 'Basic',
password: 'must-not-cross',
},
},
});
expect(mainInfo).toEqual({
account: {
accountBalance: '2.50',
expiresAt: '2030-01-01',
name: 'demo',
status: '1',
tariffPlan: 'Basic',
},
});
});
it('rejects invalid operation parameters before any wire request exists', () => {
expect(() =>
mapRawOperation(
STALKER_SESSION_APPLICATION_OPERATIONS.CatalogSearch,
{
contentType: 'vod',
query: ' ',
}
)
).toThrow('stalker-operation-invalid-parameters');
expect(() =>
mapRawOperation(STALKER_SESSION_APPLICATION_OPERATIONS.CreateLink, {
command: '',
contentType: 'vod',
})
).toThrow('stalker-operation-invalid-parameters');
});
});
@@ -0,0 +1,827 @@
import {
STALKER_SESSION_APPLICATION_OPERATIONS,
type StalkerSessionApplicationOperation,
type StalkerSessionCatalogInfo,
type StalkerSessionCatalogItem,
type StalkerSessionCatalogResult,
type StalkerSessionCategoriesResult,
type StalkerSessionContentType,
type StalkerSessionCreateLinkResult,
type StalkerSessionEntityId,
type StalkerSessionEpisodesResult,
type StalkerSessionEpgProgram,
type StalkerSessionEpgResult,
type StalkerSessionMainInfoResult,
type StalkerSessionOperationParameters,
type StalkerSessionOperationResult,
type StalkerSessionSeasonsResult,
} from '@iptvnator/shared/interfaces';
export interface StalkerRemoteOperation<
Operation extends StalkerSessionApplicationOperation,
> {
readonly kind: 'remote';
readonly parameters: Readonly<Record<string, string | number>>;
readonly mapResult: (
value: unknown
) => StalkerSessionOperationResult<Operation>;
}
export interface StalkerLocalOperation<
Operation extends StalkerSessionApplicationOperation,
> {
readonly kind: 'local';
readonly result: StalkerSessionOperationResult<Operation>;
}
export type StalkerMappedOperation<
Operation extends StalkerSessionApplicationOperation,
> = StalkerRemoteOperation<Operation> | StalkerLocalOperation<Operation>;
const JS_HTTP_REQUEST = '1-xml';
const SEARCH_PAGE_SIZE = 100;
export function mapRawOperation<
Operation extends StalkerSessionApplicationOperation,
>(
operation: Operation,
parameters: StalkerSessionOperationParameters<Operation>
): StalkerMappedOperation<Operation> {
switch (operation) {
case STALKER_SESSION_APPLICATION_OPERATIONS.CatalogCategories:
return remote(
{
action: 'get_categories',
JsHttpRequest: JS_HTTP_REQUEST,
type: readContentType(
readRecord(parameters)['contentType'],
['radio', 'series', 'vod']
),
},
mapCategories
);
case STALKER_SESSION_APPLICATION_OPERATIONS.CatalogGenres:
assertEmptyParameters(parameters);
return remote(
{
action: 'get_genres',
JsHttpRequest: JS_HTTP_REQUEST,
type: 'itv',
},
mapCategories
);
case STALKER_SESSION_APPLICATION_OPERATIONS.CatalogItems:
return mapCatalogItems(parameters);
case STALKER_SESSION_APPLICATION_OPERATIONS.CatalogAllChannels:
assertEmptyParameters(parameters);
return remote(
{
action: 'get_all_channels',
JsHttpRequest: JS_HTTP_REQUEST,
type: 'itv',
},
(value) => mapCatalog(value, 'itv')
);
case STALKER_SESSION_APPLICATION_OPERATIONS.CatalogSearch:
return mapSearch(parameters);
case STALKER_SESSION_APPLICATION_OPERATIONS.SeriesSeasons:
return mapSeasons(parameters);
case STALKER_SESSION_APPLICATION_OPERATIONS.SeriesEpisodes:
return mapEpisodes(parameters);
case STALKER_SESSION_APPLICATION_OPERATIONS.ShortEpg:
return mapShortEpg(parameters);
case STALKER_SESSION_APPLICATION_OPERATIONS.EpgInfo:
return mapEpgInfo(parameters);
case STALKER_SESSION_APPLICATION_OPERATIONS.CreateLink:
return mapCreateLink(parameters);
case STALKER_SESSION_APPLICATION_OPERATIONS.Favorites: {
const record = readRecord(parameters);
const favorite = readBoolean(record['favorite']);
readEntityId(record['itemId']);
readContentType(record['contentType']);
return {
kind: 'local',
result: {
favorite,
success: true,
},
} as StalkerMappedOperation<Operation>;
}
case STALKER_SESSION_APPLICATION_OPERATIONS.MainInfo:
assertEmptyParameters(parameters);
return remote(
{
action: 'get_main_info',
JsHttpRequest: JS_HTTP_REQUEST,
type: 'account_info',
},
mapMainInfo
);
default:
return assertNever(operation);
}
}
function mapCatalogItems<Operation extends StalkerSessionApplicationOperation>(
parameters: StalkerSessionOperationParameters<Operation>
): StalkerMappedOperation<Operation> {
const record = readRecord(parameters);
const contentType = readPortalContentType(record['contentType']);
const wire: Record<string, string | number> = {
action: 'get_ordered_list',
JsHttpRequest: JS_HTTP_REQUEST,
type: contentType,
};
copyEntityId(record, 'category', wire, 'category');
copyEntityId(record, 'genre', wire, 'genre');
copyPositiveInteger(record, 'page', wire, 'p');
copyTrimmedString(record, 'query', wire, 'search');
copyTrimmedString(record, 'sortBy', wire, 'sortby');
copyEntityId(record, 'itemId', wire, 'movie_id');
copyEntityId(record, 'seasonId', wire, 'season_id');
return remote(wire, (value) => mapCatalog(value, contentType));
}
function mapSearch<Operation extends StalkerSessionApplicationOperation>(
parameters: StalkerSessionOperationParameters<Operation>
): StalkerMappedOperation<Operation> {
const record = readRecord(parameters);
const contentType = readPortalContentType(record['contentType']);
const query = readRequiredString(record['query']);
const wire: Record<string, string | number> = {
action: 'get_ordered_list',
JsHttpRequest: JS_HTTP_REQUEST,
max_page_items: SEARCH_PAGE_SIZE,
search: query,
type: contentType,
};
copyEntityId(record, 'category', wire, 'category');
copyPositiveInteger(record, 'page', wire, 'p');
return remote(wire, (value) => mapCatalog(value, contentType));
}
function mapSeasons<Operation extends StalkerSessionApplicationOperation>(
parameters: StalkerSessionOperationParameters<Operation>
): StalkerMappedOperation<Operation> {
const seriesId = readEntityId(readRecord(parameters)['seriesId']);
return remote(
{
action: 'get_ordered_list',
JsHttpRequest: JS_HTTP_REQUEST,
movie_id: seriesId,
type: 'series',
},
mapSeasonResult
);
}
function mapEpisodes<Operation extends StalkerSessionApplicationOperation>(
parameters: StalkerSessionOperationParameters<Operation>
): StalkerMappedOperation<Operation> {
const record = readRecord(parameters);
const seriesId = readEntityId(record['seriesId']);
const seasonId = readEntityId(record['seasonId']);
return remote(
{
action: 'get_ordered_list',
JsHttpRequest: JS_HTTP_REQUEST,
movie_id: seriesId,
p: 1,
season_id: seasonId,
type: 'vod',
},
(value) => mapEpisodeResult(value, seriesId, seasonId)
);
}
function mapShortEpg<Operation extends StalkerSessionApplicationOperation>(
parameters: StalkerSessionOperationParameters<Operation>
): StalkerMappedOperation<Operation> {
const record = readRecord(parameters);
const channelId = readEntityId(record['channelId']);
const wire: Record<string, string | number> = {
action: 'get_short_epg',
ch_id: channelId,
JsHttpRequest: JS_HTTP_REQUEST,
type: 'itv',
};
copyPositiveInteger(record, 'limit', wire, 'size');
return remote(wire, (value) => mapEpg(value, channelId));
}
function mapEpgInfo<Operation extends StalkerSessionApplicationOperation>(
parameters: StalkerSessionOperationParameters<Operation>
): StalkerMappedOperation<Operation> {
const record = readRecord(parameters);
const wire: Record<string, string | number> = {
action: 'get_epg_info',
JsHttpRequest: JS_HTTP_REQUEST,
type: 'itv',
};
copyNonNegativeInteger(record, 'fromTimestamp', wire, 'from_timestamp');
copyPositiveInteger(record, 'periodHours', wire, 'period');
copyNonNegativeInteger(record, 'toTimestamp', wire, 'to_timestamp');
return remote(wire, (value) => mapEpg(value));
}
function mapCreateLink<Operation extends StalkerSessionApplicationOperation>(
parameters: StalkerSessionOperationParameters<Operation>
): StalkerMappedOperation<Operation> {
const record = readRecord(parameters);
const contentType = readContentType(record['contentType'], [
'episode',
'itv',
'radio',
'series',
'vod',
]);
const command = readRequiredString(record['command']);
const wire: Record<string, string | number> = {
action: 'create_link',
cmd: command,
disable_ad: 0,
download: 0,
JsHttpRequest: JS_HTTP_REQUEST,
type: contentType === 'episode' ? 'vod' : contentType,
};
if (contentType === 'episode') {
wire['series'] = readPositiveInteger(record['episodeNumber']);
}
return remote(wire, mapCreateLinkResult);
}
function mapCategories(value: unknown): StalkerSessionCategoriesResult {
const items = readArray(unwrapJs(value)).flatMap((value) => {
const record = asRecord(value);
if (!record) {
return [];
}
const id = optionalEntityId(record['id']);
const name =
optionalString(record['title']) ?? optionalString(record['name']);
if (id === undefined || name === undefined) {
return [];
}
const alias = optionalString(record['alias']);
const parentId = optionalEntityId(
record['parent_id'] ?? record['parentId']
);
const censored = optionalBoolean(record['censored']);
return [
compact({
id,
name,
alias,
censored,
parentId,
}),
];
});
return { items };
}
function mapCatalog(
value: unknown,
contentType: StalkerSessionContentType
): StalkerSessionCatalogResult {
const js = unwrapJs(value);
const envelope = asRecord(js);
const rawItems = envelope ? readArray(envelope['data']) : readArray(js);
const items = rawItems.flatMap((value) => {
const mapped = mapCatalogItem(value, contentType);
return mapped ? [mapped] : [];
});
if (!envelope) {
return { items };
}
const page = optionalPositiveInteger(
envelope['cur_page'] ?? envelope['page']
);
const total = optionalNonNegativeInteger(envelope['total_items']);
const pageSize = optionalPositiveInteger(envelope['max_page_items']);
const hasMore =
total !== undefined && page !== undefined
? pageSize !== undefined
? page * pageSize < total
: items.length > 0 && page * items.length < total
: undefined;
return compact({ hasMore, items, page, total });
}
function mapCatalogItem(
value: unknown,
contentType: StalkerSessionContentType
): StalkerSessionCatalogItem | undefined {
const record = asRecord(value);
if (!record) {
return undefined;
}
const id = optionalEntityId(
record['id'] ??
record['stream_id'] ??
record['movie_id'] ??
record['series_id']
);
if (id === undefined) {
return undefined;
}
const rating = optionalFiniteNumber(
record['rating_imdb'] ?? record['rating']
);
const info = mapCatalogInfo(record['info']);
return compact({
id,
contentType,
actors: optionalString(record['actors']),
categoryId: optionalEntityId(record['category_id']),
channelNumber: optionalNonNegativeInteger(record['number']),
command: optionalString(record['cmd']),
description: optionalString(record['description']),
director: optionalString(record['director']),
durationSeconds: optionalNonNegativeInteger(
record['duration'] ?? record['duration_seconds']
),
genreId: optionalEntityId(record['tv_genre_id'] ?? record['genre_id']),
hasFiles: optionalBoolean(record['has_files']),
info,
isSeries: optionalBoolean(record['is_series']),
logoUrl: optionalString(record['logo']),
name: optionalString(record['name']),
originalName: optionalString(record['o_name']),
posterUrl: optionalString(record['cover'] ?? record['movie_image']),
rating,
screenshotUrl: optionalString(record['screenshot_uri']),
seriesNumbers: readNumberArray(record['series']),
title: optionalString(record['title']),
videoId: optionalEntityId(record['video_id']),
year: optionalNonNegativeInteger(record['year']),
});
}
function mapCatalogInfo(value: unknown): StalkerSessionCatalogInfo | undefined {
const record = asRecord(value);
if (!record) {
return undefined;
}
const info = compact({
actors: optionalString(record['actors']),
description: optionalString(record['description']),
director: optionalString(record['director']),
genre: optionalString(record['genre']),
movieImage: optionalString(record['movie_image']),
name: optionalString(record['name']),
originalName: optionalString(record['o_name']),
releaseDate: optionalString(record['releasedate']),
ratingImdb: optionalString(record['rating_imdb']),
ratingKinopoisk: optionalString(record['rating_kinopoisk']),
});
return Object.keys(info).length > 0 ? info : undefined;
}
function mapSeasonResult(value: unknown): StalkerSessionSeasonsResult {
const seasons = readDataArray(value).flatMap((value, index) => {
const record = asRecord(value);
if (!record) {
return [];
}
const id = optionalEntityId(record['id']);
if (id === undefined) {
return [];
}
const number =
optionalPositiveInteger(
record['season_number'] ?? record['number']
) ??
readIdSuffixNumber(id) ??
index + 1;
return [
compact({
id,
number,
command: optionalString(record['cmd']),
episodeNumbers: readNumberArray(record['series']),
title:
optionalString(record['name']) ??
optionalString(record['title']),
}),
];
});
return { seasons };
}
function mapEpisodeResult(
value: unknown,
seriesId: StalkerSessionEntityId,
seasonId: StalkerSessionEntityId
): StalkerSessionEpisodesResult {
const seasonNumber = readIdSuffixNumber(seasonId) ?? 1;
const episodes = readDataArray(value).flatMap((value, index) => {
const record = asRecord(value);
if (!record) {
return [];
}
const id = optionalEntityId(record['id']);
if (id === undefined) {
return [];
}
const episodeNumber =
optionalPositiveInteger(
record['episode_num'] ?? record['series_number']
) ?? index + 1;
return [
compact({
id,
episodeNumber,
seasonId,
seasonNumber,
seriesId,
command: optionalString(record['cmd']),
description: optionalString(record['description']),
durationSeconds: optionalNonNegativeInteger(record['duration']),
thumbnailUrl: optionalString(
record['cover'] ?? record['screenshot_uri']
),
title:
optionalString(record['name']) ??
optionalString(record['title']),
}),
];
});
return { episodes };
}
function mapEpg(
value: unknown,
fallbackChannelId?: StalkerSessionEntityId
): StalkerSessionEpgResult {
const programs = flattenEpgEntries(unwrapJs(value)).flatMap((value) => {
const record = asRecord(value);
if (!record) {
return [];
}
const channelId =
optionalEntityId(record['ch_id'] ?? record['channel_id']) ??
fallbackChannelId;
const title =
optionalString(record['name']) ?? optionalString(record['title']);
const startsAt = optionalTimestamp(
record['start_timestamp'] ?? record['start'] ?? record['time']
);
const endsAt = optionalTimestamp(
record['stop_timestamp'] ?? record['stop'] ?? record['time_to']
);
if (
channelId === undefined ||
title === undefined ||
startsAt === undefined ||
endsAt === undefined
) {
return [];
}
return [
compact<StalkerSessionEpgProgram>({
channelId,
endsAt,
startsAt,
title,
description: optionalString(
record['descr'] ?? record['description']
),
id: optionalEntityId(record['id']),
}),
];
});
return { programs };
}
function mapCreateLinkResult(value: unknown): StalkerSessionCreateLinkResult {
const record = asRecord(unwrapJs(value));
const streamUrl = normalizePlaybackCommand(
optionalString(record?.['cmd']) ?? ''
);
if (!streamUrl) {
throw invalidResponse();
}
return { streamUrl };
}
function mapMainInfo(value: unknown): StalkerSessionMainInfoResult {
const js = asRecord(unwrapJs(value));
const account = asRecord(js?.['account_info']) ?? js;
if (!account) {
throw invalidResponse();
}
return {
account: compact({
accountBalance: optionalString(account['account_balance']),
expiresAt: optionalString(account['expire_date']),
name:
optionalString(account['login']) ??
optionalString(account['name']),
status: optionalString(account['status']),
tariffPlan: optionalString(account['tariff_plan_name']),
}),
};
}
function remote<Operation extends StalkerSessionApplicationOperation>(
parameters: Readonly<Record<string, string | number>>,
mapResult: (value: unknown) => StalkerSessionOperationResult<Operation>
): StalkerMappedOperation<Operation> {
return {
kind: 'remote',
mapResult,
parameters,
};
}
function unwrapJs(value: unknown): unknown {
const record = asRecord(value);
return record && Object.prototype.hasOwnProperty.call(record, 'js')
? record['js']
: value;
}
function readDataArray(value: unknown): readonly unknown[] {
const js = unwrapJs(value);
const record = asRecord(js);
return record ? readArray(record['data']) : readArray(js);
}
function flattenEpgEntries(value: unknown): readonly unknown[] {
if (Array.isArray(value)) {
return value;
}
const record = asRecord(value);
if (!record) {
return [];
}
const direct = readArray(record['data']);
if (direct.length > 0) {
return direct;
}
const result: unknown[] = [];
for (const nested of Object.values(record)) {
if (Array.isArray(nested)) {
result.push(...nested);
continue;
}
const nestedRecord = asRecord(nested);
if (nestedRecord) {
result.push(
...readArray(
nestedRecord['data'] ??
nestedRecord['epg'] ??
nestedRecord['items']
)
);
}
}
return result;
}
function normalizePlaybackCommand(value: string): string {
const trimmed = value.trim();
const separator = trimmed.indexOf(' ');
if (separator > 0) {
const candidate = trimmed.slice(separator + 1).trim();
if (
candidate.startsWith('http://') ||
candidate.startsWith('https://') ||
candidate.startsWith('/') ||
candidate.startsWith('?')
) {
return candidate;
}
}
return trimmed;
}
function readRecord(value: unknown): Readonly<Record<string, unknown>> {
const record = asRecord(value);
if (!record) {
throw invalidParameters();
}
return record;
}
function asRecord(
value: unknown
): Readonly<Record<string, unknown>> | undefined {
return value !== null && typeof value === 'object' && !Array.isArray(value)
? (value as Readonly<Record<string, unknown>>)
: undefined;
}
function readArray(value: unknown): readonly unknown[] {
return Array.isArray(value) ? value : [];
}
function readRequiredString(value: unknown): string {
const result = optionalString(value);
if (result === undefined) {
throw invalidParameters();
}
return result;
}
function optionalString(value: unknown): string | undefined {
if (
(typeof value !== 'string' &&
typeof value !== 'number' &&
typeof value !== 'boolean') ||
String(value).trim() === ''
) {
return undefined;
}
return String(value).trim();
}
function readEntityId(value: unknown): StalkerSessionEntityId {
const result = optionalEntityId(value);
if (result === undefined) {
throw invalidParameters();
}
return result;
}
function optionalEntityId(value: unknown): StalkerSessionEntityId | undefined {
if (typeof value === 'number') {
return Number.isFinite(value) ? value : undefined;
}
return typeof value === 'string' && value.trim() !== ''
? value.trim()
: undefined;
}
function readContentType(
value: unknown,
allowed: readonly string[] = ['itv', 'radio', 'series', 'vod']
): StalkerSessionContentType | 'episode' {
if (typeof value !== 'string' || !allowed.includes(value)) {
throw invalidParameters();
}
return value as StalkerSessionContentType | 'episode';
}
function readPortalContentType(value: unknown): StalkerSessionContentType {
return readContentType(value) as StalkerSessionContentType;
}
function readBoolean(value: unknown): boolean {
const result = optionalBoolean(value);
if (result === undefined) {
throw invalidParameters();
}
return result;
}
function optionalBoolean(value: unknown): boolean | undefined {
if (value === true || value === 1 || value === '1') {
return true;
}
if (value === false || value === 0 || value === '0') {
return false;
}
return undefined;
}
function readPositiveInteger(value: unknown): number {
const result = optionalPositiveInteger(value);
if (result === undefined) {
throw invalidParameters();
}
return result;
}
function optionalPositiveInteger(value: unknown): number | undefined {
const result = optionalFiniteNumber(value);
return result !== undefined && Number.isSafeInteger(result) && result > 0
? result
: undefined;
}
function optionalNonNegativeInteger(value: unknown): number | undefined {
const result = optionalFiniteNumber(value);
return result !== undefined && Number.isSafeInteger(result) && result >= 0
? result
: undefined;
}
function optionalFiniteNumber(value: unknown): number | undefined {
if (
value === undefined ||
value === null ||
(typeof value === 'string' && value.trim() === '')
) {
return undefined;
}
const result = Number(value);
return Number.isFinite(result) ? result : undefined;
}
function optionalTimestamp(value: unknown): number | undefined {
const numeric = optionalFiniteNumber(value);
if (numeric !== undefined && numeric >= 0) {
return numeric;
}
if (typeof value !== 'string') {
return undefined;
}
const parsed = Date.parse(value);
return Number.isFinite(parsed) ? Math.floor(parsed / 1000) : undefined;
}
function readNumberArray(value: unknown): readonly number[] | undefined {
if (!Array.isArray(value)) {
return undefined;
}
const numbers = value.flatMap((entry) => {
const number = optionalPositiveInteger(entry);
return number === undefined ? [] : [number];
});
return numbers.length > 0 ? numbers : undefined;
}
function readIdSuffixNumber(value: StalkerSessionEntityId): number | undefined {
const suffix = String(value).split(':').at(-1);
return optionalPositiveInteger(suffix);
}
function copyEntityId(
source: Readonly<Record<string, unknown>>,
sourceKey: string,
target: Record<string, string | number>,
targetKey: string
): void {
const value = source[sourceKey];
if (value !== undefined) {
target[targetKey] = readEntityId(value);
}
}
function copyPositiveInteger(
source: Readonly<Record<string, unknown>>,
sourceKey: string,
target: Record<string, string | number>,
targetKey: string
): void {
const value = source[sourceKey];
if (value !== undefined) {
target[targetKey] = readPositiveInteger(value);
}
}
function copyNonNegativeInteger(
source: Readonly<Record<string, unknown>>,
sourceKey: string,
target: Record<string, string | number>,
targetKey: string
): void {
const value = source[sourceKey];
if (value === undefined) {
return;
}
const parsed = optionalNonNegativeInteger(value);
if (parsed === undefined) {
throw invalidParameters();
}
target[targetKey] = parsed;
}
function copyTrimmedString(
source: Readonly<Record<string, unknown>>,
sourceKey: string,
target: Record<string, string | number>,
targetKey: string
): void {
const value = source[sourceKey];
if (value !== undefined) {
target[targetKey] = readRequiredString(value);
}
}
function assertEmptyParameters(value: unknown): void {
if (Object.keys(readRecord(value)).length !== 0) {
throw invalidParameters();
}
}
function compact<T extends object>(value: T): T {
return Object.fromEntries(
Object.entries(value).filter(([, entry]) => entry !== undefined)
) as T;
}
function invalidParameters(): Error {
return new Error('stalker-operation-invalid-parameters');
}
function invalidResponse(): Error {
return new Error('stalker-operation-invalid-response');
}
function assertNever(value: never): never {
throw new Error(`stalker-operation-unsupported:${String(value)}`);
}
@@ -72,6 +72,7 @@ export interface StalkerSessionShortEpgParameters {
export interface StalkerSessionEpgInfoParameters {
fromTimestamp?: number;
periodHours?: number;
toTimestamp?: number;
}
@@ -96,23 +97,47 @@ export interface StalkerSessionCategory {
id: StalkerSessionEntityId;
name: string;
alias?: string;
censored?: boolean;
parentId?: StalkerSessionEntityId;
}
export interface StalkerSessionCatalogInfo {
actors?: string;
description?: string;
director?: string;
genre?: string;
movieImage?: string;
name?: string;
originalName?: string;
releaseDate?: string;
ratingImdb?: string;
ratingKinopoisk?: string;
}
export interface StalkerSessionCatalogItem {
id: StalkerSessionEntityId;
contentType: StalkerSessionContentType;
actors?: string;
name?: string;
title?: string;
originalName?: string;
description?: string;
director?: string;
command?: string;
posterUrl?: string;
logoUrl?: string;
screenshotUrl?: string;
categoryId?: StalkerSessionEntityId;
genreId?: StalkerSessionEntityId;
videoId?: StalkerSessionEntityId;
channelNumber?: number;
hasFiles?: boolean;
seriesNumbers?: readonly number[];
year?: number;
rating?: number;
durationSeconds?: number;
isSeries?: boolean;
info?: StalkerSessionCatalogInfo;
}
export interface StalkerSessionCatalogResult {
@@ -130,6 +155,8 @@ export interface StalkerSessionSeason {
id: StalkerSessionEntityId;
number: number;
title?: string;
command?: string;
episodeNumbers?: readonly number[];
}
export interface StalkerSessionSeasonsResult {
@@ -145,6 +172,8 @@ export interface StalkerSessionEpisode {
title?: string;
command?: string;
thumbnailUrl?: string;
description?: string;
durationSeconds?: number;
}
export interface StalkerSessionEpisodesResult {
@@ -192,10 +221,7 @@ export interface StalkerSessionMainInfoResult {
account: StalkerSessionAccountSummary;
}
export interface StalkerSessionOperationContract<
Parameters,
Result,
> {
export interface StalkerSessionOperationContract<Parameters, Result> {
parameters: Parameters;
result: Result;
}