From f8a544d287219dd4b43a2fb94046b56781361c7d Mon Sep 17 00:00:00 2001 From: 4gray Date: Mon, 27 Jul 2026 03:16:16 +0200 Subject: [PATCH] fix(stalker): correlate session operation contracts --- .../src/lib/ipc-command.class.spec.ts | 40 ++- .../stalker-session-operations.interface.ts | 259 ++++++++++++++++++ .../src/lib/stalker-session.interface.ts | 71 ++--- 3 files changed, 324 insertions(+), 46 deletions(-) create mode 100644 libs/shared/interfaces/src/lib/stalker-session-operations.interface.ts diff --git a/libs/shared/interfaces/src/lib/ipc-command.class.spec.ts b/libs/shared/interfaces/src/lib/ipc-command.class.spec.ts index 05af841e6..40374441c 100644 --- a/libs/shared/interfaces/src/lib/ipc-command.class.spec.ts +++ b/libs/shared/interfaces/src/lib/ipc-command.class.spec.ts @@ -11,10 +11,13 @@ import { } from './ipc-command.class'; import { STALKER_SESSION_APPLICATION_OPERATIONS, + type StalkerSessionApplicationOperation, type StalkerSessionContinueRequest, type StalkerSessionControlRequest, type StalkerSessionOpenRequest, + type StalkerSessionOperationContractMap, type StalkerSessionRequest, + type StalkerSessionRequestOutcome, } from './stalker-session.interface'; describe('typed IPC commands', () => { @@ -96,7 +99,7 @@ describe('typed IPC commands', () => { const response: IpcCommandResponse = { kind: 'success', operation: STALKER_SESSION_APPLICATION_OPERATIONS.CatalogItems, - payload: { js: { data: [] } }, + payload: { items: [], page: 1, total: 0 }, requestId: 'request-id', }; @@ -104,6 +107,41 @@ describe('typed IPC commands', () => { expect(response.kind).toBe('success'); }); + it('correlates each operation with its request and sanitized result', () => { + type MissingOperations = Exclude< + StalkerSessionApplicationOperation, + keyof StalkerSessionOperationContractMap + >; + type ExtraOperations = Exclude< + keyof StalkerSessionOperationContractMap, + StalkerSessionApplicationOperation + >; + const mapIsExhaustive: [ + MissingOperations, + ExtraOperations, + ] extends [never, never] + ? true + : false = true; + + const mismatchedRequest: StalkerSessionRequest = { + leaseRef: 'opaque-lease', + operation: STALKER_SESSION_APPLICATION_OPERATIONS.CatalogItems, + // @ts-expect-error Catalog requests cannot use EPG parameters. + parameters: { channelId: 'channel-1' }, + }; + const mismatchedResult: StalkerSessionRequestOutcome = { + kind: 'success', + operation: STALKER_SESSION_APPLICATION_OPERATIONS.CreateLink, + // @ts-expect-error create-link cannot return a catalog collection. + payload: { items: [] }, + requestId: 'request-id', + }; + + expect(mapIsExhaustive).toBe(true); + expect(mismatchedRequest.operation).toBe('get-ordered-list'); + expect(mismatchedResult.kind).toBe('success'); + }); + it('maps STALKER_SESSION_CONTROL to exactly one request and response type', () => { const request: IpcCommandRequest = { action: 'commit', diff --git a/libs/shared/interfaces/src/lib/stalker-session-operations.interface.ts b/libs/shared/interfaces/src/lib/stalker-session-operations.interface.ts new file mode 100644 index 000000000..f1bb20989 --- /dev/null +++ b/libs/shared/interfaces/src/lib/stalker-session-operations.interface.ts @@ -0,0 +1,259 @@ +export const STALKER_SESSION_APPLICATION_OPERATIONS = { + CatalogCategories: 'get-categories', + CatalogGenres: 'get-genres', + CatalogItems: 'get-ordered-list', + CatalogAllChannels: 'get-all-channels', + CatalogSearch: 'search', + SeriesSeasons: 'get-series-seasons', + SeriesEpisodes: 'get-series-episodes', + ShortEpg: 'get-short-epg', + EpgInfo: 'get-epg-info', + CreateLink: 'create-link', + Favorites: 'favorites', + MainInfo: 'get-main-info', +} as const; + +export type StalkerSessionApplicationOperation = + (typeof STALKER_SESSION_APPLICATION_OPERATIONS)[keyof typeof STALKER_SESSION_APPLICATION_OPERATIONS]; + +export const STALKER_SESSION_CONTENT_TYPES = { + Itv: 'itv', + Radio: 'radio', + Series: 'series', + Vod: 'vod', +} as const; + +export type StalkerSessionContentType = + (typeof STALKER_SESSION_CONTENT_TYPES)[keyof typeof STALKER_SESSION_CONTENT_TYPES]; + +export type StalkerSessionEntityId = string | number; +export type StalkerSessionNoParameters = Readonly>; + +export interface StalkerSessionCategoryParameters { + contentType: 'radio' | 'series' | 'vod'; +} + +export type StalkerSessionGenreParameters = StalkerSessionNoParameters; + +export interface StalkerSessionCatalogParameters { + contentType?: StalkerSessionContentType; + category?: StalkerSessionEntityId; + genre?: StalkerSessionEntityId; + page?: number; + query?: string; + sortBy?: string; + itemId?: StalkerSessionEntityId; + seasonId?: StalkerSessionEntityId; +} + +export type StalkerSessionAllChannelsParameters = StalkerSessionNoParameters; + +export interface StalkerSessionSearchParameters { + contentType: StalkerSessionContentType; + query: string; + category?: StalkerSessionEntityId; + page?: number; +} + +export interface StalkerSessionSeriesSeasonsParameters { + seriesId: StalkerSessionEntityId; +} + +export interface StalkerSessionSeriesEpisodesParameters { + seriesId: StalkerSessionEntityId; + seasonId: StalkerSessionEntityId; +} + +export interface StalkerSessionShortEpgParameters { + channelId: StalkerSessionEntityId; + limit?: number; +} + +export interface StalkerSessionEpgInfoParameters { + fromTimestamp?: number; + toTimestamp?: number; +} + +export interface StalkerSessionCreateLinkParameters { + contentType: StalkerSessionContentType | 'episode'; + command: string; + itemId?: StalkerSessionEntityId; + seriesId?: StalkerSessionEntityId; + seasonNumber?: number; + episodeNumber?: number; +} + +export interface StalkerSessionFavoritesParameters { + contentType: StalkerSessionContentType; + itemId: StalkerSessionEntityId; + favorite: boolean; +} + +export type StalkerSessionMainInfoParameters = StalkerSessionNoParameters; + +export interface StalkerSessionCategory { + id: StalkerSessionEntityId; + name: string; + alias?: string; + parentId?: StalkerSessionEntityId; +} + +export interface StalkerSessionCatalogItem { + id: StalkerSessionEntityId; + contentType: StalkerSessionContentType; + name?: string; + title?: string; + description?: string; + command?: string; + posterUrl?: string; + categoryId?: StalkerSessionEntityId; + genreId?: StalkerSessionEntityId; + year?: number; + rating?: number; + durationSeconds?: number; + isSeries?: boolean; +} + +export interface StalkerSessionCatalogResult { + items: readonly StalkerSessionCatalogItem[]; + page?: number; + total?: number; + hasMore?: boolean; +} + +export interface StalkerSessionCategoriesResult { + items: readonly StalkerSessionCategory[]; +} + +export interface StalkerSessionSeason { + id: StalkerSessionEntityId; + number: number; + title?: string; +} + +export interface StalkerSessionSeasonsResult { + seasons: readonly StalkerSessionSeason[]; +} + +export interface StalkerSessionEpisode { + id: StalkerSessionEntityId; + seriesId: StalkerSessionEntityId; + seasonId: StalkerSessionEntityId; + seasonNumber: number; + episodeNumber: number; + title?: string; + command?: string; + thumbnailUrl?: string; +} + +export interface StalkerSessionEpisodesResult { + episodes: readonly StalkerSessionEpisode[]; +} + +export interface StalkerSessionEpgProgram { + id?: StalkerSessionEntityId; + channelId: StalkerSessionEntityId; + title: string; + description?: string; + startsAt: number; + endsAt: number; +} + +export interface StalkerSessionEpgResult { + programs: readonly StalkerSessionEpgProgram[]; +} + +export interface StalkerSessionCreateLinkResult { + streamUrl: string; + playbackContextRef?: string; + title?: string; + thumbnail?: string | null; + isLive?: boolean; + userAgent?: string; + referer?: string; + origin?: string; +} + +export interface StalkerSessionFavoritesResult { + success: boolean; + favorite: boolean; +} + +export interface StalkerSessionAccountSummary { + name?: string; + status?: string; + tariffPlan?: string; + accountBalance?: string; + expiresAt?: string; +} + +export interface StalkerSessionMainInfoResult { + account: StalkerSessionAccountSummary; +} + +export interface StalkerSessionOperationContract< + Parameters, + Result, +> { + parameters: Parameters; + result: Result; +} + +export interface StalkerSessionOperationContractMap { + [STALKER_SESSION_APPLICATION_OPERATIONS.CatalogCategories]: StalkerSessionOperationContract< + StalkerSessionCategoryParameters, + StalkerSessionCategoriesResult + >; + [STALKER_SESSION_APPLICATION_OPERATIONS.CatalogGenres]: StalkerSessionOperationContract< + StalkerSessionGenreParameters, + StalkerSessionCategoriesResult + >; + [STALKER_SESSION_APPLICATION_OPERATIONS.CatalogItems]: StalkerSessionOperationContract< + StalkerSessionCatalogParameters, + StalkerSessionCatalogResult + >; + [STALKER_SESSION_APPLICATION_OPERATIONS.CatalogAllChannels]: StalkerSessionOperationContract< + StalkerSessionAllChannelsParameters, + StalkerSessionCatalogResult + >; + [STALKER_SESSION_APPLICATION_OPERATIONS.CatalogSearch]: StalkerSessionOperationContract< + StalkerSessionSearchParameters, + StalkerSessionCatalogResult + >; + [STALKER_SESSION_APPLICATION_OPERATIONS.SeriesSeasons]: StalkerSessionOperationContract< + StalkerSessionSeriesSeasonsParameters, + StalkerSessionSeasonsResult + >; + [STALKER_SESSION_APPLICATION_OPERATIONS.SeriesEpisodes]: StalkerSessionOperationContract< + StalkerSessionSeriesEpisodesParameters, + StalkerSessionEpisodesResult + >; + [STALKER_SESSION_APPLICATION_OPERATIONS.ShortEpg]: StalkerSessionOperationContract< + StalkerSessionShortEpgParameters, + StalkerSessionEpgResult + >; + [STALKER_SESSION_APPLICATION_OPERATIONS.EpgInfo]: StalkerSessionOperationContract< + StalkerSessionEpgInfoParameters, + StalkerSessionEpgResult + >; + [STALKER_SESSION_APPLICATION_OPERATIONS.CreateLink]: StalkerSessionOperationContract< + StalkerSessionCreateLinkParameters, + StalkerSessionCreateLinkResult + >; + [STALKER_SESSION_APPLICATION_OPERATIONS.Favorites]: StalkerSessionOperationContract< + StalkerSessionFavoritesParameters, + StalkerSessionFavoritesResult + >; + [STALKER_SESSION_APPLICATION_OPERATIONS.MainInfo]: StalkerSessionOperationContract< + StalkerSessionMainInfoParameters, + StalkerSessionMainInfoResult + >; +} + +export type StalkerSessionOperationParameters< + Operation extends StalkerSessionApplicationOperation, +> = StalkerSessionOperationContractMap[Operation]['parameters']; + +export type StalkerSessionOperationResult< + Operation extends StalkerSessionApplicationOperation, +> = StalkerSessionOperationContractMap[Operation]['result']; diff --git a/libs/shared/interfaces/src/lib/stalker-session.interface.ts b/libs/shared/interfaces/src/lib/stalker-session.interface.ts index 85e05e647..4ea45ae27 100644 --- a/libs/shared/interfaces/src/lib/stalker-session.interface.ts +++ b/libs/shared/interfaces/src/lib/stalker-session.interface.ts @@ -1,3 +1,12 @@ +import type { + StalkerSessionAccountSummary, + StalkerSessionApplicationOperation, + StalkerSessionOperationParameters, + StalkerSessionOperationResult, +} from './stalker-session-operations.interface'; + +export * from './stalker-session-operations.interface'; + export const STALKER_SESSION_CONNECTION_MODES = { PersistedOpen: 'persisted-open', Provisional: 'provisional', @@ -23,24 +32,6 @@ export const STALKER_SESSION_REQUEST_RECIPES = { export type StalkerSessionRequestRecipe = (typeof STALKER_SESSION_REQUEST_RECIPES)[keyof typeof STALKER_SESSION_REQUEST_RECIPES]; -export const STALKER_SESSION_APPLICATION_OPERATIONS = { - CatalogCategories: 'get-categories', - CatalogGenres: 'get-genres', - CatalogItems: 'get-ordered-list', - CatalogAllChannels: 'get-all-channels', - CatalogSearch: 'search', - SeriesSeasons: 'get-series-seasons', - SeriesEpisodes: 'get-series-episodes', - ShortEpg: 'get-short-epg', - EpgInfo: 'get-epg-info', - CreateLink: 'create-link', - Favorites: 'favorites', - MainInfo: 'get-main-info', -} as const; - -export type StalkerSessionApplicationOperation = - (typeof STALKER_SESSION_APPLICATION_OPERATIONS)[keyof typeof STALKER_SESSION_APPLICATION_OPERATIONS]; - export const STALKER_SESSION_CONTROL_ACTIONS = { Activate: 'activate', Deactivate: 'deactivate', @@ -178,14 +169,6 @@ export interface StalkerSessionCapabilities { playbackContext: boolean; } -export interface StalkerSessionAccountSummary { - name?: string; - status?: string; - tariffPlan?: string; - accountBalance?: string; - expiresAt?: string; -} - export interface StalkerSessionPersistenceDraft { stalkerSourceUrl?: string; portalUrl: string; @@ -251,26 +234,24 @@ export type StalkerSessionConnectionOutcome = | StalkerSessionCredentialsRequiredOutcome | StalkerSessionFailureOutcome; -export type StalkerSessionParameterValue = - | string - | number - | boolean - | null; +export type StalkerSessionRequest = { + [Operation in StalkerSessionApplicationOperation]: { + leaseRef: StalkerSessionLeaseRef; + operation: Operation; + parameters: StalkerSessionOperationParameters; + requestId?: string; + }; +}[StalkerSessionApplicationOperation]; -export interface StalkerSessionRequest { - leaseRef: StalkerSessionLeaseRef; - operation: StalkerSessionApplicationOperation; - parameters: Readonly>; - requestId?: string; -} - -export interface StalkerSessionRequestSuccess { - kind: 'success'; - requestId: string; - operation: StalkerSessionApplicationOperation; - payload: unknown; - playbackContextRef?: StalkerPlaybackContextRef; -} +export type StalkerSessionRequestSuccess = { + [Operation in StalkerSessionApplicationOperation]: { + kind: 'success'; + requestId: string; + operation: Operation; + payload: StalkerSessionOperationResult; + playbackContextRef?: StalkerPlaybackContextRef; + }; +}[StalkerSessionApplicationOperation]; export type StalkerSessionRequestOutcome = | StalkerSessionRequestSuccess