From 474f4a86cf30a4bb00b92c869aa08166984831fb Mon Sep 17 00:00:00 2001 From: 4gray Date: Mon, 27 Jul 2026 10:58:14 +0200 Subject: [PATCH] refactor(stalker): route renderer requests through session leases --- libs/portal/stalker/data-access/src/index.ts | 1 + .../src/lib/stalker-request-adapter.spec.ts | 521 ++++++++++ .../src/lib/stalker-request-adapter.ts | 677 +++++++++++++ .../src/lib/stalker-session-descriptor.ts | 22 +- .../src/lib/stalker-session.service.spec.ts | 585 +++++++---- .../src/lib/stalker-session.service.ts | 924 +++++++----------- .../utils/stalker-request.utils.spec.ts | 82 +- .../lib/stores/utils/stalker-request.utils.ts | 20 +- 8 files changed, 2047 insertions(+), 785 deletions(-) create mode 100644 libs/portal/stalker/data-access/src/lib/stalker-request-adapter.spec.ts create mode 100644 libs/portal/stalker/data-access/src/lib/stalker-request-adapter.ts diff --git a/libs/portal/stalker/data-access/src/index.ts b/libs/portal/stalker/data-access/src/index.ts index 7ecb8804d..852dc5e79 100644 --- a/libs/portal/stalker/data-access/src/index.ts +++ b/libs/portal/stalker/data-access/src/index.ts @@ -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'; diff --git a/libs/portal/stalker/data-access/src/lib/stalker-request-adapter.spec.ts b/libs/portal/stalker/data-access/src/lib/stalker-request-adapter.spec.ts new file mode 100644 index 000000000..d0f2fcc32 --- /dev/null +++ b/libs/portal/stalker/data-access/src/lib/stalker-request-adapter.spec.ts @@ -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'); + }); +}); diff --git a/libs/portal/stalker/data-access/src/lib/stalker-request-adapter.ts b/libs/portal/stalker/data-access/src/lib/stalker-request-adapter.ts new file mode 100644 index 000000000..dafbd6b2f --- /dev/null +++ b/libs/portal/stalker/data-access/src/lib/stalker-request-adapter.ts @@ -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 +>; + +export type StalkerAdaptedRequest = { + [Operation in StalkerSessionApplicationOperation]: { + readonly operation: Operation; + readonly parameters: StalkerSessionOperationParameters; + }; +}[StalkerSessionApplicationOperation]; + +const RAW_AUTH_ACTIONS = new Set(['do_auth', 'get_profile', 'handshake']); +const RESERVED_PARAMETERS = new Set( + STALKER_RESERVED_REQUEST_PARAMETERS +); +const CONTENT_TYPES = new Set([ + '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 +): 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: Operation, + parameters: StalkerSessionOperationParameters +): Extract { + const validation = validateStalkerApplicationRequest({ + operation, + parameters: parameters as unknown as Readonly>, + }); + if (validation.kind === 'rejected') { + const suffix = + validation.field === undefined ? '' : `:${validation.field}`; + throw adapterError(`${validation.reason}${suffix}`); + } + return { + operation, + parameters, + } as Extract; +} + +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 | 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( + result: StalkerSessionOperationResult +): StalkerSessionOperationResult { + return result as StalkerSessionOperationResult; +} + +function compact(value: T): T { + const result: Record = {}; + 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}`); +} diff --git a/libs/portal/stalker/data-access/src/lib/stalker-session-descriptor.ts b/libs/portal/stalker/data-access/src/lib/stalker-session-descriptor.ts index 6c7cea21c..50134f425 100644 --- a/libs/portal/stalker/data-access/src/lib/stalker-session-descriptor.ts +++ b/libs/portal/stalker/data-access/src/lib/stalker-session-descriptor.ts @@ -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(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 = {}; + 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( diff --git a/libs/portal/stalker/data-access/src/lib/stalker-session.service.spec.ts b/libs/portal/stalker/data-access/src/lib/stalker-session.service.spec.ts index 8ed39a0de..9fd9b039d 100644 --- a/libs/portal/stalker/data-access/src/lib/stalker-session.service.spec.ts +++ b/libs/portal/stalker/data-access/src/lib/stalker-session.service.spec.ts @@ -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; +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 { + return { + stalkerSessionContinue: jest.fn(), + stalkerSessionControl: jest.fn(), + stalkerSessionOpen: jest.fn(), + stalkerSessionRequest: jest.fn(), + }; +} + +describe('StalkerSessionService', () => { + let bridge: jest.Mocked; 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; - 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(); + }); }); diff --git a/libs/portal/stalker/data-access/src/lib/stalker-session.service.ts b/libs/portal/stalker/data-access/src/lib/stalker-session.service.ts index 91f1ea741..ab0923e93 100644 --- a/libs/portal/stalker/data-access/src/lib/stalker-session.service.ts +++ b/libs/portal/stalker/data-access/src/lib/stalker-session.service.ts @@ -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 { - 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 { - // 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 +>; -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, + { 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(); - - // 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 + Promise >(); - private watchdogPlaylists = new Map(); - private watchdogInFlight = new Set(); - 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 { + 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 { - 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 = { - type: 'stb', - action: 'handshake', - token: '', - prehash, - JsHttpRequest: '1-xml', - }; - - try { - const response: StalkerHandshakeResponse = - await this.dataService.sendIpcEvent( - 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 { - const normalizedIdentity = normalizeStalkerPortalIdentity(identity); + async continue( + playlistRef: string, + request: StalkerSessionContinueRequest + ): Promise { + const outcome = + await this.requireBridge().stalkerSessionContinue(request); + this.rememberConnectionOutcome(playlistRef, outcome); + return outcome; + } - // Build metrics JSON matching working app - const metrics: Record = { - mac: macAddress, - model: 'MAG250', - type: 'STB', - random: handshakeRandom, - ...(normalizedIdentity.serialNumber - ? { sn: normalizedIdentity.serialNumber } - : {}), - }; + request( + request: StalkerSessionRequest + ): Promise> { + return this.requireBridge().stalkerSessionRequest(request); + } - // Generate prehash for get_profile (same as handshake) - const prehash = await generatePrehash(macAddress); + async control( + request: StalkerSessionControlRequest + ): Promise { + 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 = { - 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( - 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 { + return this.control({ + action: 'activate', + leaseRef, + }); + } + + deactivate( + leaseRef: StalkerSessionLeaseRef + ): Promise { + return this.control({ + action: 'deactivate', + leaseRef, + }); + } + + close( + leaseRef: StalkerSessionLeaseRef + ): Promise { + return this.control({ + action: 'close', + leaseRef, + }); + } + + forceRedetect( + leaseRef: StalkerSessionLeaseRef + ): Promise { + return this.control({ + action: 'force-redetect', + leaseRef, + }); + } + + commit( + attemptRef: StalkerSessionAttemptRef + ): Promise { + return this.control({ + action: 'commit', + attemptRef, + }); + } + + discard( + attemptRef: StalkerSessionAttemptRef + ): Promise { + return this.control({ + action: 'discard', + attemptRef, + }); } /** - * Performs do_auth to authenticate the session after handshake - */ - async doAuth( - portalUrl: string, - macAddress: string, - token: string - ): Promise { - const params: Record = { - type: 'stb', - action: 'do_auth', - login: '', - password: '', - JsHttpRequest: '1-xml', - }; - - try { - const response = - await this.dataService.sendIpcEvent( - 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; - - // 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; - 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( playlist: Playlist, - params: Record, - retryOnAuthFailure = true + parameters: Readonly> ): Promise { - // 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( - STALKER_REQUEST, - { - url: playlist.portalUrl, - macAddress: playlist.macAddress, - params, - token, - ...(serialNumber ? { serialNumber } : {}), + private async resolveLease( + playlist: Playlist + ): Promise { + 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[0], + savedCredentials: StalkerSavedCredentials | undefined + ): Promise { + 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 { + 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( - 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( - 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; } diff --git a/libs/portal/stalker/data-access/src/lib/stores/utils/stalker-request.utils.spec.ts b/libs/portal/stalker/data-access/src/lib/stores/utils/stalker-request.utils.spec.ts index 47607e9ef..408c98765 100644 --- a/libs/portal/stalker/data-access/src/lib/stores/utils/stalker-request.utils.spec.ts +++ b/libs/portal/stalker/data-access/src/lib/stores/utils/stalker-request.utils.spec.ts @@ -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(); diff --git a/libs/portal/stalker/data-access/src/lib/stores/utils/stalker-request.utils.ts b/libs/portal/stalker/data-access/src/lib/stores/utils/stalker-request.utils.ts index bc61bd959..84ce34ac7 100644 --- a/libs/portal/stalker/data-access/src/lib/stores/utils/stalker-request.utils.ts +++ b/libs/portal/stalker/data-access/src/lib/stores/utils/stalker-request.utils.ts @@ -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( playlist: PlaylistMeta, params: Record ): Promise { - if (playlist.isFullStalkerPortal) { + if ( + playlist.isFullStalkerPortal && + supportsTypedSessions(deps.stalkerSession) + ) { return deps.stalkerSession.makeAuthenticatedRequest( toStalkerSessionPlaylist(playlist), params @@ -32,3 +39,12 @@ export async function executeStalkerRequest( params, }); } + +function supportsTypedSessions(stalkerSession: StalkerSessionService): boolean { + const probe = ( + stalkerSession as Partial< + Pick + > + ).supportsTypedSessions; + return typeof probe !== 'function' || probe.call(stalkerSession); +}