mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-10 10:06:15 -08:00
feat(stalker): map playlists to session descriptors
This commit is contained in:
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;
|
||||
|
||||
Reference in new issue
Block a user