feat(stalker): map playlists to session descriptors

This commit is contained in:
4gray committed 2026-07-27 09:56:06 +02:00
1 parent a2fd389e74
commit 2646c0c492
4 files changed
+416

No files matched your search

@@ -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';
@@ -0,0 +1,214 @@
import type { Playlist } from '@iptvnator/shared/interfaces';
import {
mapPlaylistToStalkerSessionConnection,
type StalkerSessionDescriptorMappingOptions,
} from './stalker-session-descriptor';
function playlist(overrides: Partial<Playlist> = {}): 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');
});
});
@@ -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<T extends object>(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;
}
@@ -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;