mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-12 20:16:15 -08:00
fix(stalker): preserve explicit portal identity (#941)
This commit is contained in:
1 parent
2d5c4fa4f9
commit
272e0e772e
17 files changed
+912
-215
No files matched your search
@@ -0,0 +1,95 @@
|
||||
import {
|
||||
LEGACY_DEFAULT_STALKER_SERIAL,
|
||||
buildStalkerIdentityRequestContext,
|
||||
} from './stalker-identity';
|
||||
|
||||
describe('buildStalkerIdentityRequestContext', () => {
|
||||
const macAddress = '00:1A:79:AA:BB:CC';
|
||||
|
||||
it('does not add SN header or force get_profile SN fields when serial is absent', () => {
|
||||
const context = buildStalkerIdentityRequestContext({
|
||||
macAddress,
|
||||
params: {
|
||||
type: 'stb',
|
||||
action: 'get_profile',
|
||||
sn: 'STALE-SN',
|
||||
metrics: JSON.stringify({
|
||||
mac: macAddress,
|
||||
sn: 'STALE-SN',
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
expect(context.effectiveSerialNumber).toBeUndefined();
|
||||
expect(context.headers).not.toHaveProperty('SN');
|
||||
expect(context.cookieString).not.toContain('__cfduid=');
|
||||
expect(context.requestParams).not.toHaveProperty('sn');
|
||||
expect(
|
||||
JSON.parse(String(context.requestParams.metrics))
|
||||
).not.toHaveProperty('sn');
|
||||
});
|
||||
|
||||
it('preserves a provided serial exactly in headers, params, and metrics', () => {
|
||||
const context = buildStalkerIdentityRequestContext({
|
||||
macAddress,
|
||||
serialNumber: 'CustomSn123',
|
||||
params: {
|
||||
type: 'stb',
|
||||
action: 'get_profile',
|
||||
metrics: JSON.stringify({
|
||||
mac: macAddress,
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
expect(context.effectiveSerialNumber).toBe('CustomSn123');
|
||||
expect(context.headers.SN).toBe('CustomSn123');
|
||||
expect(context.cookieString).toContain('__cfduid=');
|
||||
expect(
|
||||
context.cookieString.match(/__cfduid=([^;]+)/)?.[1]
|
||||
).toHaveLength(32);
|
||||
expect(context.requestParams.sn).toBe('CustomSn123');
|
||||
expect(JSON.parse(String(context.requestParams.metrics))).toEqual(
|
||||
expect.objectContaining({
|
||||
sn: 'CustomSn123',
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('treats the legacy default serial as absent', () => {
|
||||
const context = buildStalkerIdentityRequestContext({
|
||||
macAddress,
|
||||
serialNumber: LEGACY_DEFAULT_STALKER_SERIAL,
|
||||
params: {
|
||||
type: 'stb',
|
||||
action: 'get_profile',
|
||||
sn: LEGACY_DEFAULT_STALKER_SERIAL,
|
||||
metrics: JSON.stringify({
|
||||
mac: macAddress,
|
||||
sn: LEGACY_DEFAULT_STALKER_SERIAL,
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
expect(context.effectiveSerialNumber).toBeUndefined();
|
||||
expect(context.headers).not.toHaveProperty('SN');
|
||||
expect(context.cookieString).not.toContain('__cfduid=');
|
||||
expect(context.requestParams).not.toHaveProperty('sn');
|
||||
expect(
|
||||
JSON.parse(String(context.requestParams.metrics))
|
||||
).not.toHaveProperty('sn');
|
||||
});
|
||||
|
||||
it('removes stale SN params from non-profile requests', () => {
|
||||
const context = buildStalkerIdentityRequestContext({
|
||||
macAddress,
|
||||
params: {
|
||||
type: 'itv',
|
||||
action: 'get_ordered_list',
|
||||
sn: 'STALE-SN',
|
||||
},
|
||||
});
|
||||
|
||||
expect(context.requestParams).not.toHaveProperty('sn');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,117 @@
|
||||
import {
|
||||
buildStalkerSerialCfduid,
|
||||
LEGACY_DEFAULT_STALKER_SERIAL,
|
||||
normalizeStalkerSerialNumber,
|
||||
} from '@iptvnator/shared/interfaces';
|
||||
|
||||
export { LEGACY_DEFAULT_STALKER_SERIAL };
|
||||
|
||||
interface StalkerIdentityRequestInput {
|
||||
macAddress: string;
|
||||
params: Record<string, string | number>;
|
||||
token?: string;
|
||||
serialNumber?: string;
|
||||
}
|
||||
|
||||
interface StalkerIdentityRequestContext {
|
||||
requestParams: Record<string, string | number>;
|
||||
headers: Record<string, string>;
|
||||
cookieString: string;
|
||||
effectiveSerialNumber?: string;
|
||||
}
|
||||
|
||||
export function buildStalkerIdentityRequestContext({
|
||||
macAddress,
|
||||
params,
|
||||
token,
|
||||
serialNumber,
|
||||
}: StalkerIdentityRequestInput): StalkerIdentityRequestContext {
|
||||
const effectiveSerialNumber = normalizeStalkerSerialNumber(serialNumber);
|
||||
const requestParams = buildRequestParams(params, effectiveSerialNumber);
|
||||
const cookieString = buildCookieString(macAddress, effectiveSerialNumber);
|
||||
const headers: Record<string, string> = {
|
||||
Cookie: cookieString,
|
||||
// Use MAG250 User-Agent matching stalker-to-m3u implementation
|
||||
'User-Agent':
|
||||
'Mozilla/5.0 (QtEmbedded; U; Linux; C) AppleWebKit/533.3 (KHTML, like Gecko) MAG250',
|
||||
'X-User-Agent':
|
||||
'Mozilla/5.0 (QtEmbedded; U; Linux; C) AppleWebKit/533.3 (KHTML, like Gecko) MAG250',
|
||||
Accept: '*/*',
|
||||
Connection: 'keep-alive',
|
||||
'Accept-Language': 'en-US,en;q=0.9',
|
||||
};
|
||||
|
||||
if (effectiveSerialNumber) {
|
||||
headers['SN'] = effectiveSerialNumber;
|
||||
}
|
||||
|
||||
if (token) {
|
||||
headers['Authorization'] = `Bearer ${token}`;
|
||||
}
|
||||
|
||||
return {
|
||||
requestParams,
|
||||
headers,
|
||||
cookieString,
|
||||
...(effectiveSerialNumber ? { effectiveSerialNumber } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
function buildRequestParams(
|
||||
params: Record<string, string | number>,
|
||||
effectiveSerialNumber: string | undefined
|
||||
): Record<string, string | number> {
|
||||
const requestParams = { ...params };
|
||||
const isProfileRequest =
|
||||
requestParams.type === 'stb' &&
|
||||
requestParams.action === 'get_profile';
|
||||
|
||||
if (!isProfileRequest) {
|
||||
delete requestParams.sn;
|
||||
return requestParams;
|
||||
}
|
||||
|
||||
if (effectiveSerialNumber) {
|
||||
requestParams.sn = effectiveSerialNumber;
|
||||
} else {
|
||||
delete requestParams.sn;
|
||||
}
|
||||
|
||||
if (typeof requestParams.metrics === 'string') {
|
||||
try {
|
||||
const parsedMetrics = JSON.parse(requestParams.metrics);
|
||||
const nextMetrics = { ...(parsedMetrics ?? {}) };
|
||||
|
||||
if (effectiveSerialNumber) {
|
||||
nextMetrics.sn = effectiveSerialNumber;
|
||||
} else {
|
||||
delete nextMetrics.sn;
|
||||
}
|
||||
|
||||
requestParams.metrics = JSON.stringify(nextMetrics);
|
||||
} catch {
|
||||
// Keep original metrics payload when malformed.
|
||||
}
|
||||
}
|
||||
|
||||
return requestParams;
|
||||
}
|
||||
|
||||
function buildCookieString(
|
||||
macAddress: string,
|
||||
effectiveSerialNumber: string | undefined
|
||||
): string {
|
||||
const cookieParts = [
|
||||
`mac=${macAddress}`,
|
||||
'stb_lang=en_US@rg=dezzzz',
|
||||
'timezone=Europe/Berlin',
|
||||
];
|
||||
|
||||
if (effectiveSerialNumber) {
|
||||
cookieParts.push(
|
||||
`__cfduid=${buildStalkerSerialCfduid(effectiveSerialNumber)}`
|
||||
);
|
||||
}
|
||||
|
||||
return cookieParts.join('; ');
|
||||
}
|
||||
@@ -4,40 +4,11 @@
|
||||
*/
|
||||
|
||||
import axios, { AxiosRequestConfig } from 'axios';
|
||||
import { createHash } from 'crypto';
|
||||
import { ipcMain } from 'electron';
|
||||
import { PortalDebugEvent, STALKER_REQUEST } from '@iptvnator/shared/interfaces';
|
||||
import { rememberStalkerPlaybackContext } from '../services/stalker-playback-context.service';
|
||||
import { emitPortalDebugEvent } from './portal-debug.events';
|
||||
|
||||
const LEGACY_DEFAULT_SERIAL = 'BEDACD4569BAF';
|
||||
|
||||
function deriveStalkerIdentity(
|
||||
macAddress: string,
|
||||
providedSerial?: string
|
||||
): { serialNumber: string; cfduid: string } {
|
||||
const normalizedMac = String(macAddress ?? '').trim().toUpperCase();
|
||||
const md5 = createHash('md5').update(normalizedMac).digest('hex');
|
||||
const derivedSerial = md5.slice(0, 13).toUpperCase();
|
||||
|
||||
const normalizedProvided = String(providedSerial ?? '')
|
||||
.trim()
|
||||
.toUpperCase();
|
||||
const useProvidedSerial =
|
||||
normalizedProvided.length > 0 &&
|
||||
normalizedProvided !== LEGACY_DEFAULT_SERIAL;
|
||||
|
||||
const serialNumber = useProvidedSerial ? normalizedProvided : derivedSerial;
|
||||
|
||||
// Keep serial and __cfduid coherent: serial as prefix + stable MAC hash tail.
|
||||
const serialPrefix = serialNumber.toLowerCase().replace(/[^a-f0-9]/g, '');
|
||||
const cfduid = `${serialPrefix}${md5.slice(serialPrefix.length)}`.slice(
|
||||
0,
|
||||
32
|
||||
);
|
||||
|
||||
return { serialNumber, cfduid };
|
||||
}
|
||||
import { buildStalkerIdentityRequestContext } from './stalker-identity';
|
||||
|
||||
export default class StalkerEvents {
|
||||
static bootstrapStalkerEvents(): Electron.IpcMain {
|
||||
@@ -55,7 +26,7 @@ ipcMain.handle(
|
||||
payload: {
|
||||
url: string;
|
||||
macAddress: string;
|
||||
params: Record<string, string>;
|
||||
params: Record<string, string | number>;
|
||||
token?: string;
|
||||
serialNumber?: string;
|
||||
requestId?: string;
|
||||
@@ -66,29 +37,13 @@ ipcMain.handle(
|
||||
try {
|
||||
const { url, macAddress, params, token, serialNumber, requestId } =
|
||||
payload;
|
||||
const identity = deriveStalkerIdentity(macAddress, serialNumber);
|
||||
const effectiveSerialNumber = identity.serialNumber;
|
||||
const requestParams = { ...params };
|
||||
|
||||
// Some providers validate sn/metrics strongly in get_profile.
|
||||
if (
|
||||
requestParams.type === 'stb' &&
|
||||
requestParams.action === 'get_profile'
|
||||
) {
|
||||
requestParams.sn = effectiveSerialNumber;
|
||||
|
||||
if (typeof requestParams.metrics === 'string') {
|
||||
try {
|
||||
const parsedMetrics = JSON.parse(requestParams.metrics);
|
||||
requestParams.metrics = JSON.stringify({
|
||||
...(parsedMetrics ?? {}),
|
||||
sn: effectiveSerialNumber,
|
||||
});
|
||||
} catch {
|
||||
// Keep original metrics payload when malformed.
|
||||
}
|
||||
}
|
||||
}
|
||||
const { requestParams, headers, effectiveSerialNumber } =
|
||||
buildStalkerIdentityRequestContext({
|
||||
macAddress,
|
||||
params,
|
||||
token,
|
||||
serialNumber,
|
||||
});
|
||||
|
||||
// Build URL with query parameters
|
||||
// Note: For 'cmd' parameter, we need to use encodeURI (not encodeURIComponent)
|
||||
@@ -117,34 +72,6 @@ ipcMain.handle(
|
||||
// Build final URL with manually constructed query string
|
||||
const fullUrl = `${urlObject.origin}${urlObject.pathname}?${queryParts.join('&')}`;
|
||||
|
||||
// Build cookie string matching the working curl example format
|
||||
// Format: mac=XX:XX:XX:XX:XX:XX; stb_lang=de_DE; timezone=Europe/Berlin; __cfduid=...
|
||||
// The __cfduid cookie uses the serial number lowercase + random suffix
|
||||
const cookieString = `mac=${macAddress}; stb_lang=en_US@rg=dezzzz; timezone=Europe/Berlin; __cfduid=${identity.cfduid}`;
|
||||
|
||||
// Build headers - using MAG250 User-Agent that stalker-to-m3u uses
|
||||
const headers: Record<string, string> = {
|
||||
Cookie: cookieString,
|
||||
// Use MAG250 User-Agent matching stalker-to-m3u implementation
|
||||
'User-Agent':
|
||||
'Mozilla/5.0 (QtEmbedded; U; Linux; C) AppleWebKit/533.3 (KHTML, like Gecko) MAG250',
|
||||
'X-User-Agent':
|
||||
'Mozilla/5.0 (QtEmbedded; U; Linux; C) AppleWebKit/533.3 (KHTML, like Gecko) MAG250',
|
||||
Accept: '*/*',
|
||||
Connection: 'keep-alive',
|
||||
'Accept-Language': 'en-US,en;q=0.9',
|
||||
};
|
||||
|
||||
// Add SN (serial number) header if provided - required by some portals
|
||||
if (effectiveSerialNumber) {
|
||||
headers['SN'] = effectiveSerialNumber;
|
||||
}
|
||||
|
||||
// Add Authorization header if token is provided
|
||||
if (token) {
|
||||
headers['Authorization'] = `Bearer ${token}`;
|
||||
}
|
||||
|
||||
// Determine timeout based on action type
|
||||
// create_link requests can take longer as server generates stream URL
|
||||
const isCreateLink = requestParams.action === 'create_link';
|
||||
@@ -200,7 +127,7 @@ ipcMain.handle(
|
||||
const debugEvent: PortalDebugEvent = {
|
||||
requestId,
|
||||
provider: 'stalker',
|
||||
operation: params.action ?? 'unknown',
|
||||
operation: String(params.action ?? 'unknown'),
|
||||
transport: 'electron-main',
|
||||
startedAt: new Date(startedAt).toISOString(),
|
||||
durationMs: Date.now() - startedAt,
|
||||
@@ -217,7 +144,7 @@ ipcMain.handle(
|
||||
const debugEvent: PortalDebugEvent = {
|
||||
requestId: payload.requestId,
|
||||
provider: 'stalker',
|
||||
operation: payload.params?.action ?? 'unknown',
|
||||
operation: String(payload.params?.action ?? 'unknown'),
|
||||
transport: 'electron-main',
|
||||
startedAt: new Date(startedAt).toISOString(),
|
||||
durationMs: Date.now() - startedAt,
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
import { LEGACY_DEFAULT_STALKER_SERIAL } from '@iptvnator/shared/interfaces';
|
||||
import {
|
||||
getStalkerPlaybackContextHeaders,
|
||||
rememberStalkerPlaybackContext,
|
||||
} from './stalker-playback-context.service';
|
||||
|
||||
describe('stalker playback context', () => {
|
||||
const macAddress = '00:1A:79:AA:BB:CC';
|
||||
|
||||
function rememberSameOriginContext(
|
||||
name: string,
|
||||
serialNumber?: string
|
||||
): Record<string, string> {
|
||||
const origin = `http://${name}.example.test`;
|
||||
const streamUrl = `${origin}/stream/1.ts`;
|
||||
|
||||
rememberStalkerPlaybackContext({
|
||||
streamUrl,
|
||||
portalUrl: `${origin}/stalker_portal/server/load.php`,
|
||||
macAddress,
|
||||
serialNumber,
|
||||
token: 'token-1',
|
||||
});
|
||||
|
||||
return getStalkerPlaybackContextHeaders(streamUrl) ?? {};
|
||||
}
|
||||
|
||||
it('does not add SN or a serial-derived __cfduid when serial is absent', () => {
|
||||
const headers = rememberSameOriginContext('stalker-no-serial');
|
||||
|
||||
expect(headers).not.toHaveProperty('SN');
|
||||
expect(headers['Cookie']).not.toContain('__cfduid=');
|
||||
});
|
||||
|
||||
it('preserves a provided serial and creates a canonical __cfduid', () => {
|
||||
const headers = rememberSameOriginContext(
|
||||
'stalker-with-serial',
|
||||
' CustomSn123 '
|
||||
);
|
||||
const cfduid = headers['Cookie']?.match(/__cfduid=([^;]+)/)?.[1];
|
||||
|
||||
expect(headers['SN']).toBe('CustomSn123');
|
||||
expect(cfduid).toHaveLength(32);
|
||||
});
|
||||
|
||||
it('treats the legacy default serial as absent', () => {
|
||||
const headers = rememberSameOriginContext(
|
||||
'stalker-legacy-serial',
|
||||
LEGACY_DEFAULT_STALKER_SERIAL
|
||||
);
|
||||
|
||||
expect(headers).not.toHaveProperty('SN');
|
||||
expect(headers['Cookie']).not.toContain('__cfduid=');
|
||||
});
|
||||
});
|
||||
@@ -1,3 +1,8 @@
|
||||
import {
|
||||
buildStalkerSerialCfduid,
|
||||
normalizeStalkerSerialNumber,
|
||||
} from '@iptvnator/shared/interfaces';
|
||||
|
||||
const STALKER_MAG_USER_AGENT =
|
||||
'Mozilla/5.0 (QtEmbedded; U; Linux; C) AppleWebKit/533.3 (KHTML, like Gecko) MAG250';
|
||||
const STALKER_STREAM_USER_AGENT = 'KSPlayer';
|
||||
@@ -66,16 +71,15 @@ export function rememberStalkerPlaybackContext(input: {
|
||||
const streamKey = normalizeStreamUrl(input.streamUrl);
|
||||
if (!streamKey || !input.macAddress) return;
|
||||
|
||||
const serialNumber = normalizeStalkerSerialNumber(input.serialNumber);
|
||||
const cookieParts = [
|
||||
`mac=${input.macAddress}`,
|
||||
'stb_lang=en_US@rg=dezzzz',
|
||||
'timezone=Europe/Berlin',
|
||||
];
|
||||
|
||||
if (input.serialNumber) {
|
||||
cookieParts.push(
|
||||
`__cfduid=${input.serialNumber.toLowerCase()}e030245495acd6ebfc1`
|
||||
);
|
||||
if (serialNumber) {
|
||||
cookieParts.push(`__cfduid=${buildStalkerSerialCfduid(serialNumber)}`);
|
||||
}
|
||||
|
||||
const streamOrigin = getOrigin(input.streamUrl) || undefined;
|
||||
@@ -112,8 +116,8 @@ export function rememberStalkerPlaybackContext(input: {
|
||||
headers['Referer'] = portalOrigin;
|
||||
}
|
||||
|
||||
if (!crossOriginStream && input.serialNumber) {
|
||||
headers['SN'] = input.serialNumber;
|
||||
if (!crossOriginStream && serialNumber) {
|
||||
headers['SN'] = serialNumber;
|
||||
}
|
||||
|
||||
if (!crossOriginStream && input.token) {
|
||||
|
||||
@@ -103,6 +103,29 @@ Failure-handling rule:
|
||||
not `undefined` collections or renderer exceptions. The workspace Stalker
|
||||
context panel and live layout rely on this guarantee.
|
||||
|
||||
## Stalker Identity Policy
|
||||
|
||||
Full Stalker/Ministra portal authentication defaults to MAC-only identity. The
|
||||
import UI can capture optional serial number, device IDs, and signatures, but
|
||||
blank fields are not generated or forwarded to `get_profile`.
|
||||
|
||||
- User-provided `sn`, `device_id`, `device_id2`, `signature`, and `signature2`
|
||||
values are trimmed, persisted under the canonical `stalker*` playlist fields,
|
||||
and reused for initial auth, token refresh, retry auth, normal API requests,
|
||||
and same-origin playback headers.
|
||||
- Empty optional identity fields remain absent. IPTVnator must not generate a
|
||||
device ID from the MAC address or duplicate `device_id2` from `device_id1`.
|
||||
- The legacy default serial value `BEDACD4569BAF` is treated as absent at
|
||||
runtime so older blank imports do not keep sending a synthetic serial number.
|
||||
- Playback headers use the same serial normalization, so the legacy default is
|
||||
not sent as `SN` or as a serial-derived `__cfduid`. MAC-only API and
|
||||
playback requests do not synthesize `__cfduid`; when a real serial is
|
||||
present, same-origin playback uses a canonical 32-character `__cfduid`
|
||||
protocol cookie.
|
||||
- Generated MAG-like identity remains a future explicit setting. It must not be
|
||||
the default because strict portals can bind accounts to the first device
|
||||
fingerprint they receive.
|
||||
|
||||
## Live TV and Radio
|
||||
|
||||
The Stalker live route and radio route intentionally share
|
||||
|
||||
+120
@@ -0,0 +1,120 @@
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { MatSnackBar } from '@angular/material/snack-bar';
|
||||
import { Store } from '@ngrx/store';
|
||||
import { TranslateService } from '@ngx-translate/core';
|
||||
import { StalkerSessionService } from '@iptvnator/portal/stalker/data-access';
|
||||
import { StalkerPortalImportComponent } from './stalker-portal-import.component';
|
||||
|
||||
describe('StalkerPortalImportComponent identity handling', () => {
|
||||
let component: StalkerPortalImportComponent;
|
||||
let stalkerSession: { authenticate: jest.Mock };
|
||||
let store: { dispatch: jest.Mock };
|
||||
|
||||
beforeEach(() => {
|
||||
stalkerSession = {
|
||||
authenticate: jest.fn().mockResolvedValue({ token: 'token-1' }),
|
||||
};
|
||||
store = {
|
||||
dispatch: jest.fn(),
|
||||
};
|
||||
|
||||
TestBed.configureTestingModule({
|
||||
providers: [
|
||||
{ provide: StalkerSessionService, useValue: stalkerSession },
|
||||
{ provide: Store, useValue: store },
|
||||
{
|
||||
provide: MatSnackBar,
|
||||
useValue: { open: jest.fn() },
|
||||
},
|
||||
{
|
||||
provide: TranslateService,
|
||||
useValue: { instant: jest.fn((value: string) => value) },
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
component = TestBed.runInInjectionContext(
|
||||
() => new StalkerPortalImportComponent()
|
||||
);
|
||||
});
|
||||
|
||||
it('passes trimmed SN, device IDs, and signatures into initial authentication and persisted playlist metadata', async () => {
|
||||
component.form.patchValue({
|
||||
_id: 'playlist-1',
|
||||
title: 'Strict Portal',
|
||||
macAddress: '00:1A:79:AA:BB:CC',
|
||||
portalUrl: 'https://portal.example.com/stalker_portal/c',
|
||||
serialNumber: ' CUSTOMSN123 ',
|
||||
deviceId1: ' DEVICE-ID-1 ',
|
||||
deviceId2: ' DEVICE-ID-2 ',
|
||||
signature1: ' SIGNATURE-1 ',
|
||||
signature2: ' SIGNATURE-2 ',
|
||||
importDate: '2026-05-15T00:00:00.000Z',
|
||||
});
|
||||
|
||||
await component.addPlaylist();
|
||||
|
||||
expect(stalkerSession.authenticate).toHaveBeenCalledWith(
|
||||
'https://portal.example.com/stalker_portal/server/load.php',
|
||||
'00:1A:79:AA:BB:CC',
|
||||
{
|
||||
serialNumber: 'CUSTOMSN123',
|
||||
deviceId1: 'DEVICE-ID-1',
|
||||
deviceId2: 'DEVICE-ID-2',
|
||||
signature1: 'SIGNATURE-1',
|
||||
signature2: 'SIGNATURE-2',
|
||||
}
|
||||
);
|
||||
|
||||
const playlist = store.dispatch.mock.calls[0][0].playlist;
|
||||
expect(playlist).toEqual(
|
||||
expect.objectContaining({
|
||||
stalkerSerialNumber: 'CUSTOMSN123',
|
||||
stalkerDeviceId1: 'DEVICE-ID-1',
|
||||
stalkerDeviceId2: 'DEVICE-ID-2',
|
||||
stalkerSignature1: 'SIGNATURE-1',
|
||||
stalkerSignature2: 'SIGNATURE-2',
|
||||
})
|
||||
);
|
||||
expect(playlist.serialNumber).toBeUndefined();
|
||||
expect(playlist.deviceId1).toBeUndefined();
|
||||
expect(playlist.deviceId2).toBeUndefined();
|
||||
expect(playlist.signature1).toBeUndefined();
|
||||
expect(playlist.signature2).toBeUndefined();
|
||||
});
|
||||
|
||||
it('keeps blank Stalker identity fields absent instead of generating defaults', async () => {
|
||||
component.form.patchValue({
|
||||
_id: 'playlist-1',
|
||||
title: 'MAC Only Portal',
|
||||
macAddress: '00:1A:79:AA:BB:CC',
|
||||
portalUrl: 'https://portal.example.com/stalker_portal/c',
|
||||
serialNumber: ' ',
|
||||
deviceId1: ' ',
|
||||
deviceId2: '',
|
||||
signature1: ' ',
|
||||
signature2: '',
|
||||
importDate: '2026-05-15T00:00:00.000Z',
|
||||
});
|
||||
|
||||
await component.addPlaylist();
|
||||
|
||||
expect(stalkerSession.authenticate).toHaveBeenCalledWith(
|
||||
'https://portal.example.com/stalker_portal/server/load.php',
|
||||
'00:1A:79:AA:BB:CC',
|
||||
{}
|
||||
);
|
||||
|
||||
const playlist = store.dispatch.mock.calls[0][0].playlist;
|
||||
expect(playlist.stalkerSerialNumber).toBeUndefined();
|
||||
expect(playlist.stalkerDeviceId1).toBeUndefined();
|
||||
expect(playlist.stalkerDeviceId2).toBeUndefined();
|
||||
expect(playlist.stalkerSignature1).toBeUndefined();
|
||||
expect(playlist.stalkerSignature2).toBeUndefined();
|
||||
expect(playlist.serialNumber).toBeUndefined();
|
||||
expect(playlist.deviceId1).toBeUndefined();
|
||||
expect(playlist.deviceId2).toBeUndefined();
|
||||
expect(playlist.signature1).toBeUndefined();
|
||||
expect(playlist.signature2).toBeUndefined();
|
||||
});
|
||||
});
|
||||
+50
-32
@@ -13,8 +13,9 @@ import { Store } from '@ngrx/store';
|
||||
import { TranslatePipe, TranslateService } from '@ngx-translate/core';
|
||||
import { PlaylistActions } from '@iptvnator/m3u-state';
|
||||
import {
|
||||
STALKER_SERIAL_NUMBER,
|
||||
StalkerPortalIdentity,
|
||||
StalkerSessionService,
|
||||
normalizeStalkerPortalIdentity,
|
||||
} from '@iptvnator/portal/stalker/data-access';
|
||||
import { Playlist } from '@iptvnator/shared/interfaces';
|
||||
import { v4 as uuid } from 'uuid';
|
||||
@@ -105,44 +106,30 @@ export class StalkerPortalImportComponent {
|
||||
this.isLoading.set(true);
|
||||
|
||||
try {
|
||||
const originalUrl = this.form.value.portalUrl;
|
||||
const formValue = this.form.getRawValue();
|
||||
const originalUrl = formValue.portalUrl ?? '';
|
||||
const transformedUrl = this.transformPortalUrl(originalUrl);
|
||||
const isFullStalkerPortal =
|
||||
this.isFullStalkerPortalUrl(originalUrl);
|
||||
const stalkerIdentity = normalizeStalkerPortalIdentity({
|
||||
serialNumber: formValue.serialNumber,
|
||||
deviceId1: formValue.deviceId1,
|
||||
deviceId2: formValue.deviceId2,
|
||||
signature1: formValue.signature1,
|
||||
signature2: formValue.signature2,
|
||||
});
|
||||
|
||||
let stalkerToken: string | undefined;
|
||||
let stalkerAccountInfo: Playlist['stalkerAccountInfo'] | undefined;
|
||||
let stalkerSerialNumber: string | undefined;
|
||||
let stalkerDeviceId1: string | undefined;
|
||||
let stalkerDeviceId2: string | undefined;
|
||||
let stalkerSignature1: string | undefined;
|
||||
let stalkerSignature2: string | undefined;
|
||||
|
||||
// For full stalker portal URLs, perform handshake and get profile
|
||||
if (isFullStalkerPortal) {
|
||||
// Use provided serial number or generate a new one
|
||||
stalkerSerialNumber =
|
||||
this.form.value.serialNumber?.trim() ||
|
||||
STALKER_SERIAL_NUMBER;
|
||||
|
||||
// Use provided device IDs if available (64 hex chars each)
|
||||
stalkerDeviceId1 =
|
||||
this.form.value.deviceId1?.trim() || undefined;
|
||||
stalkerDeviceId2 =
|
||||
this.form.value.deviceId2?.trim() || undefined;
|
||||
stalkerSignature1 =
|
||||
this.form.value.signature1?.trim() || undefined;
|
||||
stalkerSignature2 =
|
||||
this.form.value.signature2?.trim() || undefined;
|
||||
|
||||
try {
|
||||
const authResult =
|
||||
await this.stalkerSessionService.authenticate(
|
||||
transformedUrl,
|
||||
this.form.value.macAddress,
|
||||
stalkerSerialNumber,
|
||||
stalkerDeviceId1,
|
||||
stalkerDeviceId2
|
||||
formValue.macAddress ?? '',
|
||||
stalkerIdentity
|
||||
);
|
||||
|
||||
stalkerToken = authResult.token;
|
||||
@@ -183,17 +170,22 @@ export class StalkerPortalImportComponent {
|
||||
}
|
||||
}
|
||||
|
||||
const {
|
||||
serialNumber: _serialNumber,
|
||||
deviceId1: _deviceId1,
|
||||
deviceId2: _deviceId2,
|
||||
signature1: _signature1,
|
||||
signature2: _signature2,
|
||||
...playlistFormValue
|
||||
} = formValue;
|
||||
|
||||
const playlist: Playlist = {
|
||||
...this.form.value,
|
||||
...playlistFormValue,
|
||||
portalUrl: transformedUrl,
|
||||
isFullStalkerPortal,
|
||||
stalkerToken,
|
||||
stalkerAccountInfo,
|
||||
stalkerSerialNumber,
|
||||
stalkerDeviceId1,
|
||||
stalkerDeviceId2,
|
||||
stalkerSignature1,
|
||||
stalkerSignature2,
|
||||
...this.toPlaylistIdentityFields(stalkerIdentity),
|
||||
} as Playlist;
|
||||
|
||||
this.store.dispatch(PlaylistActions.addPlaylist({ playlist }));
|
||||
@@ -211,6 +203,32 @@ export class StalkerPortalImportComponent {
|
||||
return url.includes('/stalker_portal');
|
||||
}
|
||||
|
||||
private toPlaylistIdentityFields(identity: StalkerPortalIdentity): {
|
||||
stalkerSerialNumber?: string;
|
||||
stalkerDeviceId1?: string;
|
||||
stalkerDeviceId2?: string;
|
||||
stalkerSignature1?: string;
|
||||
stalkerSignature2?: string;
|
||||
} {
|
||||
return {
|
||||
...(identity.serialNumber
|
||||
? { stalkerSerialNumber: identity.serialNumber }
|
||||
: {}),
|
||||
...(identity.deviceId1
|
||||
? { stalkerDeviceId1: identity.deviceId1 }
|
||||
: {}),
|
||||
...(identity.deviceId2
|
||||
? { stalkerDeviceId2: identity.deviceId2 }
|
||||
: {}),
|
||||
...(identity.signature1
|
||||
? { stalkerSignature1: identity.signature1 }
|
||||
: {}),
|
||||
...(identity.signature2
|
||||
? { stalkerSignature2: identity.signature2 }
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Transforms the portal URL to the correct API endpoint
|
||||
* - Simple URL (example.com/c) -> example.com/portal.php
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
import {
|
||||
normalizeStalkerPortalIdentity as normalizeSharedStalkerPortalIdentity,
|
||||
type Playlist,
|
||||
type StalkerPortalIdentity,
|
||||
} from '@iptvnator/shared/interfaces';
|
||||
|
||||
export {
|
||||
buildStalkerSerialCfduid,
|
||||
LEGACY_DEFAULT_STALKER_SERIAL,
|
||||
normalizeStalkerIdentityValue,
|
||||
normalizeStalkerPortalIdentity,
|
||||
normalizeStalkerSerialNumber,
|
||||
type StalkerPortalIdentity,
|
||||
} from '@iptvnator/shared/interfaces';
|
||||
|
||||
export function getStalkerPortalIdentityFromPlaylist(
|
||||
playlist: Pick<
|
||||
Playlist,
|
||||
| 'stalkerSerialNumber'
|
||||
| 'stalkerDeviceId1'
|
||||
| 'stalkerDeviceId2'
|
||||
| 'stalkerSignature1'
|
||||
| 'stalkerSignature2'
|
||||
>
|
||||
): StalkerPortalIdentity {
|
||||
return normalizeSharedStalkerPortalIdentity({
|
||||
serialNumber: playlist.stalkerSerialNumber,
|
||||
deviceId1: playlist.stalkerDeviceId1,
|
||||
deviceId2: playlist.stalkerDeviceId2,
|
||||
signature1: playlist.stalkerSignature1,
|
||||
signature2: playlist.stalkerSignature2,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
import { PlaylistMeta } from '@iptvnator/shared/interfaces';
|
||||
import { buildStalkerExternalPlaybackHeaders } from './stalker-live-playback.utils';
|
||||
import { STALKER_SERIAL_NUMBER } from './stalker-session.service';
|
||||
|
||||
function createPlaylist(
|
||||
overrides: Partial<PlaylistMeta> = {}
|
||||
): PlaylistMeta {
|
||||
return {
|
||||
_id: 'playlist-1',
|
||||
title: 'Playlist',
|
||||
filename: '',
|
||||
count: 0,
|
||||
url: '',
|
||||
importDate: '',
|
||||
filePath: '',
|
||||
updateDate: '',
|
||||
updateState: '',
|
||||
position: 0,
|
||||
autoRefresh: false,
|
||||
favorites: [],
|
||||
serverUrl: '',
|
||||
username: '',
|
||||
password: '',
|
||||
macAddress: '00:1A:79:AA:BB:CC',
|
||||
hiddenGroupTitles: [],
|
||||
portalUrl: 'http://portal.test/stalker_portal/c/index.html',
|
||||
recentlyViewed: [],
|
||||
isFullStalkerPortal: true,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe('buildStalkerExternalPlaybackHeaders', () => {
|
||||
it('treats the legacy default serial as absent', () => {
|
||||
const headers = buildStalkerExternalPlaybackHeaders(
|
||||
createPlaylist({ stalkerSerialNumber: STALKER_SERIAL_NUMBER }),
|
||||
'TOKEN',
|
||||
'http://portal.test/stalker_portal/server/load.php'
|
||||
);
|
||||
|
||||
expect(headers).not.toHaveProperty('SN');
|
||||
expect(headers['Cookie']).not.toContain('__cfduid=');
|
||||
});
|
||||
|
||||
it('trims a provided serial before adding same-origin playback headers', () => {
|
||||
const headers = buildStalkerExternalPlaybackHeaders(
|
||||
createPlaylist({ stalkerSerialNumber: ' CustomSn123 ' }),
|
||||
'TOKEN',
|
||||
'http://portal.test/stalker_portal/server/load.php'
|
||||
);
|
||||
|
||||
expect(headers['SN']).toBe('CustomSn123');
|
||||
expect(headers['Cookie']).toContain('__cfduid=');
|
||||
});
|
||||
|
||||
it('uses a canonical 32-character __cfduid when serial is provided', () => {
|
||||
const headers = buildStalkerExternalPlaybackHeaders(
|
||||
createPlaylist({ stalkerSerialNumber: ' CustomSn123 ' }),
|
||||
'TOKEN',
|
||||
'http://portal.test/stalker_portal/server/load.php'
|
||||
);
|
||||
|
||||
const cfduid = headers['Cookie']?.match(/__cfduid=([^;]+)/)?.[1];
|
||||
expect(cfduid).toHaveLength(32);
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,8 @@
|
||||
import { PlaylistMeta } from '@iptvnator/shared/interfaces';
|
||||
import {
|
||||
buildStalkerSerialCfduid,
|
||||
normalizeStalkerSerialNumber,
|
||||
} from './stalker-identity.utils';
|
||||
|
||||
export const STALKER_MAG_USER_AGENT =
|
||||
'Mozilla/5.0 (QtEmbedded; U; Linux; C) AppleWebKit/533.3 (KHTML, like Gecko) MAG250';
|
||||
@@ -59,19 +63,23 @@ export function buildStalkerExternalPlaybackHeaders(
|
||||
'stb_lang=en_US@rg=dezzzz',
|
||||
'timezone=Europe/Berlin',
|
||||
];
|
||||
const serialNumber = normalizeStalkerSerialNumber(
|
||||
playlist.stalkerSerialNumber
|
||||
);
|
||||
|
||||
if (playlist.stalkerSerialNumber) {
|
||||
cookieParts.push(
|
||||
`__cfduid=${playlist.stalkerSerialNumber.toLowerCase()}e030245495acd6ebfc1`
|
||||
);
|
||||
if (serialNumber) {
|
||||
cookieParts.push(`__cfduid=${buildStalkerSerialCfduid(serialNumber)}`);
|
||||
}
|
||||
|
||||
const headers: Record<string, string> = {
|
||||
Cookie: cookieParts.join('; '),
|
||||
'X-User-Agent': STALKER_MAG_USER_AGENT,
|
||||
SN: playlist.stalkerSerialNumber || '',
|
||||
};
|
||||
|
||||
if (serialNumber) {
|
||||
headers['SN'] = serialNumber;
|
||||
}
|
||||
|
||||
if (token) {
|
||||
headers['Authorization'] = `Bearer ${token}`;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,189 @@
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { DataService } from '@iptvnator/services';
|
||||
import { Playlist } from '@iptvnator/shared/interfaces';
|
||||
import {
|
||||
STALKER_SERIAL_NUMBER,
|
||||
StalkerProfileResponse,
|
||||
StalkerSessionService,
|
||||
} from './stalker-session.service';
|
||||
|
||||
type ExpectedStalkerPortalIdentity = {
|
||||
serialNumber?: string;
|
||||
deviceId1?: string;
|
||||
deviceId2?: string;
|
||||
signature1?: string;
|
||||
signature2?: string;
|
||||
};
|
||||
|
||||
type GetProfileWithIdentity = (
|
||||
portalUrl: string,
|
||||
macAddress: string,
|
||||
token: string,
|
||||
identity: ExpectedStalkerPortalIdentity,
|
||||
handshakeRandom: string
|
||||
) => Promise<StalkerProfileResponse>;
|
||||
|
||||
describe('StalkerSessionService identity payloads', () => {
|
||||
const portalUrl = 'https://portal.example.com/stalker_portal/server/load.php';
|
||||
const macAddress = '00:1A:79:AA:BB:CC';
|
||||
|
||||
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: {} }),
|
||||
};
|
||||
|
||||
TestBed.configureTestingModule({
|
||||
providers: [
|
||||
StalkerSessionService,
|
||||
{ provide: DataService, useValue: dataService },
|
||||
],
|
||||
});
|
||||
|
||||
service = TestBed.inject(StalkerSessionService);
|
||||
});
|
||||
|
||||
it('omits SN, device IDs, and signatures from get_profile when identity is blank', async () => {
|
||||
const getProfile =
|
||||
service.getProfile as unknown as GetProfileWithIdentity;
|
||||
|
||||
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',
|
||||
},
|
||||
})
|
||||
.mockResolvedValueOnce({ js: {} });
|
||||
|
||||
await service.authenticate(portalUrl, macAddress, {
|
||||
serialNumber: 'CUSTOMSN123',
|
||||
});
|
||||
|
||||
const handshakePayload = dataService.sendIpcEvent.mock.calls[0][1];
|
||||
expect(handshakePayload.params.action).toBe('handshake');
|
||||
expect(handshakePayload.serialNumber).toBe('CUSTOMSN123');
|
||||
});
|
||||
|
||||
function lastStalkerPayload(): {
|
||||
params: Record<string, unknown>;
|
||||
serialNumber?: string;
|
||||
} {
|
||||
return dataService.sendIpcEvent.mock.calls.at(-1)?.[1];
|
||||
}
|
||||
});
|
||||
@@ -1,6 +1,18 @@
|
||||
import { Injectable, inject } from '@angular/core';
|
||||
import { Playlist, STALKER_REQUEST } from '@iptvnator/shared/interfaces';
|
||||
import { DataService } from '@iptvnator/services';
|
||||
import {
|
||||
getStalkerPortalIdentityFromPlaylist,
|
||||
LEGACY_DEFAULT_STALKER_SERIAL,
|
||||
normalizeStalkerPortalIdentity,
|
||||
type StalkerPortalIdentity,
|
||||
} from './stalker-identity.utils';
|
||||
|
||||
export {
|
||||
getStalkerPortalIdentityFromPlaylist,
|
||||
normalizeStalkerPortalIdentity,
|
||||
};
|
||||
export type { StalkerPortalIdentity };
|
||||
|
||||
/**
|
||||
* SHA1 hash using native Web Crypto API
|
||||
@@ -36,11 +48,7 @@ function generateRandom(): string {
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Deterministic serial number - 13 hex characters
|
||||
* Constant value for consistency across all sessions
|
||||
*/
|
||||
export const STALKER_SERIAL_NUMBER = 'BEDACD4569BAF';
|
||||
export const STALKER_SERIAL_NUMBER = LEGACY_DEFAULT_STALKER_SERIAL;
|
||||
const STALKER_WATCHDOG_INTERVAL_MS = 25_000;
|
||||
|
||||
export interface StalkerHandshakeResponse {
|
||||
@@ -97,25 +105,6 @@ export class StalkerSessionService {
|
||||
private watchdogInFlight = new Set<string>();
|
||||
private activeWatchdogPlaylistId: string | null = null;
|
||||
|
||||
/**
|
||||
* Generates a consistent 64-character device ID from MAC address
|
||||
*/
|
||||
private generateDeviceId(macAddress: string): string {
|
||||
// Generate consistent device ID from MAC
|
||||
const str = macAddress.toUpperCase().replace(/:/g, '');
|
||||
const chars = '0123456789ABCDEF';
|
||||
let result = '';
|
||||
let seed = 0;
|
||||
for (let i = 0; i < str.length; i++) {
|
||||
seed = (seed * 31 + str.charCodeAt(i)) >>> 0;
|
||||
}
|
||||
for (let i = 0; i < 64; i++) {
|
||||
seed = (seed * 1103515245 + 12345) >>> 0;
|
||||
result += chars[seed % 16];
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a URL is a full stalker portal URL (requires handshake)
|
||||
* Full stalker portal URLs contain /stalker_portal/ in the path
|
||||
@@ -252,8 +241,10 @@ export class StalkerSessionService {
|
||||
*/
|
||||
async performHandshake(
|
||||
portalUrl: string,
|
||||
macAddress: string
|
||||
macAddress: string,
|
||||
identity: StalkerPortalIdentity = {}
|
||||
): Promise<{ token: string; random: string }> {
|
||||
const normalizedIdentity = normalizeStalkerPortalIdentity(identity);
|
||||
const prehash = await generatePrehash(macAddress);
|
||||
|
||||
const params: Record<string, string> = {
|
||||
@@ -269,9 +260,12 @@ export class StalkerSessionService {
|
||||
await this.dataService.sendIpcEvent<StalkerHandshakeResponse>(
|
||||
STALKER_REQUEST,
|
||||
{
|
||||
url: portalUrl,
|
||||
macAddress,
|
||||
params,
|
||||
url: portalUrl,
|
||||
macAddress,
|
||||
params,
|
||||
...(normalizedIdentity.serialNumber
|
||||
? { serialNumber: normalizedIdentity.serialNumber }
|
||||
: {}),
|
||||
}
|
||||
);
|
||||
|
||||
@@ -298,29 +292,20 @@ export class StalkerSessionService {
|
||||
portalUrl: string,
|
||||
macAddress: string,
|
||||
token: string,
|
||||
serialNumber: string,
|
||||
handshakeRandom: string,
|
||||
providedDeviceId1?: string,
|
||||
providedDeviceId2?: string,
|
||||
providedSignature1?: string,
|
||||
providedSignature2?: string
|
||||
identity: StalkerPortalIdentity,
|
||||
handshakeRandom: string
|
||||
): Promise<StalkerProfileResponse> {
|
||||
// Use provided device IDs or generate from MAC (64 hex chars each)
|
||||
const deviceId1 =
|
||||
providedDeviceId1?.trim() || this.generateDeviceId(macAddress);
|
||||
const deviceId2 = providedDeviceId2?.trim() || deviceId1;
|
||||
|
||||
// Use provided signatures or empty string (some portals don't require them)
|
||||
const signature1 = providedSignature1?.trim() || '';
|
||||
const signature2 = providedSignature2?.trim() || '';
|
||||
const normalizedIdentity = normalizeStalkerPortalIdentity(identity);
|
||||
|
||||
// Build metrics JSON matching working app
|
||||
const metrics = {
|
||||
const metrics: Record<string, string> = {
|
||||
mac: macAddress,
|
||||
model: 'MAG250',
|
||||
type: 'STB',
|
||||
random: handshakeRandom,
|
||||
sn: serialNumber,
|
||||
...(normalizedIdentity.serialNumber
|
||||
? { sn: normalizedIdentity.serialNumber }
|
||||
: {}),
|
||||
};
|
||||
|
||||
// Generate prehash for get_profile (same as handshake)
|
||||
@@ -337,11 +322,21 @@ export class StalkerSessionService {
|
||||
auth_second_step: '1',
|
||||
num_banks: '2',
|
||||
metrics: JSON.stringify(metrics),
|
||||
sn: serialNumber,
|
||||
device_id: deviceId1,
|
||||
device_id2: deviceId2,
|
||||
signature: signature1,
|
||||
...(signature2 ? { signature2: signature2 } : {}),
|
||||
...(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',
|
||||
@@ -352,11 +347,13 @@ export class StalkerSessionService {
|
||||
await this.dataService.sendIpcEvent<StalkerProfileResponse>(
|
||||
STALKER_REQUEST,
|
||||
{
|
||||
url: portalUrl,
|
||||
macAddress,
|
||||
params,
|
||||
token,
|
||||
serialNumber,
|
||||
url: portalUrl,
|
||||
macAddress,
|
||||
params,
|
||||
token,
|
||||
...(normalizedIdentity.serialNumber
|
||||
? { serialNumber: normalizedIdentity.serialNumber }
|
||||
: {}),
|
||||
}
|
||||
);
|
||||
|
||||
@@ -380,8 +377,6 @@ export class StalkerSessionService {
|
||||
action: 'do_auth',
|
||||
login: '',
|
||||
password: '',
|
||||
device_id: '',
|
||||
device_id2: '',
|
||||
JsHttpRequest: '1-xml',
|
||||
};
|
||||
|
||||
@@ -390,10 +385,10 @@ export class StalkerSessionService {
|
||||
await this.dataService.sendIpcEvent<StalkerAuthConfirmationResponse>(
|
||||
STALKER_REQUEST,
|
||||
{
|
||||
url: portalUrl,
|
||||
macAddress,
|
||||
params,
|
||||
token,
|
||||
url: portalUrl,
|
||||
macAddress,
|
||||
params,
|
||||
token,
|
||||
}
|
||||
);
|
||||
|
||||
@@ -416,19 +411,18 @@ export class StalkerSessionService {
|
||||
async authenticate(
|
||||
portalUrl: string,
|
||||
macAddress: string,
|
||||
serialNumber: string,
|
||||
deviceId1?: string,
|
||||
deviceId2?: string,
|
||||
signature1?: string,
|
||||
signature2?: 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
|
||||
macAddress,
|
||||
normalizedIdentity
|
||||
);
|
||||
|
||||
// Step 2: Get profile to activate token and get account info
|
||||
@@ -438,12 +432,8 @@ export class StalkerSessionService {
|
||||
portalUrl,
|
||||
macAddress,
|
||||
token,
|
||||
serialNumber,
|
||||
random,
|
||||
deviceId1,
|
||||
deviceId2,
|
||||
signature1,
|
||||
signature2
|
||||
normalizedIdentity,
|
||||
random
|
||||
);
|
||||
|
||||
// Check for profile-level errors
|
||||
@@ -480,10 +470,12 @@ export class StalkerSessionService {
|
||||
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 };
|
||||
return { token: cachedToken, serialNumber: identity.serialNumber };
|
||||
}
|
||||
|
||||
// Check if there's already a pending authentication for this playlist
|
||||
@@ -503,12 +495,6 @@ export class StalkerSessionService {
|
||||
throw new Error('Portal URL and MAC address are required');
|
||||
}
|
||||
|
||||
// Get or generate serial number - must be consistent for the MAC
|
||||
let serialNumber = playlist.stalkerSerialNumber;
|
||||
if (!serialNumber) {
|
||||
serialNumber = STALKER_SERIAL_NUMBER;
|
||||
}
|
||||
|
||||
// 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 () => {
|
||||
@@ -516,10 +502,10 @@ export class StalkerSessionService {
|
||||
const { token } = await this.authenticate(
|
||||
playlist.portalUrl,
|
||||
playlist.macAddress,
|
||||
serialNumber
|
||||
identity
|
||||
);
|
||||
this.setCachedToken(playlist._id, token);
|
||||
return { token, serialNumber };
|
||||
return { token, serialNumber: identity.serialNumber };
|
||||
} finally {
|
||||
// Clean up pending promise regardless of success/failure
|
||||
this.pendingAuth.delete(playlist._id);
|
||||
@@ -532,8 +518,6 @@ export class StalkerSessionService {
|
||||
return authPromise;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/**
|
||||
* Checks if a response or error indicates an authorization failure
|
||||
*/
|
||||
@@ -578,8 +562,7 @@ export class StalkerSessionService {
|
||||
retryOnAuthFailure = true
|
||||
): Promise<T> {
|
||||
// Get token (will wait if auth is in progress)
|
||||
const { token } = await this.ensureToken(playlist);
|
||||
const serialNumber = playlist.stalkerSerialNumber;
|
||||
const { token, serialNumber } = await this.ensureToken(playlist);
|
||||
|
||||
try {
|
||||
const response = await this.dataService.sendIpcEvent<T>(
|
||||
@@ -589,7 +572,7 @@ export class StalkerSessionService {
|
||||
macAddress: playlist.macAddress,
|
||||
params,
|
||||
token,
|
||||
serialNumber,
|
||||
...(serialNumber ? { serialNumber } : {}),
|
||||
}
|
||||
);
|
||||
|
||||
|
||||
+1
-1
@@ -62,7 +62,7 @@ export function withStalkerPortal() {
|
||||
toStalkerSessionPlaylist(playlist)
|
||||
);
|
||||
token = result.token ?? undefined;
|
||||
serialNumber = playlist.stalkerSerialNumber;
|
||||
serialNumber = result.serialNumber;
|
||||
} catch (error) {
|
||||
logger.error('Failed to get stalker token', error);
|
||||
}
|
||||
|
||||
@@ -176,7 +176,7 @@ export class StalkerSearchComponent {
|
||||
playlist as Playlist
|
||||
);
|
||||
token = result.token ?? undefined;
|
||||
serialNumber = (playlist as Playlist).stalkerSerialNumber;
|
||||
serialNumber = result.serialNumber;
|
||||
} catch (error) {
|
||||
this.logger.error('Failed to get stalker token', error);
|
||||
}
|
||||
|
||||
@@ -43,6 +43,7 @@ export * from './lib/xtream-vod-stream.interface';
|
||||
|
||||
// Stalker interfaces
|
||||
export * from './lib/stalker-item.normalizer';
|
||||
export * from './lib/stalker-identity.utils';
|
||||
export * from './lib/stalker-portal-item.interface';
|
||||
export * from './lib/stalker-serial-details.interface';
|
||||
export * from './lib/stalker-vod-details.interface';
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
export const LEGACY_DEFAULT_STALKER_SERIAL = 'BEDACD4569BAF';
|
||||
const STALKER_CFDUID_LENGTH = 32;
|
||||
const STALKER_SERIAL_CFDUID_SUFFIX = 'e030245495acd6ebfc1';
|
||||
|
||||
export interface StalkerPortalIdentity {
|
||||
serialNumber?: string;
|
||||
deviceId1?: string;
|
||||
deviceId2?: string;
|
||||
signature1?: string;
|
||||
signature2?: string;
|
||||
}
|
||||
|
||||
export function normalizeStalkerIdentityValue(
|
||||
value: string | undefined | null
|
||||
): string | undefined {
|
||||
const trimmed = value?.trim();
|
||||
return trimmed ? trimmed : undefined;
|
||||
}
|
||||
|
||||
export function normalizeStalkerSerialNumber(
|
||||
serialNumber: string | undefined | null
|
||||
): string | undefined {
|
||||
const trimmed = normalizeStalkerIdentityValue(serialNumber);
|
||||
if (
|
||||
!trimmed ||
|
||||
trimmed.toUpperCase() === LEGACY_DEFAULT_STALKER_SERIAL
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
export function normalizeStalkerPortalIdentity(
|
||||
identity: StalkerPortalIdentity | undefined | null
|
||||
): StalkerPortalIdentity {
|
||||
const serialNumber = normalizeStalkerSerialNumber(identity?.serialNumber);
|
||||
const deviceId1 = normalizeStalkerIdentityValue(identity?.deviceId1);
|
||||
const deviceId2 = normalizeStalkerIdentityValue(identity?.deviceId2);
|
||||
const signature1 = normalizeStalkerIdentityValue(identity?.signature1);
|
||||
const signature2 = normalizeStalkerIdentityValue(identity?.signature2);
|
||||
|
||||
return {
|
||||
...(serialNumber ? { serialNumber } : {}),
|
||||
...(deviceId1 ? { deviceId1 } : {}),
|
||||
...(deviceId2 ? { deviceId2 } : {}),
|
||||
...(signature1 ? { signature1 } : {}),
|
||||
...(signature2 ? { signature2 } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
export function buildStalkerSerialCfduid(serialNumber: string): string {
|
||||
const serialPrefix = serialNumber.toLowerCase().replace(/[^a-f0-9]/g, '');
|
||||
|
||||
return `${serialPrefix}${STALKER_SERIAL_CFDUID_SUFFIX}`
|
||||
.slice(0, STALKER_CFDUID_LENGTH)
|
||||
.padEnd(STALKER_CFDUID_LENGTH, '0');
|
||||
}
|
||||
Reference in new issue
Block a user