feat: add support for additional HTTP headers in player and Stalker requests

This commit is contained in:
4gray committed 2026-03-05 23:33:07 +01:00
1 parent 2fdb8f409f
commit e76466e250
9 files changed
+354 -98

No files matched your search

@@ -88,7 +88,8 @@ contextBridge.exposeInMainWorld('electron', {
referer?: string,
origin?: string,
contentInfo?: any,
startTime?: number
startTime?: number,
headers?: Record<string, string>
) =>
ipcRenderer.invoke(
'OPEN_MPV_PLAYER',
@@ -98,7 +99,8 @@ contextBridge.exposeInMainWorld('electron', {
referer,
origin,
contentInfo,
startTime
startTime,
headers
),
openInVlc: (
url: string,
@@ -107,7 +109,8 @@ contextBridge.exposeInMainWorld('electron', {
referer?: string,
origin?: string,
contentInfo?: any,
startTime?: number
startTime?: number,
headers?: Record<string, string>
) =>
ipcRenderer.invoke(
'OPEN_VLC_PLAYER',
@@ -117,7 +120,8 @@ contextBridge.exposeInMainWorld('electron', {
referer,
origin,
contentInfo,
startTime
startTime,
headers
),
autoUpdatePlaylists: (playlists) =>
ipcRenderer.invoke('AUTO_UPDATE', playlists),
@@ -146,6 +150,7 @@ contextBridge.exposeInMainWorld('electron', {
macAddress: string;
params: Record<string, string>;
token?: string;
serialNumber?: string;
}) => ipcRenderer.invoke('STALKER_REQUEST', payload),
xtreamRequest: (payload: { url: string; params: Record<string, string> }) =>
ipcRenderer.invoke('XTREAM_REQUEST', payload),
@@ -17,6 +17,7 @@ import { ChildProcess, spawn } from 'child_process';
import { existsSync } from 'fs';
import { AddressInfo, createConnection, createServer } from 'net';
import path from 'path';
import { getStalkerPlaybackContextHeaders } from '../services/stalker-playback-context.service';
export default class PlayerEvents {
static bootstrapPlayerEvents(): Electron.IpcMain {
@@ -154,6 +155,41 @@ function shouldIgnoreMpvStdoutLine(line: string): boolean {
return /^(\(Paused\)\s*)?AV:\s/.test(line);
}
function buildHttpHeaderFields(
origin?: string,
headers?: Record<string, string>
): string[] {
const fields: string[] = [];
const normalizedHeaders = headers ?? {};
if (
origin &&
normalizedHeaders['Origin'] === undefined &&
normalizedHeaders['origin'] === undefined
) {
fields.push(`Origin: ${origin}`);
}
Object.entries(normalizedHeaders).forEach(([name, value]) => {
if (!name || value === undefined || value === null) return;
const trimmedValue = String(value).trim();
if (!trimmedValue) return;
fields.push(`${name}: ${trimmedValue}`);
});
return fields;
}
function isStalkerDirectStreamProfile(
headers: Record<string, string>
): boolean {
const icyMetaData = headers['Icy-MetaData'] ?? headers['icy-metadata'];
const userAgent = headers['User-Agent'] ?? headers['user-agent'];
return String(icyMetaData).trim() === '1' &&
String(userAgent).trim().toLowerCase() === 'ksplayer';
}
function startPositionPolling(socketPath: string, contentInfo: any) {
stopPositionPolling();
@@ -312,19 +348,47 @@ ipcMain.handle(
referer?: string,
origin?: string,
contentInfo?: any,
startTime?: number
startTime?: number,
headers?: Record<string, string>
) => {
try {
const mpvPath = getMpvPath();
const reuseInstance = store.get(MPV_REUSE_INSTANCE, false);
const fallbackHeaders = getStalkerPlaybackContextHeaders(url) ?? {};
const mergedHeaders = isStalkerDirectStreamProfile(fallbackHeaders)
? fallbackHeaders
: {
...fallbackHeaders,
...(headers ?? {}),
};
const effectiveOrigin =
origin ??
mergedHeaders['Origin'] ??
mergedHeaders['origin'] ??
undefined;
const effectiveReferer =
referer ??
mergedHeaders['Referer'] ??
mergedHeaders['referer'] ??
undefined;
const effectiveUserAgent =
userAgent ??
mergedHeaders['User-Agent'] ??
mergedHeaders['user-agent'] ??
undefined;
const headerFields = buildHttpHeaderFields(
effectiveOrigin,
mergedHeaders
);
console.log('[MPV] Opening player', {
path: mpvPath,
reuseInstance,
stream: maskUrlForLogs(url),
hasUserAgent: Boolean(userAgent),
hasReferer: Boolean(referer),
hasOrigin: Boolean(origin),
hasUserAgent: Boolean(effectiveUserAgent),
hasReferer: Boolean(effectiveReferer),
hasOrigin: Boolean(effectiveOrigin),
headerCount: headerFields.length,
hasContentInfo: Boolean(contentInfo),
startTime: startTime ?? null,
});
@@ -338,11 +402,38 @@ ipcMain.handle(
) {
console.log('Reusing existing MPV instance');
try {
const loadFileArgs: Array<string | number> = [url, 'replace'];
if (effectiveUserAgent) {
await sendMpvCommand('set_property', [
'user-agent',
effectiveUserAgent,
]);
}
if (effectiveReferer) {
await sendMpvCommand('set_property', [
'referrer',
effectiveReferer,
]);
}
if (headerFields.length > 0) {
await sendMpvCommand('set_property', [
'http-header-fields',
headerFields.join(','),
]);
}
const loadFileArgs: Array<string | number> = [
url,
'replace',
];
const loadFileOptions: string[] = [];
// loadfile args are: url, flags, index, options
// Index must be numeric; options are a comma-separated key=value list.
if (title) {
// loadfile args are: url, flags, index, options
// Index must be numeric; use -1, then pass options as 4th arg.
loadFileArgs.push(-1, `force-media-title=${title}`);
loadFileOptions.push(`force-media-title=${title}`);
}
if (loadFileOptions.length > 0) {
loadFileArgs.push(-1, loadFileOptions.join(','));
}
await sendMpvCommand('loadfile', loadFileArgs);
@@ -388,22 +479,26 @@ ipcMain.handle(
? `\\\\.\\pipe\\mpv-${Date.now()}`
: `/tmp/mpvsocket-${Date.now()}`;
const args = [`--input-ipc-server=${socketPath}`, '--idle=yes'];
const args = [
`--input-ipc-server=${socketPath}`,
'--idle=yes',
// IPTV URLs often look like generic web pages to ytdl_hook and can fail with 403.
// Force MPV to open them directly instead of probing via yt-dlp/youtube-dl.
'--ytdl=no',
];
// Add user agent if provided
if (userAgent) {
args.push(`--user-agent=${userAgent}`);
if (effectiveUserAgent) {
args.push(`--user-agent=${effectiveUserAgent}`);
}
// Add referer if provided
if (referer) {
args.push(`--referrer=${referer}`);
if (effectiveReferer) {
args.push(`--referrer=${effectiveReferer}`);
}
// Add origin as custom HTTP header if provided
// MPV doesn't have a direct --origin flag, so we use --http-header-fields
if (origin) {
args.push(`--http-header-fields=Origin: ${origin}`);
if (headerFields.length > 0) {
args.push(`--http-header-fields=${headerFields.join(',')}`);
}
// Add title if provided
@@ -587,10 +682,33 @@ ipcMain.handle(
referer?: string,
origin?: string,
contentInfo?: any,
startTime?: number
startTime?: number,
headers?: Record<string, string>
) => {
try {
const vlcPath = getVlcPath();
const fallbackHeaders = getStalkerPlaybackContextHeaders(url) ?? {};
const mergedHeaders = isStalkerDirectStreamProfile(fallbackHeaders)
? fallbackHeaders
: {
...fallbackHeaders,
...(headers ?? {}),
};
const effectiveOrigin =
origin ??
mergedHeaders['Origin'] ??
mergedHeaders['origin'] ??
undefined;
const effectiveReferer =
referer ??
mergedHeaders['Referer'] ??
mergedHeaders['referer'] ??
undefined;
const effectiveUserAgent =
userAgent ??
mergedHeaders['User-Agent'] ??
mergedHeaders['user-agent'] ??
undefined;
console.log('Opening VLC player with path:', vlcPath);
console.log('URL:', url);
// console.log('User-Agent:', userAgent);
@@ -618,17 +736,29 @@ ipcMain.handle(
}
// Add user agent if provided (VLC uses :http-user-agent= format)
if (userAgent) {
args.push(`:http-user-agent=${userAgent}`);
if (effectiveUserAgent) {
args.push(`:http-user-agent=${effectiveUserAgent}`);
}
// Add referer if provided (VLC uses :http-referrer= format)
if (referer) {
args.push(`:http-referrer=${referer}`);
if (effectiveReferer) {
args.push(`:http-referrer=${effectiveReferer}`);
}
// Note: VLC doesn't have a direct origin option, but origin is typically
// included in referer for most IPTV use cases
if (effectiveOrigin && !effectiveReferer) {
args.push(`:http-referrer=${effectiveOrigin}`);
}
if (Object.keys(mergedHeaders).length > 0) {
Object.entries(mergedHeaders).forEach(([name, value]) => {
if (!name || value === undefined || value === null) return;
const trimmedValue = String(value).trim();
if (!trimmedValue) return;
args.push(`:http-header=${name}: ${trimmedValue}`);
});
}
// Add start time if provided
if (startTime) {
@@ -4,8 +4,39 @@
*/
import axios, { AxiosRequestConfig } from 'axios';
import { createHash } from 'crypto';
import { ipcMain } from 'electron';
import { STALKER_REQUEST } from 'shared-interfaces';
import { rememberStalkerPlaybackContext } from '../services/stalker-playback-context.service';
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 };
}
export default class StalkerEvents {
static bootstrapStalkerEvents(): Electron.IpcMain {
@@ -30,6 +61,29 @@ ipcMain.handle(
) => {
try {
const { url, macAddress, params, token, serialNumber } = 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.
}
}
}
// Build URL with query parameters
// Note: For 'cmd' parameter, we need to use encodeURI (not encodeURIComponent)
@@ -37,7 +91,7 @@ ipcMain.handle(
const urlObject = new URL(url);
const queryParts: string[] = [];
Object.entries(params).forEach(([key, value]) => {
Object.entries(requestParams).forEach(([key, value]) => {
if (key === 'cmd') {
// Don't encode cmd - it's already a path like /media/12345.mpg
// Encoding would break the path format expected by the server
@@ -51,7 +105,7 @@ ipcMain.handle(
});
// Always add JsHttpRequest parameter if not present (required by Stalker API)
if (!params['JsHttpRequest']) {
if (!requestParams['JsHttpRequest']) {
queryParts.push('JsHttpRequest=1-xml');
}
@@ -61,13 +115,7 @@ ipcMain.handle(
// 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
let cookieString = `mac=${macAddress}; stb_lang=de_DE; timezone=Europe/Berlin`;
if (serialNumber) {
// Generate __cfduid from serial number (lowercase) + random suffix
const cfduidBase = serialNumber.toLowerCase();
const cfduidSuffix = 'e030245495acd6ebfc1'; // Static suffix matching working app
cookieString += `; __cfduid=${cfduidBase}${cfduidSuffix}`;
}
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> = {
@@ -83,8 +131,8 @@ ipcMain.handle(
};
// Add SN (serial number) header if provided - required by some portals
if (serialNumber) {
headers['SN'] = serialNumber;
if (effectiveSerialNumber) {
headers['SN'] = effectiveSerialNumber;
}
// Add Authorization header if token is provided
@@ -94,7 +142,7 @@ ipcMain.handle(
// Determine timeout based on action type
// create_link requests can take longer as server generates stream URL
const isCreateLink = params.action === 'create_link';
const isCreateLink = requestParams.action === 'create_link';
const requestTimeout = isCreateLink ? 30000 : 15000;
// Configure axios request
@@ -122,6 +170,20 @@ ipcMain.handle(
}
// Return the response data
if (
params.action === 'create_link' &&
response.data?.js?.cmd &&
typeof response.data.js.cmd === 'string'
) {
rememberStalkerPlaybackContext({
streamUrl: response.data.js.cmd,
portalUrl: url,
macAddress,
serialNumber: effectiveSerialNumber,
token,
});
}
return response.data;
} catch (error) {
console.error('[StalkerEvents] Request error:', error);
@@ -99,7 +99,8 @@ export class ElectronService extends DataService {
data.referer ?? undefined,
data.origin ?? undefined,
data.contentInfo,
data.startTime
data.startTime,
data.headers ?? undefined
);
/* thumbnail: data.thumbnail ?? '', */
} catch (error: any) {
@@ -124,7 +125,8 @@ export class ElectronService extends DataService {
data.referer ?? undefined,
data.origin ?? undefined,
data.contentInfo,
data.startTime
data.startTime,
data.headers ?? undefined
);
} catch (error: any) {
const errorMessage = error?.message || String(error);
+4 -1
View File
@@ -31,7 +31,8 @@ export class PlayerService {
referer?: string,
origin?: string,
contentInfo?: any,
startTime?: number
startTime?: number,
headers?: Record<string, string>
) {
const player = this.settingsStore.player() ?? VideoPlayer.VideoJs;
@@ -46,6 +47,7 @@ export class PlayerService {
'user-agent': userAgent,
referer: referer,
origin: origin,
headers,
contentInfo,
startTime,
});
@@ -60,6 +62,7 @@ export class PlayerService {
'user-agent': userAgent,
referer: referer,
origin: origin,
headers,
contentInfo,
startTime,
});
@@ -3,14 +3,18 @@ import { MatSnackBar } from '@angular/material/snack-bar';
import { signalStoreFeature, withMethods } from '@ngrx/signals';
import { TranslateService } from '@ngx-translate/core';
import { DataService, PlaylistsService, StalkerSessionService } from 'services';
import { Playlist, STALKER_REQUEST, StalkerPortalActions } from 'shared-interfaces';
import {
Playlist,
STALKER_REQUEST,
StalkerPortalActions,
} from 'shared-interfaces';
import { PlayerService } from '../../../services/player.service';
import { createLogger } from '../../../shared/utils/logger';
import { StalkerContentTypes } from '../../stalker-content-types';
import {
normalizeStalkerEntityId,
normalizeStalkerEntityIdAsNumber,
} from '../../stalker-vod.utils';
import { createLogger } from '../../../shared/utils/logger';
type StalkerContentType = 'itv' | 'vod' | 'series';
@@ -61,6 +65,30 @@ export function withStalkerPlayer() {
cmd: string,
series?: number
) => {
const normalizeCmdValue = (value: string): string => {
const trimmed = String(value ?? '').trim();
if (!trimmed) return '';
// Some portals prepend transport wrappers like:
// "ffmpeg http://...", "ffrt http://...", etc.
const splitAt = trimmed.indexOf(' ');
if (splitAt > 0) {
const candidate = trimmed
.slice(splitAt + 1)
.trim();
if (
candidate.startsWith('http://') ||
candidate.startsWith('https://') ||
candidate.startsWith('/') ||
candidate.startsWith('?')
) {
return candidate;
}
}
return trimmed;
};
const selectedContentType =
storeState.selectedContentType();
const type = series ? 'vod' : selectedContentType;
@@ -69,7 +97,8 @@ export function withStalkerPlayer() {
// The server adds the required token for playback authorization
// Note: cmd is already transformed during item processing (has_files items)
const params = {
action: StalkerContentTypes[selectedContentType].getLink,
action: StalkerContentTypes[selectedContentType]
.getLink,
cmd: cmd,
type,
disable_ad: '0',
@@ -83,10 +112,11 @@ export function withStalkerPlayer() {
let response: StalkerResponse;
if (playlist?.isFullStalkerPortal) {
// Full stalker portal - use authenticated request with retry
response = await stalkerSession.makeAuthenticatedRequest(
playlist,
params
);
response =
await stalkerSession.makeAuthenticatedRequest(
playlist,
params
);
} else {
// Simple stalker portal - no auth needed
response = await dataService.sendIpcEvent(
@@ -106,16 +136,13 @@ export function withStalkerPlayer() {
throw new Error(errorMsg);
}
let url = response.js.cmd as string;
let url = normalizeCmdValue(response.js.cmd as string);
// If cmd is empty, the content is not available
if (!url) {
throw new Error('nothing_to_play');
}
if (url.startsWith('ffmpeg')) {
url = url.split(' ')[1];
}
// Handle incomplete URLs - some portals return just query params or relative paths
if (
url &&
@@ -136,7 +163,8 @@ export function withStalkerPlayer() {
pathParts[i] === 'portal'
) {
basePath =
'/' + pathParts.slice(1, i + 1).join('/');
'/' +
pathParts.slice(1, i + 1).join('/');
break;
}
}
@@ -144,9 +172,17 @@ export function withStalkerPlayer() {
// If url starts with ?, it's just query params
// Combine with the original cmd path to form the complete streaming URL
if (url.startsWith('?')) {
const normalizedCmd = normalizeCmdValue(cmd);
// The streaming URL is: portal origin + base path + original cmd path + token query
// e.g., http://portal.com + /stalker_portal + /media/12345.mpg + ?token=xxx
url = `${portalUrlObj.origin}${basePath}${cmd}${url}`;
if (
normalizedCmd.startsWith('http://') ||
normalizedCmd.startsWith('https://')
) {
url = `${normalizedCmd}${url}`;
} else {
url = `${portalUrlObj.origin}${basePath}${normalizedCmd}${url}`;
}
} else if (url.startsWith('/')) {
// Relative path - prepend origin and base path
url = `${portalUrlObj.origin}${basePath}${url}`;
@@ -173,10 +209,11 @@ export function withStalkerPlayer() {
let response: StalkerResponse;
if (playlist.isFullStalkerPortal) {
response = await stalkerSession.makeAuthenticatedRequest(
playlist,
queryParams
);
response =
await stalkerSession.makeAuthenticatedRequest(
playlist,
queryParams
);
} else {
response = await dataService.sendIpcEvent(
STALKER_REQUEST,
@@ -205,8 +242,10 @@ export function withStalkerPlayer() {
) => {
const playlistId = storeState.currentPlaylist()?._id;
if (!playlistId) return;
const recentlyViewedItem: { id: string; title: string } &
Record<string, unknown> = {
const recentlyViewedItem: {
id: string;
title: string;
} & Record<string, unknown> = {
...item,
id: normalizeStalkerEntityId(item?.id),
cmd,
@@ -64,42 +64,51 @@ export function withStalkerPortal() {
},
})
),
withMethods((store, dataService = inject(DataService)) => ({
async setCurrentPlaylist(playlist: PlaylistMeta | undefined) {
patchState(store, { currentPlaylist: playlist });
withMethods(
(
store,
dataService = inject(DataService),
stalkerSession = inject(StalkerSessionService)
) => ({
async setCurrentPlaylist(playlist: PlaylistMeta | undefined) {
stalkerSession.setActiveWatchdogPlaylist(
(playlist as Playlist) || undefined
);
patchState(store, { currentPlaylist: playlist });
// Ensure Stalker playlist exists in SQLite for playback positions
// Only sync if this is actually a Stalker playlist (has macAddress and portalUrl)
if (
playlist &&
dataService.isElectron &&
playlist._id &&
playlist.macAddress &&
playlist.portalUrl
) {
try {
const playlistId = String(playlist._id);
// Check if playlist exists in SQLite
const existing =
await window.electron.dbGetPlaylist(playlistId);
if (!existing) {
// Create playlist in SQLite
await window.electron.dbCreatePlaylist({
id: playlistId,
name: playlist.title || '',
macAddress: playlist.macAddress || '',
url: playlist.portalUrl || '',
type: 'stalker',
});
// Ensure Stalker playlist exists in SQLite for playback positions
// Only sync if this is actually a Stalker playlist (has macAddress and portalUrl)
if (
playlist &&
dataService.isElectron &&
playlist._id &&
playlist.macAddress &&
playlist.portalUrl
) {
try {
const playlistId = String(playlist._id);
// Check if playlist exists in SQLite
const existing =
await window.electron.dbGetPlaylist(playlistId);
if (!existing) {
// Create playlist in SQLite
await window.electron.dbCreatePlaylist({
id: playlistId,
name: playlist.title || '',
macAddress: playlist.macAddress || '',
url: playlist.portalUrl || '',
type: 'stalker',
});
}
} catch (error) {
logger.error(
'Error syncing Stalker playlist to SQLite',
error
);
}
} catch (error) {
logger.error(
'Error syncing Stalker playlist to SQLite',
error
);
}
}
},
}))
},
})
)
);
}
+5 -2
View File
@@ -34,7 +34,8 @@ declare global {
referer?: string,
origin?: string,
contentInfo?: any,
startTime?: number
startTime?: number,
headers?: Record<string, string>
) => void;
openInVlc: (
url: string,
@@ -43,7 +44,8 @@ declare global {
referer?: string,
origin?: string,
contentInfo?: any,
startTime?: number
startTime?: number,
headers?: Record<string, string>
) => void;
autoUpdatePlaylists: (playlists: any[]) => Promise<any[]>;
fetchEpg: (
@@ -80,6 +82,7 @@ declare global {
macAddress: string;
params: Record<string, string>;
token?: string;
serialNumber?: string;
}) => Promise<any>;
xtreamRequest: (payload: {
url: string;
Vendored
+5 -2
View File
@@ -37,7 +37,8 @@ declare global {
referer?: string,
origin?: string,
contentInfo?: any,
startTime?: number
startTime?: number,
headers?: Record<string, string>
) => void;
openInVlc: (
url: string,
@@ -46,7 +47,8 @@ declare global {
referer?: string,
origin?: string,
contentInfo?: any,
startTime?: number
startTime?: number,
headers?: Record<string, string>
) => void;
autoUpdatePlaylists: (playlists: Playlist[]) => Promise<Playlist[]>;
fetchEpg: (
@@ -83,6 +85,7 @@ declare global {
macAddress: string;
params: Record<string, string>;
token?: string;
serialNumber?: string;
}) => Promise<any>;
xtreamRequest: (payload: {
url: string;