diff --git a/libs/portal/stalker/data-access/src/index.ts b/libs/portal/stalker/data-access/src/index.ts index 9ea6e474c..7ecb8804d 100644 --- a/libs/portal/stalker/data-access/src/index.ts +++ b/libs/portal/stalker/data-access/src/index.ts @@ -4,6 +4,7 @@ export * from './lib/stalker-content-types'; export * from './lib/stalker-itv-cache.service'; export * from './lib/stalker-live-playback.utils'; export * from './lib/stalker-series.adapters'; +export * from './lib/stalker-session-descriptor'; export * from './lib/stalker-session.service'; export * from './lib/stalker-vod.utils'; export * from './lib/stalker.store'; diff --git a/libs/portal/stalker/data-access/src/lib/stalker-session-descriptor.spec.ts b/libs/portal/stalker/data-access/src/lib/stalker-session-descriptor.spec.ts new file mode 100644 index 000000000..47ccf2040 --- /dev/null +++ b/libs/portal/stalker/data-access/src/lib/stalker-session-descriptor.spec.ts @@ -0,0 +1,214 @@ +import type { Playlist } from '@iptvnator/shared/interfaces'; +import { + mapPlaylistToStalkerSessionConnection, + type StalkerSessionDescriptorMappingOptions, +} from './stalker-session-descriptor'; + +function playlist(overrides: Partial = {}): Playlist { + return { + _id: 'playlist-1', + autoRefresh: false, + count: 0, + importDate: '2026-07-27T00:00:00.000Z', + lastUsage: '2026-07-27T00:00:00.000Z', + macAddress: '00-1a-79-aa-bb-cc', + portalUrl: 'https://portal.example/stalker_portal/server/load.php', + title: 'Portal', + ...overrides, + }; +} + +describe('mapPlaylistToStalkerSessionConnection', () => { + it('prefers stalkerSourceUrl, then portalUrl, then the legacy url', () => { + expect( + mapPlaylistToStalkerSessionConnection( + playlist({ + stalkerSourceUrl: 'https://source.example/c/', + portalUrl: 'https://endpoint.example/portal.php', + url: 'https://legacy.example/c/', + }) + ).descriptor.sourceUrl + ).toBe('https://source.example/c/'); + + expect( + mapPlaylistToStalkerSessionConnection( + playlist({ + stalkerSourceUrl: undefined, + portalUrl: 'https://endpoint.example/portal.php', + url: 'https://legacy.example/c/', + }) + ).descriptor.sourceUrl + ).toBe('https://endpoint.example/portal.php'); + + expect( + mapPlaylistToStalkerSessionConnection( + playlist({ + stalkerSourceUrl: undefined, + portalUrl: undefined, + url: 'https://legacy.example/c/', + }) + ).descriptor.sourceUrl + ).toBe('https://legacy.example/c/'); + }); + + it('maps a persisted playlist to normalized identity and learned endpoint hint', () => { + const result = mapPlaylistToStalkerSessionConnection( + playlist({ + portalUrl: 'https://portal.example/server/load.php', + stalkerSourceUrl: 'https://portal.example/c/', + }) + ); + + expect(result).toEqual({ + descriptor: { + connectionMode: 'persisted-open', + learnedEndpointHint: + 'https://portal.example/server/load.php', + macAddress: '00:1A:79:AA:BB:CC', + playlistRef: 'playlist-1', + profilePreset: { + id: 'mag250-public-5_1-minimal-v1', + version: 1, + }, + sourceUrl: 'https://portal.example/c/', + }, + }); + }); + + it.each([ + { + options: { + connectionMode: 'provisional', + provisionalReason: 'import', + } satisfies StalkerSessionDescriptorMappingOptions, + reason: 'import', + }, + { + options: { + connectionMode: 'provisional', + provisionalReason: 'edit', + } satisfies StalkerSessionDescriptorMappingOptions, + reason: 'edit', + }, + { + options: { + connectionMode: 'provisional', + provisionalReason: 'migration', + } satisfies StalkerSessionDescriptorMappingOptions, + reason: 'migration', + }, + ])('maps a provisional $reason attempt', ({ options, reason }) => { + expect( + mapPlaylistToStalkerSessionConnection(playlist(), options) + .descriptor + ).toEqual( + expect.objectContaining({ + connectionMode: 'provisional', + provisionalReason: reason, + }) + ); + }); + + it('maps explicit profile, identity, and transport overrides unchanged', () => { + const result = mapPlaylistToStalkerSessionConnection( + playlist({ + stalkerIdentityOverrides: { + apiSignature: 'API-SIGNATURE', + deviceId1: 'DEVICE-1', + firmwareVersion: 'FIRMWARE-TUPLE', + serialNumber: 'SERIAL-1', + }, + stalkerProfilePreset: { + id: 'mag250-public-5_1-minimal-v1', + version: 1, + }, + stalkerTransportConfiguration: { + language: 'de', + locale: 'de-DE', + origin: 'https://portal.example', + referer: 'https://portal.example/c/', + timezone: 'Europe/Berlin', + userAgent: 'Explicit User Agent', + xUserAgent: 'Explicit X User Agent', + }, + }) + ); + + expect(result.descriptor.identityOverrides).toEqual({ + apiSignature: 'API-SIGNATURE', + deviceId1: 'DEVICE-1', + firmwareVersion: 'FIRMWARE-TUPLE', + serialNumber: 'SERIAL-1', + }); + expect(result.descriptor.transportConfiguration).toEqual({ + language: 'de', + locale: 'de-DE', + origin: 'https://portal.example', + referer: 'https://portal.example/c/', + timezone: 'Europe/Berlin', + userAgent: 'Explicit User Agent', + xUserAgent: 'Explicit X User Agent', + }); + }); + + it('maps legacy identity and transport fields while omitting blanks and the synthetic default serial', () => { + const result = mapPlaylistToStalkerSessionConnection( + playlist({ + origin: 'https://portal.example', + referrer: 'https://portal.example/c/', + stalkerDeviceId1: ' DEVICE-1 ', + stalkerDeviceId2: '', + stalkerSerialNumber: 'BEDACD4569BAF', + stalkerSignature1: 'SIGNATURE-1', + stalkerSignature2: ' ', + userAgent: 'Explicit User Agent', + }) + ); + + expect(result.descriptor.identityOverrides).toEqual({ + deviceId1: ' DEVICE-1 ', + signature1: 'SIGNATURE-1', + }); + expect(result.descriptor.transportConfiguration).toEqual({ + origin: 'https://portal.example', + referer: 'https://portal.example/c/', + userAgent: 'Explicit User Agent', + }); + }); + + it('returns saved credentials separately without placing them in the descriptor', () => { + const result = mapPlaylistToStalkerSessionConnection( + playlist({ + password: 'password-secret', + username: ' exact user ', + }) + ); + + expect(result.savedCredentials).toEqual({ + password: 'password-secret', + username: ' exact user ', + }); + expect(JSON.stringify(result.descriptor)).not.toContain( + 'password-secret' + ); + expect(result.descriptor).not.toHaveProperty('credentials'); + }); + + it('never reads the legacy stalkerToken into the new mapping', () => { + const result = mapPlaylistToStalkerSessionConnection( + playlist({ stalkerToken: 'legacy-token-secret' }) + ); + + expect(JSON.stringify(result)).not.toContain('legacy-token-secret'); + }); + + it.each([ + playlist({ stalkerSourceUrl: undefined, portalUrl: undefined, url: '' }), + playlist({ macAddress: undefined }), + playlist({ _id: '' }), + ])('rejects an incomplete playlist mapping', (invalidPlaylist) => { + expect(() => + mapPlaylistToStalkerSessionConnection(invalidPlaylist) + ).toThrow('invalid-identity-input'); + }); +}); 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 new file mode 100644 index 000000000..6c7cea21c --- /dev/null +++ b/libs/portal/stalker/data-access/src/lib/stalker-session-descriptor.ts @@ -0,0 +1,179 @@ +import { normalizeStalkerMacAddress } from '@iptvnator/portal/stalker/protocol'; +import { + LEGACY_DEFAULT_STALKER_SERIAL, + type Playlist, + type StalkerSessionConnectionDescriptor, + type StalkerSessionIdentityOverrides, + type StalkerSessionProfilePreset, + type StalkerSessionProvisionalReason, + type StalkerSessionTransportConfiguration, +} from '@iptvnator/shared/interfaces'; + +const DEFAULT_PROFILE_PRESET: StalkerSessionProfilePreset = { + id: 'mag250-public-5_1-minimal-v1', + version: 1, +}; + +export type StalkerSessionDescriptorMappingOptions = + | { + connectionMode?: 'persisted-open'; + provisionalReason?: never; + } + | { + connectionMode: 'provisional'; + provisionalReason: StalkerSessionProvisionalReason; + }; + +export interface StalkerSavedCredentials { + readonly username: string; + readonly password: string; +} + +export interface StalkerSessionDescriptorMapping { + readonly descriptor: StalkerSessionConnectionDescriptor; + readonly savedCredentials?: StalkerSavedCredentials; +} + +export function mapPlaylistToStalkerSessionConnection( + playlist: Playlist, + options: StalkerSessionDescriptorMappingOptions = {} +): StalkerSessionDescriptorMapping { + const playlistRef = requiredValue(playlist._id); + const sourceUrl = requiredValue( + firstPresent( + playlist.stalkerSourceUrl, + playlist.portalUrl, + playlist.url + ) + ); + const macAddress = normalizeMac(playlist.macAddress); + const learnedEndpointHint = presentValue(playlist.portalUrl); + const identityOverrides = mapIdentityOverrides(playlist); + const transportConfiguration = mapTransportConfiguration(playlist); + const base = { + macAddress, + playlistRef, + profilePreset: mapProfilePreset(playlist.stalkerProfilePreset), + sourceUrl, + ...(learnedEndpointHint === undefined + ? {} + : { learnedEndpointHint }), + ...(identityOverrides === undefined ? {} : { identityOverrides }), + ...(transportConfiguration === undefined + ? {} + : { transportConfiguration }), + }; + + const descriptor: StalkerSessionConnectionDescriptor = + options.connectionMode === 'provisional' + ? { + ...base, + connectionMode: 'provisional', + provisionalReason: options.provisionalReason, + } + : { + ...base, + connectionMode: 'persisted-open', + }; + const savedCredentials = mapSavedCredentials(playlist); + return { + descriptor, + ...(savedCredentials === undefined ? {} : { savedCredentials }), + }; +} + +function mapProfilePreset( + profilePreset: StalkerSessionProfilePreset | undefined +): StalkerSessionProfilePreset { + return profilePreset === undefined + ? { ...DEFAULT_PROFILE_PRESET } + : { ...profilePreset }; +} + +function mapIdentityOverrides( + playlist: Playlist +): StalkerSessionIdentityOverrides | undefined { + if (playlist.stalkerIdentityOverrides !== undefined) { + return copyPresentStringValues(playlist.stalkerIdentityOverrides); + } + + const serialNumber = + playlist.stalkerSerialNumber?.trim().toUpperCase() === + LEGACY_DEFAULT_STALKER_SERIAL + ? undefined + : playlist.stalkerSerialNumber; + return copyPresentStringValues({ + deviceId1: playlist.stalkerDeviceId1, + deviceId2: playlist.stalkerDeviceId2, + serialNumber, + signature1: playlist.stalkerSignature1, + signature2: playlist.stalkerSignature2, + }); +} + +function mapTransportConfiguration( + playlist: Playlist +): StalkerSessionTransportConfiguration | undefined { + if (playlist.stalkerTransportConfiguration !== undefined) { + return copyPresentStringValues( + playlist.stalkerTransportConfiguration + ); + } + + return copyPresentStringValues({ + origin: playlist.origin, + referer: playlist.referrer, + userAgent: playlist.userAgent, + }); +} + +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; +} + +function mapSavedCredentials( + playlist: Playlist +): StalkerSavedCredentials | undefined { + if (playlist.username === undefined || playlist.username.length === 0) { + return undefined; + } + return { + password: playlist.password ?? '', + username: playlist.username, + }; +} + +function normalizeMac(value: string | undefined): string { + if (value === undefined) { + throw new Error('invalid-identity-input'); + } + try { + return normalizeStalkerMacAddress(value); + } catch { + throw new Error('invalid-identity-input'); + } +} + +function requiredValue(value: string | undefined): string { + const present = presentValue(value); + if (present === undefined) { + throw new Error('invalid-identity-input'); + } + return present; +} + +function firstPresent( + ...values: readonly (string | undefined)[] +): string | undefined { + return values.find((value) => presentValue(value) !== undefined); +} + +function presentValue(value: string | undefined): string | undefined { + return value !== undefined && value.trim().length > 0 ? value : undefined; +} diff --git a/libs/shared/interfaces/src/lib/playlist.interface.ts b/libs/shared/interfaces/src/lib/playlist.interface.ts index 2721ce84e..26a321a1a 100644 --- a/libs/shared/interfaces/src/lib/playlist.interface.ts +++ b/libs/shared/interfaces/src/lib/playlist.interface.ts @@ -1,5 +1,11 @@ import { PlaylistRecentlyViewedItem } from './playlist-recently-viewed.interface'; import { StalkerPortalItem } from './stalker-portal-item.interface'; +import type { + StalkerSessionIdentityOverrides, + StalkerSessionProfilePreset, + StalkerSessionRequestRecipe, + StalkerSessionTransportConfiguration, +} from './stalker-session.interface'; /** * An interface that describe the possible states of the playlist update/refresh process @@ -51,6 +57,22 @@ export interface Playlist { password?: string; macAddress?: string; portalUrl?: string; + /** User-entered Stalker source URL; endpoint discovery starts here. */ + stalkerSourceUrl?: string; + /** Last verified landing URL used for Referer behavior. */ + stalkerLandingUrl?: string; + /** Last verified request recipe; treated as a revalidated hint. */ + stalkerRequestRecipe?: StalkerSessionRequestRecipe; + /** Version of the classifier that established stalkerRequestRecipe. */ + stalkerRecipeClassifierVersion?: number; + /** Selected coherent Stalker identity preset. */ + stalkerProfilePreset?: StalkerSessionProfilePreset; + /** Explicit user-owned identity values; blank fields remain absent. */ + stalkerIdentityOverrides?: StalkerSessionIdentityOverrides; + /** Explicit non-secret transport/profile presentation overrides. */ + stalkerTransportConfiguration?: StalkerSessionTransportConfiguration; + /** ISO timestamp of the last complete, successful endpoint verification. */ + stalkerLastVerifiedAt?: string; recentlyViewed?: PlaylistRecentlyViewedItem[]; /** Indicates if this is a full stalker portal URL (e.g., /stalker_portal/c) requiring handshake authentication */ isFullStalkerPortal?: boolean;