From d5acb329e0675b42b9e2abf3f95f6133290354e7 Mon Sep 17 00:00:00 2001 From: 4gray Date: Mon, 27 Jul 2026 10:32:14 +0200 Subject: [PATCH] feat(stalker): map typed portal operations in main --- .../stalker-operation-adapter.spec.ts | 410 +++++++++ .../stalker-operation-adapter.ts | 827 ++++++++++++++++++ .../stalker-session-operations.interface.ts | 34 +- 3 files changed, 1267 insertions(+), 4 deletions(-) create mode 100644 apps/electron-backend/src/app/services/stalker-session/stalker-operation-adapter.spec.ts create mode 100644 apps/electron-backend/src/app/services/stalker-session/stalker-operation-adapter.ts diff --git a/apps/electron-backend/src/app/services/stalker-session/stalker-operation-adapter.spec.ts b/apps/electron-backend/src/app/services/stalker-session/stalker-operation-adapter.spec.ts new file mode 100644 index 000000000..d5eb9fdb3 --- /dev/null +++ b/apps/electron-backend/src/app/services/stalker-session/stalker-operation-adapter.spec.ts @@ -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: Operation, + parameters: StalkerSessionOperationParameters +) { + 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'); + }); +}); diff --git a/apps/electron-backend/src/app/services/stalker-session/stalker-operation-adapter.ts b/apps/electron-backend/src/app/services/stalker-session/stalker-operation-adapter.ts new file mode 100644 index 000000000..424a6dd5f --- /dev/null +++ b/apps/electron-backend/src/app/services/stalker-session/stalker-operation-adapter.ts @@ -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>; + readonly mapResult: ( + value: unknown + ) => StalkerSessionOperationResult; +} + +export interface StalkerLocalOperation< + Operation extends StalkerSessionApplicationOperation, +> { + readonly kind: 'local'; + readonly result: StalkerSessionOperationResult; +} + +export type StalkerMappedOperation< + Operation extends StalkerSessionApplicationOperation, +> = StalkerRemoteOperation | StalkerLocalOperation; + +const JS_HTTP_REQUEST = '1-xml'; +const SEARCH_PAGE_SIZE = 100; + +export function mapRawOperation< + Operation extends StalkerSessionApplicationOperation, +>( + operation: Operation, + parameters: StalkerSessionOperationParameters +): StalkerMappedOperation { + 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; + } + 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( + parameters: StalkerSessionOperationParameters +): StalkerMappedOperation { + const record = readRecord(parameters); + const contentType = readPortalContentType(record['contentType']); + const wire: Record = { + 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( + parameters: StalkerSessionOperationParameters +): StalkerMappedOperation { + const record = readRecord(parameters); + const contentType = readPortalContentType(record['contentType']); + const query = readRequiredString(record['query']); + const wire: Record = { + 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( + parameters: StalkerSessionOperationParameters +): StalkerMappedOperation { + const seriesId = readEntityId(readRecord(parameters)['seriesId']); + return remote( + { + action: 'get_ordered_list', + JsHttpRequest: JS_HTTP_REQUEST, + movie_id: seriesId, + type: 'series', + }, + mapSeasonResult + ); +} + +function mapEpisodes( + parameters: StalkerSessionOperationParameters +): StalkerMappedOperation { + 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( + parameters: StalkerSessionOperationParameters +): StalkerMappedOperation { + const record = readRecord(parameters); + const channelId = readEntityId(record['channelId']); + const wire: Record = { + 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( + parameters: StalkerSessionOperationParameters +): StalkerMappedOperation { + const record = readRecord(parameters); + const wire: Record = { + 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( + parameters: StalkerSessionOperationParameters +): StalkerMappedOperation { + const record = readRecord(parameters); + const contentType = readContentType(record['contentType'], [ + 'episode', + 'itv', + 'radio', + 'series', + 'vod', + ]); + const command = readRequiredString(record['command']); + const wire: Record = { + 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({ + 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( + parameters: Readonly>, + mapResult: (value: unknown) => StalkerSessionOperationResult +): StalkerMappedOperation { + 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> { + const record = asRecord(value); + if (!record) { + throw invalidParameters(); + } + return record; +} + +function asRecord( + value: unknown +): Readonly> | undefined { + return value !== null && typeof value === 'object' && !Array.isArray(value) + ? (value as Readonly>) + : 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>, + sourceKey: string, + target: Record, + targetKey: string +): void { + const value = source[sourceKey]; + if (value !== undefined) { + target[targetKey] = readEntityId(value); + } +} + +function copyPositiveInteger( + source: Readonly>, + sourceKey: string, + target: Record, + targetKey: string +): void { + const value = source[sourceKey]; + if (value !== undefined) { + target[targetKey] = readPositiveInteger(value); + } +} + +function copyNonNegativeInteger( + source: Readonly>, + sourceKey: string, + target: Record, + 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>, + sourceKey: string, + target: Record, + 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(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)}`); +} diff --git a/libs/shared/interfaces/src/lib/stalker-session-operations.interface.ts b/libs/shared/interfaces/src/lib/stalker-session-operations.interface.ts index 24899d2f4..69664b338 100644 --- a/libs/shared/interfaces/src/lib/stalker-session-operations.interface.ts +++ b/libs/shared/interfaces/src/lib/stalker-session-operations.interface.ts @@ -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: Parameters; result: Result; }