Merge pull request #709 from 4gray/feat/improve-stalker-support

feat/improve-stalker-support
This commit is contained in:
4gray authored and GitHub committed 2026-01-05 21:51:10 +01:00
commit ad9bf45518
21 files changed
+2292 -277

No files matched your search

@@ -94,10 +94,12 @@ contextBridge.exposeInMainWorld('electron', {
ipcRenderer.invoke('SET_VLC_PLAYER_PATH', vlcPlayerPath),
updateSettings: (settings: any) =>
ipcRenderer.invoke('SETTINGS_UPDATE', settings),
getAiSettings: () => ipcRenderer.invoke('GET_AI_SETTINGS'),
stalkerRequest: (payload: {
url: string;
macAddress: string;
params: Record<string, string>;
token?: string;
}) => ipcRenderer.invoke('STALKER_REQUEST', payload),
xtreamRequest: (payload: { url: string; params: Record<string, string> }) =>
ipcRenderer.invoke('XTREAM_REQUEST', payload),
@@ -14,7 +14,7 @@ export default class StalkerEvents {
}
/**
* Handle Stalker API requests with MAC address cookie
* Handle Stalker API requests with MAC address cookie and optional Bearer token
*/
ipcMain.handle(
STALKER_REQUEST,
@@ -24,35 +24,122 @@ ipcMain.handle(
url: string;
macAddress: string;
params: Record<string, string>;
token?: string;
serialNumber?: string;
}
) => {
try {
const { url, macAddress, params } = payload;
const { url, macAddress, params, token, serialNumber } = payload;
console.log('[StalkerEvents] Incoming request');
console.log('[StalkerEvents] Base URL:', url);
console.log('[StalkerEvents] MAC Address:', macAddress);
console.log(
'[StalkerEvents] Params:',
JSON.stringify(params, null, 2)
);
console.log(
'[StalkerEvents] Token:',
token ? token.substring(0, 10) + '...' : 'none'
);
// Build URL with query parameters
// Note: For 'cmd' parameter, we need to use encodeURI (not encodeURIComponent)
// to preserve forward slashes, matching stalker-to-m3u implementation
const urlObject = new URL(url);
const queryParts: string[] = [];
Object.entries(params).forEach(([key, value]) => {
urlObject.searchParams.append(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
queryParts.push(`${key}=${String(value)}`);
} else {
// Use encodeURIComponent for other params
queryParts.push(
`${key}=${encodeURIComponent(String(value))}`
);
}
});
// Configure axios request with MAC address cookie
// Always add JsHttpRequest parameter if not present (required by Stalker API)
if (!params['JsHttpRequest']) {
queryParts.push('JsHttpRequest=1-xml');
}
// Build final URL with manually constructed query string
const fullUrl = `${urlObject.origin}${urlObject.pathname}?${queryParts.join('&')}`;
console.log('[StalkerEvents] Full request URL:', fullUrl);
// 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}`;
}
// 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 (serialNumber) {
headers['SN'] = serialNumber;
}
// Add Authorization header if token is provided
if (token) {
headers['Authorization'] = `Bearer ${token}`;
console.log('[StalkerEvents] Authorization header added');
}
console.log(
'[StalkerEvents] Request headers:',
JSON.stringify(headers, null, 2)
);
// Determine timeout based on action type
// create_link requests can take longer as server generates stream URL
const isCreateLink = params.action === 'create_link';
const requestTimeout = isCreateLink ? 30000 : 15000;
// Configure axios request
const config: AxiosRequestConfig = {
method: 'GET',
url: urlObject.toString(),
headers: {
Cookie: `mac=${macAddress}`,
'User-Agent':
'Mozilla/5.0 (QtEmbedded; U; Linux; C) AppleWebKit/533.3 (KHTML, like Gecko) MAG200 stbapp ver: 2 rev: 250 Safari/533.3',
'X-User-Agent': 'Model: MAG250; Link: WiFi',
},
timeout: 15000, // 15 seconds timeout
url: fullUrl,
headers,
timeout: requestTimeout,
validateStatus: (status) => status < 500, // Don't throw on 4xx errors
};
console.log('[StalkerEvents] Sending request...');
const response = await axios(config);
console.log('[StalkerEvents] Response status:', response.status);
console.log(
'[StalkerEvents] Response data:',
JSON.stringify(response.data, null, 2).substring(0, 500) + '...'
);
// Check if response is successful
if (response.status >= 400) {
console.error(
'[StalkerEvents] HTTP Error:',
response.status,
response.statusText
);
throw {
message: `HTTP Error: ${response.statusText}`,
status: response.status,
@@ -62,7 +149,7 @@ ipcMain.handle(
// Return the response data
return response.data;
} catch (error) {
console.error('Stalker request error:', error);
console.error('[StalkerEvents] Request error:', error);
// Format error response
if (axios.isAxiosError(error)) {
@@ -74,6 +161,10 @@ ipcMain.handle(
'Failed to fetch data from Stalker portal',
status: error.response?.status || 500,
};
console.error(
'[StalkerEvents] Axios error response:',
errorResponse
);
throw errorResponse;
} else if (
error &&
@@ -31,6 +31,71 @@
formControlName="macAddress"
/>
</mat-form-field>
<mat-form-field class="full-width">
<mat-label for="serialNumber">{{
'HOME.STALKER_PORTAL.SERIAL_NUMBER' | translate
}}</mat-label>
<input
matInput
type="text"
id="serialNumber"
formControlName="serialNumber"
placeholder="Optional - auto-generated if empty"
/>
<mat-hint>{{ 'HOME.STALKER_PORTAL.SERIAL_NUMBER_HINT' | translate }}</mat-hint>
</mat-form-field>
<mat-form-field class="full-width">
<mat-label for="deviceId1">{{
'HOME.STALKER_PORTAL.DEVICE_ID_1' | translate
}}</mat-label>
<input
matInput
type="text"
id="deviceId1"
formControlName="deviceId1"
placeholder="Optional - auto-generated if empty"
/>
<mat-hint>{{ 'HOME.STALKER_PORTAL.DEVICE_ID_HINT' | translate }}</mat-hint>
</mat-form-field>
<mat-form-field class="full-width">
<mat-label for="deviceId2">{{
'HOME.STALKER_PORTAL.DEVICE_ID_2' | translate
}}</mat-label>
<input
matInput
type="text"
id="deviceId2"
formControlName="deviceId2"
placeholder="Optional - auto-generated if empty"
/>
<mat-hint>{{ 'HOME.STALKER_PORTAL.DEVICE_ID_HINT' | translate }}</mat-hint>
</mat-form-field>
<mat-form-field class="full-width">
<mat-label for="signature1">{{
'HOME.STALKER_PORTAL.SIGNATURE_1' | translate
}}</mat-label>
<input
matInput
type="text"
id="signature1"
formControlName="signature1"
placeholder="Optional"
/>
<mat-hint>{{ 'HOME.STALKER_PORTAL.SIGNATURE_HINT' | translate }}</mat-hint>
</mat-form-field>
<mat-form-field class="full-width">
<mat-label for="signature2">{{
'HOME.STALKER_PORTAL.SIGNATURE_2' | translate
}}</mat-label>
<input
matInput
type="text"
id="signature2"
formControlName="signature2"
placeholder="Optional"
/>
<mat-hint>{{ 'HOME.STALKER_PORTAL.SIGNATURE_HINT' | translate }}</mat-hint>
</mat-form-field>
<!-- <mat-form-field class="full-width">
<mat-label for="username">{{
'HOME.XTREAM_PLAYLIST.USERNAME' | translate
@@ -43,12 +108,19 @@
}}</mat-label>
<input matInput type="text" id="password" formControlName="password" />
</mat-form-field> -->
<button
mat-button
type="submit"
[disabled]="!form.valid"
(click)="addPlaylist()"
>
{{ 'HOME.STALKER_PORTAL.ADD' | translate }}
</button>
@if (isLoading()) {
<div class="loading-container">
<mat-spinner diameter="20"></mat-spinner>
<span>{{ 'HOME.STALKER_PORTAL.VALIDATING' | translate }}</span>
</div>
} @else {
<button
mat-button
type="submit"
[disabled]="!form.valid"
(click)="addPlaylist()"
>
{{ 'HOME.STALKER_PORTAL.ADD' | translate }}
</button>
}
</form>
@@ -1,4 +1,4 @@
import { Component, EventEmitter, Output, inject } from '@angular/core';
import { Component, inject, output, signal } from '@angular/core';
import {
FormControl,
FormGroup,
@@ -9,10 +9,12 @@ import {
import { MatButtonModule } from '@angular/material/button';
import { MatFormFieldModule } from '@angular/material/form-field';
import { MatInputModule } from '@angular/material/input';
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
import { MatSnackBar } from '@angular/material/snack-bar';
import { Store } from '@ngrx/store';
import { TranslatePipe } from '@ngx-translate/core';
import { TranslatePipe, TranslateService } from '@ngx-translate/core';
import { PlaylistActions } from 'm3u-state';
import { DataService } from 'services';
import { STALKER_SERIAL_NUMBER, StalkerSessionService } from 'services';
import { Playlist } from 'shared-interfaces';
import { v4 as uuid } from 'uuid';
@@ -22,6 +24,7 @@ import { v4 as uuid } from 'uuid';
MatButtonModule,
MatFormFieldModule,
MatInputModule,
MatProgressSpinnerModule,
ReactiveFormsModule,
TranslatePipe,
],
@@ -38,17 +41,28 @@ import { v4 as uuid } from 'uuid';
form {
width: 100%;
}
.loading-container {
display: flex;
align-items: center;
gap: 8px;
}
`,
],
})
export class StalkerPortalImportComponent {
@Output() addClicked = new EventEmitter<void>();
URL_REGEX = /^(http|https|file):\/\/[^ "]+$/;
readonly addClicked = output<void>();
readonly URL_REGEX = /^(http|https|file):\/\/[^ "]+$/;
form = new FormGroup({
readonly form = new FormGroup({
_id: new FormControl(uuid()),
title: new FormControl('', [Validators.required]),
macAddress: new FormControl('', [Validators.required]),
serialNumber: new FormControl(''),
deviceId1: new FormControl(''),
deviceId2: new FormControl(''),
signature1: new FormControl(''),
signature2: new FormControl(''),
password: new FormControl(''),
username: new FormControl(''),
portalUrl: new FormControl('', [
@@ -59,38 +73,238 @@ export class StalkerPortalImportComponent {
userAgent: new FormControl(''),
});
dataService = inject(DataService);
store = inject(Store);
private readonly stalkerSessionService = inject(StalkerSessionService);
private readonly store = inject(Store);
private readonly snackBar = inject(MatSnackBar);
readonly translate = inject(TranslateService);
addPlaylist() {
this.form.value.portalUrl = this.transformPortalUrl(
this.form.value.portalUrl
);
this.store.dispatch(
PlaylistActions.addPlaylist({ playlist: this.form.value as Playlist })
);
this.addClicked.emit();
readonly isLoading = signal(false);
async addPlaylist() {
if (!this.form.valid || this.isLoading()) {
return;
}
this.isLoading.set(true);
try {
const originalUrl = this.form.value.portalUrl;
const transformedUrl = this.transformPortalUrl(originalUrl);
const isFullStalkerPortal =
this.isFullStalkerPortalUrl(originalUrl);
console.log('[StalkerImport] Starting import process');
console.log('[StalkerImport] Original URL:', originalUrl);
console.log('[StalkerImport] Transformed URL:', transformedUrl);
console.log(
'[StalkerImport] Is Full Stalker Portal:',
isFullStalkerPortal
);
console.log(
'[StalkerImport] MAC Address:',
this.form.value.macAddress
);
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) {
console.log(
'[StalkerImport] Full stalker portal detected, starting authentication...'
);
// Use provided serial number or generate a new one
stalkerSerialNumber =
this.form.value.serialNumber?.trim() ||
STALKER_SERIAL_NUMBER;
console.log(
'[StalkerImport] Using serial number:',
stalkerSerialNumber,
this.form.value.serialNumber?.trim()
? '(user provided)'
: '(auto-generated)'
);
// 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;
console.log(
'[StalkerImport] Device ID 1:',
stalkerDeviceId1
? stalkerDeviceId1.substring(0, 16) +
'... (user provided)'
: '(auto-generated)'
);
console.log(
'[StalkerImport] Device ID 2:',
stalkerDeviceId2
? stalkerDeviceId2.substring(0, 16) +
'... (user provided)'
: '(auto-generated)'
);
console.log(
'[StalkerImport] Signature 1:',
stalkerSignature1
? stalkerSignature1.substring(0, 16) +
'... (user provided)'
: '(not provided)'
);
console.log(
'[StalkerImport] Signature 2:',
stalkerSignature2
? stalkerSignature2.substring(0, 16) +
'... (user provided)'
: '(not provided)'
);
try {
const authResult =
await this.stalkerSessionService.authenticate(
transformedUrl,
this.form.value.macAddress,
stalkerSerialNumber,
stalkerDeviceId1,
stalkerDeviceId2
);
stalkerToken = authResult.token;
console.log(
'[StalkerImport] Authentication successful, token received:',
stalkerToken?.substring(0, 10) + '...'
);
if (authResult.accountInfo) {
stalkerAccountInfo = {
login: authResult.accountInfo.login,
expireDate: authResult.accountInfo.expire_date,
tariffPlanName:
authResult.accountInfo.tariff_plan_name,
status: authResult.accountInfo.status,
};
console.log(
'[StalkerImport] Account info received:',
stalkerAccountInfo
);
}
// Show success notification with account info if available
if (stalkerAccountInfo?.expireDate) {
const expireDate = new Date(
stalkerAccountInfo.expireDate * 1000
);
this.snackBar.open(
`Portal validated. Expires: ${expireDate.toLocaleDateString()}`,
null,
{ duration: 3000 }
);
}
} catch (error) {
console.error(
'[StalkerImport] Authentication failed:',
error
);
this.snackBar.open(
'Failed to authenticate with portal. Please check URL and MAC address.',
null,
{ duration: 5000 }
);
this.isLoading.set(false);
return;
}
} else {
console.log(
'[StalkerImport] Simple portal URL, skipping authentication'
);
}
const playlist: Playlist = {
...this.form.value,
portalUrl: transformedUrl,
isFullStalkerPortal,
stalkerToken,
stalkerAccountInfo,
stalkerSerialNumber,
stalkerDeviceId1,
stalkerDeviceId2,
stalkerSignature1,
stalkerSignature2,
} as Playlist;
console.log('[StalkerImport] Creating playlist with config:', {
portalUrl: playlist.portalUrl,
isFullStalkerPortal: playlist.isFullStalkerPortal,
hasToken: !!playlist.stalkerToken,
});
this.store.dispatch(PlaylistActions.addPlaylist({ playlist }));
this.addClicked.emit();
} finally {
this.isLoading.set(false);
}
}
transformPortalUrl(url: string) {
// if the url ends with "/c" it should be to end with "/portal.php"
/**
* Checks if the URL is a full stalker portal URL that requires handshake authentication
* Pattern: example.com/stalker_portal/c or example.com/stalker_portal/...
*/
isFullStalkerPortalUrl(url: string): boolean {
return url.includes('/stalker_portal');
}
/**
* Transforms the portal URL to the correct API endpoint
* - Simple URL (example.com/c) -> example.com/portal.php
* - Full stalker portal (example.com/stalker_portal/c) -> example.com/stalker_portal/server/load.php
*/
transformPortalUrl(url: string): string {
// Remove trailing slashes
url = url.replace(/\/+$/, '');
// Case 1: Simple URL ending with /c -> convert to /portal.php
if (url.endsWith('/c')) {
return url.replace('/c', '/portal.php');
// Check if it's a full stalker portal URL
if (url.includes('/stalker_portal')) {
// example.com/stalker_portal/c -> example.com/stalker_portal/server/load.php
return url.replace(
/\/stalker_portal\/c$/,
'/stalker_portal/server/load.php'
);
}
// Simple URL: example.com/c -> example.com/portal.php
return url.replace(/\/c$/, '/portal.php');
}
if (url.endsWith('/c/')) {
return url.replace('/c/', '/portal.php');
// Case 2: Full stalker portal URL without /c at the end
if (
url.includes('/stalker_portal') &&
!url.includes('/server/load.php')
) {
// example.com/stalker_portal -> example.com/stalker_portal/server/load.php
if (url.endsWith('/stalker_portal')) {
return url + '/server/load.php';
}
// If it has other path segments after /stalker_portal, append server/load.php
if (!url.endsWith('/load.php')) {
return url.replace(
/\/stalker_portal(\/.*)?$/,
'/stalker_portal/server/load.php'
);
}
}
// if the url ends with "/stalker_portal" it should be extended to "/stalker_portal/server/load.php"
if (url.endsWith('/stalker_portal')) {
return url.replace(
'/stalker_portal/c',
'/stalker_portal/server/load.php'
);
}
// otherwise keep the provided url
// Otherwise keep the provided url
return url;
}
}
@@ -7,8 +7,12 @@ import { ActivatedRoute } from '@angular/router';
import { Store } from '@ngrx/store';
import { TranslatePipe } from '@ngx-translate/core';
import { selectPlaylistById } from 'm3u-state';
import { DataService } from 'services';
import { STALKER_REQUEST, StalkerPortalActions } from 'shared-interfaces';
import { DataService, StalkerSessionService } from 'services';
import {
Playlist,
STALKER_REQUEST,
StalkerPortalActions,
} from 'shared-interfaces';
import { SearchFormComponent } from '../../shared/components/search-form/search-form.component';
import { SearchResultItemComponent } from '../../shared/components/search-result-item/search-result-item.component';
import { PlaylistErrorViewComponent } from '../../xtream-tauri/playlist-error-view/playlist-error-view.component';
@@ -57,6 +61,7 @@ export class StalkerSearchComponent {
private readonly activatedRoute = inject(ActivatedRoute);
private readonly dataService = inject(DataService);
private readonly stalkerStore = inject(StalkerStore);
private readonly stalkerSession = inject(StalkerSessionService);
private readonly store = inject(Store);
readonly filters = {
@@ -101,6 +106,21 @@ export class StalkerSearchComponent {
if (!playlist) return [];
const { portalUrl, macAddress } = playlist;
// Get token if it's a full stalker portal
let token: string | undefined;
let serialNumber: string | undefined;
if ((playlist as Playlist).isFullStalkerPortal) {
try {
const result = await this.stalkerSession.ensureToken(
playlist as Playlist
);
token = result.token ?? undefined;
serialNumber = (playlist as Playlist).stalkerSerialNumber;
} catch (error) {
console.error('Failed to get stalker token:', error);
}
}
const response = await this.dataService.sendIpcEvent(
STALKER_REQUEST,
{
@@ -113,6 +133,8 @@ export class StalkerSearchComponent {
search: params.search,
max_page_items: 100,
},
token,
serialNumber,
}
);
if (response) {
@@ -1,6 +1,6 @@
@let seasons = seasonsData;
@let seasons = seasonsData();
<div class="container">
@if (selectedItem(); as serial) {
@if (displayItem(); as serial) {
<div class="image">
@if (serial.info.movie_image) {
<img
@@ -9,6 +9,7 @@
(error)="
$event.target.src = './assets/images/default-poster.png'
"
alt="Series Cover"
/>
} @else {
<div class="placeholder-cover"></div>
@@ -20,32 +21,48 @@
<div>
{{ serial.info.description }}
</div>
<div>
<div class="label">{{ 'XTREAM.DIRECTOR' | translate }}:</div>
{{ serial.info.director }}
</div>
<div>
<div class="label">{{ 'XTREAM.ACTORS' | translate }}:</div>
{{ serial.info.actors }}
</div>
<div>
<div class="label">{{ 'XTREAM.YEAR' | translate }}:</div>
{{ serial.info.releasedate }}
</div>
<div>
<div class="label">{{ 'XTREAM.GENRE' | translate }}:</div>
{{ serial.info.genre }}
</div>
<div>
<div class="label">{{ 'XTREAM.IMDB_RATING' | translate }}:</div>
{{ serial.info.rating_imdb }}
</div>
<div>
<div class="label">
{{ 'XTREAM.KINOPOISK_RATING' | translate }}:
@if (serial.info.director) {
<div>
<div class="label">
{{ 'XTREAM.DIRECTOR' | translate }}:
</div>
{{ serial.info.director }}
</div>
{{ serial.info.rating_kinopoisk }}
</div>
}
@if (serial.info.actors) {
<div>
<div class="label">{{ 'XTREAM.ACTORS' | translate }}:</div>
{{ serial.info.actors }}
</div>
}
@if (serial.info.releasedate || serial.info.year) {
<div>
<div class="label">{{ 'XTREAM.YEAR' | translate }}:</div>
{{ serial.info.releasedate || serial.info.year }}
</div>
}
@if (serial.info.genre || serial.info.genres_str) {
<div>
<div class="label">{{ 'XTREAM.GENRE' | translate }}:</div>
{{ serial.info.genre || serial.info.genres_str }}
</div>
}
@if (serial.info.rating_imdb) {
<div>
<div class="label">
{{ 'XTREAM.IMDB_RATING' | translate }}:
</div>
{{ serial.info.rating_imdb }}
</div>
}
@if (serial.info.rating_kinopoisk) {
<div>
<div class="label">
{{ 'XTREAM.KINOPOISK_RATING' | translate }}:
</div>
{{ serial.info.rating_kinopoisk }}
</div>
}
<div class="action-buttons">
<app-favorites-button
[itemId]="serial.id"
@@ -58,19 +75,85 @@
</div>
}
</div>
<!-- Loading state for VOD series seasons -->
@if (isVodSeries() && isVodSeriesSeasonsLoading()) {
<div class="seasons-loading">
<mat-spinner diameter="40"></mat-spinner>
<span>{{ 'PORTALS.LOADING_SEASONS' | translate }}</span>
</div>
}
<!-- Loading state for regular series seasons -->
@if (!isVodSeries() && isSerialSeasonsLoading()) {
<div class="seasons-loading">
<mat-spinner diameter="40"></mat-spinner>
<span>{{ 'PORTALS.LOADING_SEASONS' | translate }}</span>
</div>
}
<div class="seasons">
@for (item of seasons(); track $index) {
<mat-divider />
<h4>{{ item.name }}</h4>
<div class="episodes">
@for (episode of item.series; track $index) {
<button
mat-stroked-button
(click)="playEpisodeClicked(episode, item.cmd)"
>
{{ 'XTREAM.EPISODE' | translate }} {{ episode }}
</button>
@if (isVodSeries()) {
<!-- VOD series mode: expandable seasons with dynamic episode loading -->
@for (item of vodSeriesSeasons(); track item.id) {
<mat-divider />
<div class="season-header" (click)="toggleSeasonExpanded(item)">
<h4>{{ item.name }}</h4>
<span class="expand-icon">{{
item.isExpanded ? '▼' : '►'
}}</span>
</div>
@if (item.isExpanded) {
@if (item.isLoading) {
<div class="episodes-loading">
<mat-spinner diameter="24"></mat-spinner>
</div>
} @else {
<div class="episodes">
@for (
episode of item.episodes;
track episode.id || $index
) {
<button
mat-stroked-button
(click)="
playVodSeriesEpisode(episode);
$event.stopPropagation()
"
>
{{
episode.name ||
('XTREAM.EPISODE' | translate) +
' ' +
episode.series_number
}}
</button>
}
@if (!item.episodes || item.episodes.length === 0) {
<span class="no-episodes">{{
'PORTALS.NO_EPISODES' | translate
}}</span>
}
</div>
}
}
</div>
}
} @else {
<!-- Regular series mode: pre-loaded episodes -->
@for (item of seasons; track item.id || $index) {
<mat-divider />
<h4>{{ item.name }}</h4>
<div class="episodes">
@for (episode of $any(item).series; track $index) {
<button
mat-stroked-button
(click)="playEpisodeClicked(episode, $any(item).cmd)"
>
{{ 'XTREAM.EPISODE' | translate }} {{ episode }}
</button>
}
</div>
}
}
</div>
@@ -1,24 +1,220 @@
import { Component, inject } from '@angular/core';
import {
Component,
computed,
effect,
inject,
input,
signal,
} from '@angular/core';
import { MatButton } from '@angular/material/button';
import { MatDivider } from '@angular/material/divider';
import { MatProgressSpinner } from '@angular/material/progress-spinner';
import { TranslatePipe } from '@ngx-translate/core';
import { FavoritesButtonComponent } from '../favorites-button/favorites-button.component';
import { StalkerStore } from '../stalker.store';
/**
* VOD series season with episodes loaded dynamically
*/
interface VodSeriesSeason {
id: string;
video_id: string;
name: string;
season_number: string;
episodes?: any[];
isLoading?: boolean;
isExpanded?: boolean;
}
/**
* Component for displaying series/episodes for Stalker portal content.
* Supports three modes:
* 1. Regular series (type=series): Fetches seasons from API via serialSeasonsResource
* 2. VOD with embedded series (vclub): Uses the series array from the vodWithSeries input
* 3. VOD series (Ministra is_series=1): Fetches seasons/episodes using movie_id and season_id
*/
@Component({
selector: 'app-stalker-series-view',
templateUrl: './stalker-series-view.component.html',
styleUrls: ['../../xtream/detail-view.scss'],
imports: [FavoritesButtonComponent, MatButton, MatDivider, TranslatePipe],
imports: [
FavoritesButtonComponent,
MatButton,
MatDivider,
MatProgressSpinner,
TranslatePipe,
],
})
export class StalkerSeriesViewComponent {
readonly stalkerStore = inject(StalkerStore);
readonly selectedItem = this.stalkerStore.selectedItem;
readonly seasonsData = this.stalkerStore.getSerialSeasonsResource;
/**
* Optional input for VOD items with embedded series array (vclub mode)
* When provided, uses this instead of fetching seasons from API
*/
readonly vodWithSeries = input<any>(null);
readonly selectedItem = this.stalkerStore.selectedItem;
/**
* Track VOD series seasons with their loaded episodes
*/
readonly vodSeriesSeasons = signal<VodSeriesSeason[]>([]);
/**
* Indicates if this is a VOD series item (Ministra is_series=1)
*/
readonly isVodSeries = computed(() => {
const item = this.displayItem();
return item?.is_series === true;
});
/**
* Loading state for VOD series seasons
*/
readonly isVodSeriesSeasonsLoading =
this.stalkerStore.isVodSeriesSeasonsLoading;
/**
* Loading state for regular series seasons
*/
readonly isSerialSeasonsLoading = this.stalkerStore.isSerialSeasonsLoading;
constructor() {
// Effect to load VOD series seasons when a VOD series item is selected
effect(() => {
const item = this.displayItem();
if (item?.is_series === true) {
// Get seasons from the resource
const seasons = this.stalkerStore.getVodSeriesSeasonsResource();
if (seasons && seasons.length > 0) {
// Map seasons to our VodSeriesSeason structure
const mappedSeasons: VodSeriesSeason[] = seasons.map(
(season: any) => ({
id: season.id,
video_id: season.video_id,
name:
season.name || `Season ${season.season_number}`,
season_number: season.season_number,
episodes: [],
isLoading: false,
isExpanded: false,
})
);
this.vodSeriesSeasons.set(mappedSeasons);
}
} else {
this.vodSeriesSeasons.set([]);
}
});
}
/**
* For VOD with embedded series, we create a single "season" with the episodes
* For regular series, we use the API-fetched seasons
* For VOD series (is_series=1), we use the fetched seasons with dynamic episode loading
*/
readonly seasonsData = computed(() => {
const vodItem = this.vodWithSeries();
if (vodItem?.series?.length > 0) {
// VOD with embedded series - create a pseudo-season structure
return [
{
id: vodItem.id,
name: vodItem.info?.name || 'Episodes',
cmd: vodItem.cmd,
series: vodItem.series,
},
];
}
// Check if this is a VOD series (is_series=1)
const displayItem = this.displayItem();
if (displayItem?.is_series === true) {
// Return VOD series seasons (episodes loaded dynamically)
return this.vodSeriesSeasons();
}
// Regular series - use API-fetched seasons
return this.stalkerStore.getSerialSeasonsResource();
});
/**
* Get the item to display details for (either vodWithSeries or selectedItem from store)
*/
readonly displayItem = computed(() => {
return this.vodWithSeries() || this.selectedItem();
});
/**
* Toggle season expansion and load episodes if needed (for VOD series)
*/
async toggleSeasonExpanded(season: VodSeriesSeason) {
const seasons = this.vodSeriesSeasons();
const index = seasons.findIndex((s) => s.id === season.id);
if (index === -1) return;
const updatedSeasons = [...seasons];
const currentSeason = { ...updatedSeasons[index] };
// Toggle expansion
currentSeason.isExpanded = !currentSeason.isExpanded;
// Load episodes if expanding and not already loaded
if (
currentSeason.isExpanded &&
(!currentSeason.episodes || currentSeason.episodes.length === 0)
) {
currentSeason.isLoading = true;
updatedSeasons[index] = currentSeason;
this.vodSeriesSeasons.set(updatedSeasons);
try {
const episodes = await this.stalkerStore.fetchVodSeriesEpisodes(
currentSeason.video_id,
currentSeason.id
);
// Update season with loaded episodes
const newSeasons = [...this.vodSeriesSeasons()];
const seasonIndex = newSeasons.findIndex(
(s) => s.id === season.id
);
if (seasonIndex !== -1) {
newSeasons[seasonIndex] = {
...newSeasons[seasonIndex],
episodes: episodes,
isLoading: false,
};
this.vodSeriesSeasons.set(newSeasons);
}
} catch (error) {
console.error(
'[StalkerSeriesViewComponent] Failed to load episodes:',
error
);
const newSeasons = [...this.vodSeriesSeasons()];
const seasonIndex = newSeasons.findIndex(
(s) => s.id === season.id
);
if (seasonIndex !== -1) {
newSeasons[seasonIndex] = {
...newSeasons[seasonIndex],
isLoading: false,
};
this.vodSeriesSeasons.set(newSeasons);
}
}
} else {
updatedSeasons[index] = currentSeason;
this.vodSeriesSeasons.set(updatedSeasons);
}
}
/**
* Play episode - handles both regular series and VOD series
*/
playEpisodeClicked(episode: any, cmd: string) {
const item = this.selectedItem();
const item = this.displayItem();
this.stalkerStore.createLinkToPlayVod(
cmd,
item.info.name,
@@ -27,12 +223,30 @@ export class StalkerSeriesViewComponent {
);
}
/**
* Play VOD series episode
* For VOD series, we need to use a different cmd format
*/
playVodSeriesEpisode(episode: any) {
const item = this.displayItem();
// For VOD series episodes, the cmd is: /media/file_{episode.id}.mpg
const cmd = `/media/file_${episode.id}.mpg`;
const episodeName = episode.name || `Episode ${episode.series_number}`;
this.stalkerStore.createLinkToPlayVod(
cmd,
`${item.info.name} - ${episodeName}`,
item.info.movie_image
);
}
addToFavorites(item: any) {
const displayItem = this.displayItem();
this.stalkerStore.addToFavorites({
...item,
title: item.info.name,
cover: item.info.movie_image,
series_id: item.id,
title: displayItem.info.name,
cover: displayItem.info.movie_image,
series_id: displayItem.id,
added_at: new Date().toISOString(),
category_id: 'series',
});
+659 -160
View File
@@ -9,8 +9,9 @@ import {
withState,
} from '@ngrx/signals';
import { TranslateService } from '@ngx-translate/core';
import { DataService, PlaylistsService } from 'services';
import { DataService, PlaylistsService, StalkerSessionService } from 'services';
import {
Playlist,
PlaylistMeta,
STALKER_REQUEST,
StalkerPortalActions,
@@ -42,6 +43,10 @@ interface StalkerState {
itvCategories: StalkerCategoryItem[];
hasMoreChannels: boolean;
itvChannels: any[];
/** For VOD items that are actually series (Ministra plugin is_series=1) */
vodSeriesSeasons: any[];
vodSeriesEpisodes: any[];
selectedVodSeriesSeasonId: string;
}
const initialState: StalkerState = {
@@ -61,6 +66,9 @@ const initialState: StalkerState = {
itvCategories: [],
hasMoreChannels: false,
itvChannels: [],
vodSeriesSeasons: [],
vodSeriesEpisodes: [],
selectedVodSeriesSeasonId: undefined,
};
function extractNumericValue(str: string) {
@@ -81,52 +89,167 @@ function sortByNumericValue(array: StalkerSeason[]): StalkerSeason[] {
});
}
/**
* Sort episodes by series_number in ascending numeric order (1, 2, 3... not "1", "10", "2")
*/
function sortEpisodesByNumber(episodes: any[]): any[] {
if (!episodes) return [];
return episodes.sort((a, b) => {
const numA = parseInt(a.series_number, 10) || 0;
const numB = parseInt(b.series_number, 10) || 0;
return numA - numB;
});
}
/**
* Convert relative URLs to absolute URLs using the portal base URL
* Handles screenshot_uri and cmd paths that come as relative from the server
*/
function makeAbsoluteUrl(baseUrl: string, relativePath: string): string {
if (!relativePath) return '';
// Already absolute URL
if (
relativePath.startsWith('http://') ||
relativePath.startsWith('https://')
) {
return relativePath;
}
// Parse the base URL to get origin
try {
const url = new URL(baseUrl);
// Ensure the relative path starts with /
const path = relativePath.startsWith('/')
? relativePath
: `/${relativePath}`;
return `${url.origin}${path}`;
} catch {
return relativePath;
}
}
/**
* Post-process stalker items to convert relative URLs to absolute
*/
function processItemUrls(item: any, portalUrl: string): any {
const processed = { ...item };
// Convert screenshot_uri to absolute URL
if (processed.screenshot_uri) {
processed.screenshot_uri = makeAbsoluteUrl(
portalUrl,
processed.screenshot_uri
);
}
return processed;
}
export const StalkerStore = signalStore(
{ providedIn: 'root' },
withState(initialState),
withProps((store, dataService = inject(DataService)) => ({
getCategoryResource: resource({
params: () => ({
contentType: store.selectedContentType(),
action: StalkerPortalActions.GetCategories,
currentPlaylist: store.currentPlaylist(),
}),
loader: async ({ params }) => {
if (params.currentPlaylist === undefined) return;
switch (params.contentType) {
case 'itv':
if (store.itvCategories().length > 0) {
return store.itvCategories();
}
break;
case 'vod':
if (store.vodCategories().length > 0) {
return store.vodCategories();
}
break;
case 'series':
if (store.seriesCategories().length > 0) {
return store.seriesCategories();
}
break;
withProps(
(
store,
dataService = inject(DataService),
stalkerSession = inject(StalkerSessionService)
) => ({
/**
* Helper to make stalker requests with automatic token handling
*/
async makeStalkerRequest(
playlist: PlaylistMeta,
params: Record<string, any>
) {
// Get token if it's a full stalker portal
let token: string | undefined;
let serialNumber: string | undefined;
if ((playlist as Playlist).isFullStalkerPortal) {
try {
const result = await stalkerSession.ensureToken(
playlist as Playlist
);
token = result.token ?? undefined;
serialNumber = (playlist as Playlist)
.stalkerSerialNumber;
} catch (error) {
console.error('Failed to get stalker token:', error);
}
}
const { portalUrl, macAddress } = params.currentPlaylist;
return dataService.sendIpcEvent(STALKER_REQUEST, {
url: playlist.portalUrl,
macAddress: playlist.macAddress,
params,
token,
serialNumber,
});
},
getCategoryResource: resource({
params: () => ({
contentType: store.selectedContentType(),
action: StalkerPortalActions.GetCategories,
currentPlaylist: store.currentPlaylist(),
}),
loader: async ({ params }) => {
if (params.currentPlaylist === undefined) return;
const response = await dataService.sendIpcEvent(
STALKER_REQUEST,
{
url: portalUrl,
macAddress,
params: {
action: StalkerContentTypes[params.contentType]
.getCategoryAction,
type: params.contentType,
},
switch (params.contentType) {
case 'itv':
if (store.itvCategories().length > 0) {
return store.itvCategories();
}
break;
case 'vod':
if (store.vodCategories().length > 0) {
return store.vodCategories();
}
break;
case 'series':
if (store.seriesCategories().length > 0) {
return store.seriesCategories();
}
break;
}
);
if (response) {
const { portalUrl, macAddress } = params.currentPlaylist;
// Use makeAuthenticatedRequest for automatic retry on auth failure
const playlist = params.currentPlaylist as Playlist;
const queryParams = {
action: StalkerContentTypes[params.contentType]
.getCategoryAction,
type: params.contentType,
};
let response: any;
if (playlist.isFullStalkerPortal) {
// Full stalker portal - use authenticated request with retry
response =
await stalkerSession.makeAuthenticatedRequest(
playlist,
queryParams
);
} else {
// Simple stalker portal - no auth needed
response = await dataService.sendIpcEvent(
STALKER_REQUEST,
{
url: portalUrl,
macAddress,
params: queryParams,
}
);
}
// Guard: ensure response has expected structure
if (!response?.js || !Array.isArray(response.js)) {
console.warn(
'[StalkerStore] Invalid categories response:',
response
);
return [];
}
const categories = response.js
.map((item) => ({
category_name: item.title,
@@ -139,58 +262,102 @@ export const StalkerStore = signalStore(
[`${params.contentType}Categories`]: categories,
});
return categories;
} else {
throw new Error(
`Error: ${response.message} (Status: ${response.status})`
);
}
},
}),
getContentResource: resource({
params: () => ({
contentType: store.selectedContentType(),
category: store.selectedCategoryId(),
action: StalkerPortalActions.GetOrderedList,
search: store.searchPhrase(),
pageIndex: store.page() + 1,
},
}),
loader: async ({ params }) => {
if (
!params.category ||
params.category === null ||
params.category === ''
) {
return Promise.resolve(undefined);
}
const currentPlaylist = store.currentPlaylist;
const queryParams = {
action: StalkerContentTypes[params.contentType]
.getContentAction,
type: params.contentType,
category: params.category ?? '',
genre: params.category ?? '',
sortby: 'added',
...(params.search !== '' ? { search: params.search } : {}),
p: params.pageIndex,
};
const response = await dataService.sendIpcEvent(
STALKER_REQUEST,
{
url: currentPlaylist().portalUrl,
macAddress: currentPlaylist().macAddress,
params: queryParams,
getContentResource: resource({
params: () => ({
contentType: store.selectedContentType(),
category: store.selectedCategoryId(),
action: StalkerPortalActions.GetOrderedList,
search: store.searchPhrase(),
pageIndex: store.page() + 1,
}),
loader: async ({ params }) => {
if (
!params.category ||
params.category === null ||
params.category === ''
) {
return Promise.resolve(undefined);
}
);
patchState(store, { totalCount: response.js.total_items });
const currentPlaylist = store.currentPlaylist;
if (response) {
const newItems = response.js.data.map((item) => ({
...item,
cover: item.screenshot_uri,
}));
// Guard: ensure currentPlaylist is available (may not be during deep link init)
if (!currentPlaylist() || !currentPlaylist().portalUrl) {
return Promise.resolve(undefined);
}
// VOD uses 'genre' param, series uses 'category' param, itv uses both
// Based on stalker-to-m3u implementation
const categoryParam = params.category ?? '';
const queryParams: Record<string, string | number> = {
action: StalkerContentTypes[params.contentType]
.getContentAction,
type: params.contentType,
sortby: 'added',
...(params.search !== ''
? { search: params.search }
: {}),
p: params.pageIndex,
};
// Add the correct category/genre param based on content type
// Based on working app traces: VOD uses genre=0 and category={id}
if (params.contentType === 'vod') {
queryParams['genre'] = '0';
queryParams['category'] = categoryParam;
} else if (params.contentType === 'series') {
queryParams['category'] = categoryParam;
} else {
// itv - use both for compatibility
queryParams['category'] = categoryParam;
queryParams['genre'] = categoryParam;
}
// Use makeAuthenticatedRequest for automatic retry on auth failure
const playlist = currentPlaylist() as Playlist;
let response: any;
if (playlist.isFullStalkerPortal) {
// Full stalker portal - use authenticated request with retry
response =
await stalkerSession.makeAuthenticatedRequest(
playlist,
queryParams
);
} else {
// Simple stalker portal - no auth needed
response = await dataService.sendIpcEvent(
STALKER_REQUEST,
{
url: playlist.portalUrl,
macAddress: playlist.macAddress,
params: queryParams,
}
);
}
// Guard: ensure response has expected structure
if (!response?.js?.data) {
console.warn(
'[StalkerStore] Invalid response structure:',
response
);
return [];
}
patchState(store, {
totalCount: response.js.total_items ?? 0,
});
const portalUrl = currentPlaylist().portalUrl;
const newItems = response.js.data.map((item) => {
// Post-process to convert relative URLs to absolute
const processed = processItemUrls(item, portalUrl);
return {
...processed,
cover: processed.screenshot_uri,
};
});
if (store.selectedContentType() === 'itv') {
// Check if we're loading the first page or loading more
@@ -209,41 +376,129 @@ export const StalkerStore = signalStore(
const totalLoaded = store.itvChannels().length;
patchState(store, {
hasMoreChannels:
totalLoaded < response.js.total_items,
totalLoaded < (response.js.total_items ?? 0),
});
}
return newItems;
} else {
throw new Error(
`Error: ${response.message} (Status: ${response.status})`
);
}
},
}),
serialSeasonsResource: resource({
params: () => ({
itemId: store.selectedSerialId(),
},
}),
loader: async ({ params }) => {
const { portalUrl, macAddress } = store.currentPlaylist();
const queryParams = {
action: StalkerContentTypes.series.getContentAction,
type: 'series',
movie_id: params.itemId,
};
const response = await dataService.sendIpcEvent(
STALKER_REQUEST,
{
url: portalUrl,
macAddress,
params: queryParams,
serialSeasonsResource: resource({
params: () => ({
itemId: store.selectedSerialId(),
}),
loader: async ({ params }) => {
// Guard: ensure currentPlaylist and itemId are available
if (!store.currentPlaylist() || !params.itemId) {
return [];
}
);
return sortByNumericValue(response.js.data);
},
}),
})),
const playlist = store.currentPlaylist() as Playlist;
const queryParams = {
action: StalkerContentTypes.series.getContentAction,
type: 'series',
movie_id: params.itemId,
};
// Use makeAuthenticatedRequest for automatic retry on auth failure
let response: any;
if (playlist.isFullStalkerPortal) {
// Full stalker portal - use authenticated request with retry
response =
await stalkerSession.makeAuthenticatedRequest(
playlist,
queryParams
);
} else {
// Simple stalker portal - no auth needed
response = await dataService.sendIpcEvent(
STALKER_REQUEST,
{
url: playlist.portalUrl,
macAddress: playlist.macAddress,
params: queryParams,
}
);
}
// Guard: ensure response has expected structure
if (!response?.js?.data) {
console.warn(
'[StalkerStore] Invalid seasons response:',
response
);
return [];
}
return sortByNumericValue(response.js.data);
},
}),
/**
* Resource to fetch seasons for VOD items that are actually series (is_series=1)
* Used for Ministra plugin where VOD items can contain series/seasons
*/
vodSeriesSeasonsResource: resource({
params: () => ({
selectedItem: store.selectedItem(),
}),
loader: async ({ params }) => {
const item = params.selectedItem;
// Only fetch if item is a VOD series (has is_series flag)
if (!store.currentPlaylist() || !item || !item.is_series) {
return [];
}
const playlist = store.currentPlaylist() as Playlist;
const queryParams = {
action: StalkerPortalActions.GetOrderedList,
type: 'vod',
movie_id: item.id,
p: '1',
};
console.log(
'[StalkerStore] Fetching VOD series seasons for movie_id:',
item.id
);
let response: any;
if (playlist.isFullStalkerPortal) {
response =
await stalkerSession.makeAuthenticatedRequest(
playlist,
queryParams
);
} else {
response = await dataService.sendIpcEvent(
STALKER_REQUEST,
{
url: playlist.portalUrl,
macAddress: playlist.macAddress,
params: queryParams,
}
);
}
console.log(
'[StalkerStore] VOD series seasons response:',
response
);
if (!response?.js?.data) {
console.warn(
'[StalkerStore] Invalid VOD series seasons response:',
response
);
return [];
}
// Filter for season items (is_season: true)
const seasons = response.js.data.filter(
(item: any) => item.is_season === true
);
return sortByNumericValue(seasons);
},
}),
})
),
withComputed((store) => ({
getTotalPages: computed(() => {
return Math.ceil(store.totalCount() / store.limit());
@@ -260,6 +515,16 @@ export const StalkerStore = signalStore(
getSerialSeasonsResource: computed(() =>
store.serialSeasonsResource.value()
),
isSerialSeasonsLoading: computed(() =>
store.serialSeasonsResource.isLoading()
),
/** VOD series (Ministra plugin is_series=1) */
getVodSeriesSeasonsResource: computed(() =>
store.vodSeriesSeasonsResource.value()
),
isVodSeriesSeasonsLoading: computed(() =>
store.vodSeriesSeasonsResource.isLoading()
),
/** category resource */
getCategoryResource: computed(() => store.getCategoryResource.value()),
isCategoryResourceLoading: computed(() =>
@@ -292,6 +557,7 @@ export const StalkerStore = signalStore(
dataService = inject(DataService),
playerService = inject(PlayerService),
playlistService = inject(PlaylistsService),
stalkerSession = inject(StalkerSessionService),
snackBar = inject(MatSnackBar),
translate = inject(TranslateService)
) => ({
@@ -359,6 +625,76 @@ export const StalkerStore = signalStore(
setItvChannels(channels: any[]) {
patchState(store, { itvChannels: channels });
},
/**
* Fetch episodes for a VOD series season (Ministra plugin)
* @param videoId The video_id from the season item
* @param seasonId The season id
* @returns Array of episode items
*/
async fetchVodSeriesEpisodes(
videoId: string,
seasonId: string
): Promise<any[]> {
const playlist = store.currentPlaylist() as Playlist;
if (!playlist) return [];
const queryParams = {
action: StalkerPortalActions.GetOrderedList,
type: 'vod',
movie_id: videoId,
season_id: seasonId,
p: '1',
};
console.log(
'[StalkerStore] Fetching VOD series episodes for video_id:',
videoId,
'season_id:',
seasonId
);
let response: any;
if (playlist.isFullStalkerPortal) {
response = await stalkerSession.makeAuthenticatedRequest(
playlist,
queryParams
);
} else {
response = await dataService.sendIpcEvent(STALKER_REQUEST, {
url: playlist.portalUrl,
macAddress: playlist.macAddress,
params: queryParams,
});
}
console.log(
'[StalkerStore] VOD series episodes response:',
response
);
if (!response?.js?.data) {
console.warn(
'[StalkerStore] Invalid VOD series episodes response:',
response
);
return [];
}
// Filter for episode items (is_episode: true) and sort by series_number
const episodes = sortEpisodesByNumber(
response.js.data.filter(
(item: any) => item.is_episode === true
)
);
// Store episodes and selected season
patchState(store, {
vodSeriesEpisodes: episodes,
selectedVodSeriesSeasonId: seasonId,
});
return episodes;
},
/** getters */
getSelectedCategory() {
return signal<string>(store.selectedCategoryId());
@@ -373,28 +709,97 @@ export const StalkerStore = signalStore(
const type = series
? ContentType.VODS
: store.selectedContentType();
// Always use create_link to get the tokenized streaming URL
// The server adds the required token for playback authorization
// Note: cmd is already transformed during item processing (has_files items)
const params = {
action: StalkerContentTypes[store.selectedContentType()]
.getLink,
cmd: cmd,
type,
cmd,
forced_storage: 'undefined',
disable_ad: '0',
download: '0',
JsHttpRequest: '1-xml',
...(series ? { series } : {}),
...(series ? { series: String(series) } : {}),
};
const response = await dataService.sendIpcEvent(
STALKER_REQUEST,
{
url: portalUrl + '?' + cmd,
console.log('[StalkerStore] series is :', series);
// Use makeAuthenticatedRequest for automatic retry on auth failure
const playlist = store.currentPlaylist() as Playlist;
let response: any;
if (playlist?.isFullStalkerPortal) {
// Full stalker portal - use authenticated request with retry
response = await stalkerSession.makeAuthenticatedRequest(
playlist,
params
);
} else {
// Simple stalker portal - no auth needed
response = await dataService.sendIpcEvent(STALKER_REQUEST, {
url: portalUrl,
macAddress,
params,
}
);
});
}
// Check for server-side errors
if (response.js?.error) {
const errorMsg = response.js.error;
console.error('[StalkerStore] Server error:', errorMsg);
throw new Error(errorMsg);
}
let url = response.js.cmd as string;
if (url?.startsWith('ffmpeg')) {
// 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 &&
!url.startsWith('http://') &&
!url.startsWith('https://')
) {
// Extract base URL from portal URL
try {
const portalUrlObj = new URL(portalUrl);
// Get the stalker portal base path (e.g., /stalker_portal from /stalker_portal/server/load.php)
const pathParts = portalUrlObj.pathname.split('/');
// Find the stalker_portal or c directory and use that as base
let basePath = '';
for (let i = 0; i < pathParts.length; i++) {
if (
pathParts[i] === 'stalker_portal' ||
pathParts[i] === 'c' ||
pathParts[i] === 'portal'
) {
basePath =
'/' + pathParts.slice(1, i + 1).join('/');
break;
}
}
// If url starts with ?, it's just query params
// Combine with the original cmd path to form the complete streaming URL
if (url.startsWith('?')) {
// 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}`;
} else if (url.startsWith('/')) {
// Relative path - prepend origin and base path
url = `${portalUrlObj.origin}${basePath}${url}`;
}
} catch {
// URL parsing failed, return as-is
}
}
return url;
},
async getExpireDate() {
@@ -405,14 +810,27 @@ export const StalkerStore = signalStore(
};
try {
const response = await dataService.sendIpcEvent(
STALKER_REQUEST,
{
url: store.currentPlaylist().portalUrl,
macAddress: store.currentPlaylist().macAddress,
params,
}
);
// Use makeAuthenticatedRequest for automatic retry on auth failure
const playlist = store.currentPlaylist() as Playlist;
let response: any;
if (playlist?.isFullStalkerPortal) {
// Full stalker portal - use authenticated request with retry
response =
await stalkerSession.makeAuthenticatedRequest(
playlist,
params
);
} else {
// Simple stalker portal - no auth needed
response = await dataService.sendIpcEvent(
STALKER_REQUEST,
{
url: playlist.portalUrl,
macAddress: playlist.macAddress,
params,
}
);
}
if (response && response.js && response.js.account_info) {
// Extract the expire date from the response
@@ -467,37 +885,118 @@ export const StalkerStore = signalStore(
);
});
},
/**
* Fetch movie files using get_ordered_list with movie_id parameter.
* This is needed for items with has_files property to get the correct video_id
* for the create_link request.
*/
async fetchMovieFileId(movieId: string): Promise<string | null> {
const playlist = store.currentPlaylist() as Playlist;
if (!playlist) return null;
const queryParams = {
action: StalkerPortalActions.GetOrderedList,
type: 'vod',
movie_id: movieId,
p: '1',
};
console.log(
'[StalkerStore] Fetching movie files for movie_id:',
movieId
);
let response: any;
if (playlist.isFullStalkerPortal) {
response = await stalkerSession.makeAuthenticatedRequest(
playlist,
queryParams
);
} else {
response = await dataService.sendIpcEvent(STALKER_REQUEST, {
url: playlist.portalUrl,
macAddress: playlist.macAddress,
params: queryParams,
});
}
console.log('[StalkerStore] Movie files response:', response);
// Extract id from the first data item
if (response?.js?.data?.[0]?.id) {
const fileId = response.js.data[0].id;
console.log('[StalkerStore] Found file id:', fileId);
return String(fileId);
}
return null;
},
async createLinkToPlayVod(
cmd?: string,
title?: string,
thumbnail?: string,
episode?: number
) {
const url = await this.fetchLinkToPlay(
this.currentPlaylist().portalUrl,
this.currentPlaylist().macAddress,
cmd ?? this.selectedItem().cmd,
episode
);
const item = this.selectedItem();
this.addToRecentlyViewed({
...item,
id: item.id,
cmd: cmd,
cover: thumbnail,
title,
});
const playlist = this.currentPlaylist();
playerService.openPlayer(
url,
title,
thumbnail,
true,
false,
playlist?.userAgent,
playlist?.referrer,
playlist?.origin
);
try {
const item = this.selectedItem();
let cmdToUse = cmd ?? item?.cmd;
// For items with has_files and relative path, we need to fetch the file id first
if (
item?.has_files !== undefined &&
cmdToUse &&
!cmdToUse.includes('://') &&
cmdToUse.includes('/media/') &&
!cmdToUse.includes('/media/file_')
) {
console.log(
'[StalkerStore] Item has has_files, fetching file id...'
);
const fileId = await this.fetchMovieFileId(item.id);
if (fileId) {
cmdToUse = `/media/file_${fileId}.mpg`;
console.log(
'[StalkerStore] Transformed cmd with file id:',
cmdToUse
);
}
}
const url = await this.fetchLinkToPlay(
this.currentPlaylist().portalUrl,
this.currentPlaylist().macAddress,
cmdToUse,
episode
);
this.addToRecentlyViewed({
...item,
id: item.id,
cmd: cmd,
cover: thumbnail,
title,
});
const playlist = this.currentPlaylist();
playerService.openPlayer(
url,
title,
thumbnail,
true,
false,
playlist?.userAgent,
playlist?.referrer,
playlist?.origin
);
} catch (error) {
console.error(
'[StalkerStore] Failed to get playback URL:',
error
);
const errorMessage =
error?.message === 'nothing_to_play'
? translate.instant('PORTALS.CONTENT_NOT_AVAILABLE')
: translate.instant('PORTALS.PLAYBACK_ERROR');
snackBar.open(errorMessage, null, { duration: 3000 });
}
},
addToRecentlyViewed(item: any) {
console.log('Adding to recently viewed', item);
@@ -1,18 +1,28 @@
@if (selectedCategory()) {
@if (isStalker && selectedItem()) {
@if (contentType === 'vod') {
<app-vod-details
[item]="selectedItem()"
(playClicked)="
createLinkToPlayVod(
selectedItem().cmd,
selectedItem().info.name,
selectedItem().info.movie_image
)
"
(addToFavoritesClicked)="addToFavorites($event)"
(removeFromFavoritesClicked)="removeFromFavorites($any($event))"
/>
@if (selectedItem().series?.length > 0) {
<!-- VOD item with embedded series (vclub) -->
<app-stalker-series-view [vodWithSeries]="selectedItem()" />
} @else if (selectedItem().is_series) {
<!-- VOD item that is actually a series (Ministra plugin is_series=1) -->
<app-stalker-series-view />
} @else {
<app-vod-details
[item]="selectedItem()"
(playClicked)="
createLinkToPlayVod(
selectedItem().cmd,
selectedItem().info.name,
selectedItem().info.movie_image
)
"
(addToFavoritesClicked)="addToFavorites($event)"
(removeFromFavoritesClicked)="
removeFromFavorites($any($event))
"
/>
}
} @else if (contentType === 'series') {
<app-stalker-series-view />
}
@@ -48,7 +48,10 @@ export class CategoryContentViewComponent implements OnInit {
const { categoryId } = this.activatedRoute.snapshot.params;
// Only set category if it's different from the currently selected one
// This preserves the page state when navigating back from detail view
if (categoryId && this.store.selectedCategoryId() !== Number(categoryId)) {
if (
categoryId &&
this.store.selectedCategoryId() !== Number(categoryId)
) {
this.store.setSelectedCategory(categoryId);
}
}
@@ -63,10 +66,22 @@ export class CategoryContentViewComponent implements OnInit {
const selectedItem = {
id: item.id,
cmd: item.cmd,
// For VOD items with embedded series array (Stalker vclub)
series: item.series,
// Preserve has_files for cmd transformation during playback
has_files: item.has_files,
// Flag for VOD items that are actually series (Ministra plugin)
// is_series can be "1" (string) or 1 (number)
// ONLY set this for VOD content type - regular series should use the standard series flow
is_series:
this.contentType === 'vod' &&
(item.is_series === '1' || item.is_series === 1),
// Store video_id for season fetching if available
video_id: item.video_id,
info: {
movie_image: item.screenshot_uri,
description: item.description,
name: item.name,
name: item.name || item.o_name,
director: item.director,
releasedate: item.year,
genre: item.genres_str,
+34
View File
@@ -42,6 +42,40 @@
margin-top: 5px;
}
}
.season-header {
display: flex;
align-items: center;
justify-content: space-between;
cursor: pointer;
padding: 10px 0;
&:hover {
opacity: 0.8;
}
h4 {
margin: 0;
}
.expand-icon {
font-size: 12px;
margin-right: 10px;
}
}
.seasons-loading,
.episodes-loading {
display: flex;
align-items: center;
gap: 10px;
padding: 20px;
}
.no-episodes {
font-style: italic;
opacity: 0.7;
}
}
.image {
+15 -2
View File
@@ -107,9 +107,18 @@
"STALKER_PORTAL": {
"TITLE": "Title",
"MAC_ADDRESS": "Mac Address",
"SERIAL_NUMBER": "Serial Number (Optional)",
"SERIAL_NUMBER_HINT": "Use if already registered with provider",
"DEVICE_ID_1": "Device ID 1 (Optional)",
"DEVICE_ID_2": "Device ID 2 (Optional)",
"DEVICE_ID_HINT": "64 hex characters - use if provider requires specific device ID",
"SIGNATURE_1": "Signature 1 (Optional)",
"SIGNATURE_2": "Signature 2 (Optional)",
"SIGNATURE_HINT": "64 hex characters - use if provider requires device verification signatures",
"SERVER_URL": "Server URL",
"URL_VALIDATION_ERROR": "Should be a valid url with protocol (e.g. http://example.com/c or https://example.com/stalker_portal/c)",
"ADD": "Add"
"ADD": "Add",
"VALIDATING": "Validating portal..."
},
"FILTER_BY_NAME": "Filter by name",
"FILTER_BY_TYPE": "Playlist Types",
@@ -402,7 +411,11 @@
"STREAM_URL_COPIED": "Stream URL copied to clipboard",
"COPY_STREAM_URL": "Copy Stream URL",
"SELECT_CATEGORY": "Select a category",
"PAGE": "Page"
"PAGE": "Page",
"CONTENT_NOT_AVAILABLE": "This content is not available on this server",
"PLAYBACK_ERROR": "Failed to get playback URL",
"LOADING_SEASONS": "Loading seasons...",
"NO_EPISODES": "No episodes available"
},
"INFORMATION": "Information",
"REMOTE_CONTROL": {
+4
View File
@@ -82,3 +82,7 @@ textarea,
.mat-drawer-inner-container {
overflow: hidden !important;
}
.cdk-overlay-backdrop {
backdrop-filter: blur(4px);
}
Vendored
+6
View File
@@ -67,12 +67,18 @@ declare global {
limit?: number
) => Promise<any[]>;
updateSettings: (settings: any) => Promise<void>;
getAiSettings: () => Promise<{
aiProvider: string;
aiModelName: string;
aiApiKey: string;
}>;
setMpvPlayerPath: (mpvPlayerPath: string) => Promise<void>;
setVlcPlayerPath: (vlcPlayerPath: string) => Promise<void>;
stalkerRequest: (payload: {
url: string;
macAddress: string;
params: Record<string, string>;
token?: string;
}) => Promise<any>;
xtreamRequest: (payload: {
url: string;
+1
View File
@@ -4,3 +4,4 @@ export * from './lib/epg.service';
export * from './lib/playlists.service';
export * from './lib/portal-status.service';
export * from './lib/sort.service';
export * from './lib/stalker-session.service';
@@ -100,6 +100,22 @@ export class PlaylistsService {
...(updatedPlaylist.updateDate !== undefined
? { updateDate: updatedPlaylist.updateDate }
: {}),
// Stalker portal optional fields
...(updatedPlaylist.stalkerSerialNumber !== undefined
? { stalkerSerialNumber: updatedPlaylist.stalkerSerialNumber }
: {}),
...(updatedPlaylist.stalkerDeviceId1 !== undefined
? { stalkerDeviceId1: updatedPlaylist.stalkerDeviceId1 }
: {}),
...(updatedPlaylist.stalkerDeviceId2 !== undefined
? { stalkerDeviceId2: updatedPlaylist.stalkerDeviceId2 }
: {}),
...(updatedPlaylist.stalkerSignature1 !== undefined
? { stalkerSignature1: updatedPlaylist.stalkerSignature1 }
: {}),
...(updatedPlaylist.stalkerSignature2 !== undefined
? { stalkerSignature2: updatedPlaylist.stalkerSignature2 }
: {}),
})
)
);
@@ -0,0 +1,652 @@
import { Injectable, inject } from '@angular/core';
import { Playlist, STALKER_REQUEST } from 'shared-interfaces';
import { DataService } from './data.service';
/**
* SHA1 hash using native Web Crypto API
* Produces correct 40-character hex hash matching real Stalker clients
*/
async function sha1(str: string): Promise<string> {
const encoder = new TextEncoder();
const data = encoder.encode(str);
const hashBuffer = await crypto.subtle.digest('SHA-1', data);
const hashArray = Array.from(new Uint8Array(hashBuffer));
return hashArray.map((b) => b.toString(16).padStart(2, '0')).join('');
}
/**
* Generates SHA1 prehash from MAC address
* This must match what real Stalker clients send
*/
async function generatePrehash(macAddress: string): Promise<string> {
// Use MAC address with colons, uppercase - this is what most clients use
const str = macAddress.toUpperCase();
return (await sha1(str)).toUpperCase();
}
/**
* Generates a random string for metrics
*/
function generateRandom(): string {
const chars = 'abcdef0123456789';
let result = '';
for (let i = 0; i < 40; i++) {
result += chars.charAt(Math.floor(Math.random() * chars.length));
}
return result;
}
/**
* Deterministic serial number - 13 hex characters
* Constant value for consistency across all sessions
*/
export const STALKER_SERIAL_NUMBER = 'BEDACD4569BAF';
export interface StalkerHandshakeResponse {
js: {
token: string;
not_valid?: number;
random?: string;
};
}
export interface StalkerProfileResponse {
js: {
id?: string;
name?: string;
mac?: string;
status?: number;
msg?: string;
block_msg?: string;
account_info?: {
login?: string;
expire_date?: number;
tariff_plan_name?: string;
status?: number;
};
};
}
/**
* Service to manage Stalker portal session tokens.
* Handles handshake authentication for full stalker portals (/stalker_portal/c URLs).
* Persists tokens during session and handles re-authentication on auth failures.
*/
@Injectable({
providedIn: 'root',
})
export class StalkerSessionService {
private dataService = inject(DataService);
// In-memory token cache for current session (keyed by playlist ID)
private tokenCache = new Map<string, string>();
// Pending authentication promises to prevent race conditions
// When multiple requests need a token simultaneously, they all wait for the same auth
private pendingAuth = new Map<
string,
Promise<{ token: string; serialNumber?: string }>
>();
/**
* 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
*/
isFullStalkerPortal(url: string): boolean {
return (
url.includes('/stalker_portal/') || url.includes('/server/load.php')
);
}
/**
* Gets the cached token for a playlist, or null if not cached
*/
getCachedToken(playlistId: string): string | null {
return this.tokenCache.get(playlistId) || null;
}
/**
* Sets a token in the cache
*/
setCachedToken(playlistId: string, token: string): void {
this.tokenCache.set(playlistId, token);
}
/**
* Clears the cached token for a playlist (e.g., on auth failure)
*/
clearCachedToken(playlistId: string): void {
this.tokenCache.delete(playlistId);
}
/**
* Performs handshake to get a session token for a full stalker portal
* Returns both the token and the random value for use in subsequent requests
*/
async performHandshake(
portalUrl: string,
macAddress: string
): Promise<{ token: string; random: string }> {
const prehash = await generatePrehash(macAddress);
const params: Record<string, string> = {
type: 'stb',
action: 'handshake',
token: '',
prehash,
JsHttpRequest: '1-xml',
};
const fullUrl = `${portalUrl}?type=stb&action=handshake&token=&prehash=${prehash}&JsHttpRequest=1-xml`;
console.log('[StalkerSession] Performing handshake...');
console.log('[StalkerSession] Handshake URL:', fullUrl);
console.log('[StalkerSession] MAC Address:', macAddress);
console.log('[StalkerSession] Prehash:', prehash);
try {
const response: StalkerHandshakeResponse =
await this.dataService.sendIpcEvent(STALKER_REQUEST, {
url: portalUrl,
macAddress,
params,
});
console.log(
'[StalkerSession] Handshake response:',
JSON.stringify(response, null, 2)
);
if (response?.js?.token) {
console.log(
'[StalkerSession] Token received:',
response.js.token.substring(0, 10) + '...'
);
console.log(
'[StalkerSession] Random received:',
response.js.random
);
return {
token: response.js.token,
random: response.js.random || generateRandom(),
};
}
console.error('[StalkerSession] No token in response');
throw new Error('Handshake failed: No token received');
} catch (error) {
console.error('[StalkerSession] Handshake error:', error);
throw error;
}
}
/**
* Gets account profile information to validate the portal and check subscription
* Based on working implementation from stalker-to-m3u repo
*/
async getProfile(
portalUrl: string,
macAddress: string,
token: string,
serialNumber: string,
handshakeRandom: string,
providedDeviceId1?: string,
providedDeviceId2?: string,
providedSignature1?: string,
providedSignature2?: 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() || '';
// Build metrics JSON matching working app
const metrics = {
mac: macAddress,
model: 'MAG250',
type: 'STB',
random: handshakeRandom,
sn: serialNumber,
};
// Generate prehash for get_profile (same as handshake)
const prehash = await generatePrehash(macAddress);
// Profile request matching working StalkerTV app
// auth_second_step=1, includes metrics, prehash, and device_id params
const params: Record<string, string> = {
type: 'stb',
action: 'get_profile',
hd: '1',
not_valid_token: '0',
video_out: 'hdmi',
auth_second_step: '1',
num_banks: '2',
metrics: JSON.stringify(metrics),
sn: serialNumber,
device_id: deviceId1,
device_id2: deviceId2,
signature: signature1,
...(signature2 ? { signature2: signature2 } : {}),
prehash: prehash,
stb_type: '',
JsHttpRequest: '1-xml',
};
console.log('[StalkerSession] Getting profile...');
console.log('[StalkerSession] Profile URL:', portalUrl);
console.log('[StalkerSession] Token:', token.substring(0, 10) + '...');
console.log('[StalkerSession] Serial Number:', serialNumber);
console.log(
'[StalkerSession] Device ID:',
deviceId1.substring(0, 16) + '...'
);
console.log(
'[StalkerSession] Signature:',
signature1 ? signature1.substring(0, 16) + '...' : '(empty)'
);
try {
const response: StalkerProfileResponse =
await this.dataService.sendIpcEvent(STALKER_REQUEST, {
url: portalUrl,
macAddress,
params,
token,
serialNumber,
});
console.log(
'[StalkerSession] Profile response:',
JSON.stringify(response, null, 2)
);
return response;
} catch (error) {
console.error('[StalkerSession] Get profile error:', error);
throw error;
}
}
/**
* Performs do_auth to authenticate the session after handshake
*/
async doAuth(
portalUrl: string,
macAddress: string,
token: string
): Promise<boolean> {
const params: Record<string, string> = {
type: 'stb',
action: 'do_auth',
login: '',
password: '',
device_id: '',
device_id2: '',
JsHttpRequest: '1-xml',
};
console.log('[StalkerSession] Performing do_auth...');
console.log('[StalkerSession] do_auth URL:', portalUrl);
console.log('[StalkerSession] Token:', token.substring(0, 10) + '...');
try {
const response = await this.dataService.sendIpcEvent(
STALKER_REQUEST,
{
url: portalUrl,
macAddress,
params,
token,
}
);
console.log(
'[StalkerSession] do_auth response:',
JSON.stringify(response, null, 2)
);
// do_auth returns { js: true } on success
if (response?.js === true) {
console.log('[StalkerSession] do_auth successful');
return true;
}
console.warn('[StalkerSession] do_auth returned:', response?.js);
return false;
} catch (error) {
console.error('[StalkerSession] do_auth error:', error);
throw error;
}
}
/**
* Performs full authentication flow: handshake -> get_profile (NO do_auth based on working traces)
* Returns the token and account info if successful
*/
async authenticate(
portalUrl: string,
macAddress: string,
serialNumber: string,
deviceId1?: string,
deviceId2?: string,
signature1?: string,
signature2?: string
): Promise<{
token: string;
accountInfo?: StalkerProfileResponse['js']['account_info'];
}> {
console.log('[StalkerSession] Starting full authentication flow...');
console.log('[StalkerSession] Portal URL:', portalUrl);
console.log('[StalkerSession] MAC Address:', macAddress);
console.log('[StalkerSession] Serial Number:', serialNumber);
console.log(
'[StalkerSession] Device ID 1:',
deviceId1
? deviceId1.substring(0, 16) + '... (provided)'
: '(will be auto-generated)'
);
console.log(
'[StalkerSession] Device ID 2:',
deviceId2
? deviceId2.substring(0, 16) + '... (provided)'
: '(will be auto-generated)'
);
console.log(
'[StalkerSession] Signature 1:',
signature1
? signature1.substring(0, 16) + '... (provided)'
: '(empty)'
);
console.log(
'[StalkerSession] Signature 2:',
signature2
? signature2.substring(0, 16) + '... (provided)'
: '(empty)'
);
// Step 1: Handshake to get token and random
console.log('[StalkerSession] Step 1: Performing handshake...');
const { token, random } = await this.performHandshake(
portalUrl,
macAddress
);
console.log(
'[StalkerSession] Step 1 complete: Token and random obtained'
);
// Step 2: Get profile to activate token and get account info
// The random from handshake must be used in auth_second_step
console.log(
'[StalkerSession] Step 2: Getting profile with handshake random (activates token)...'
);
try {
const profileResponse = await this.getProfile(
portalUrl,
macAddress,
token,
serialNumber,
random,
deviceId1,
deviceId2,
signature1,
signature2
);
// Check for profile-level errors
if (profileResponse?.js?.msg || profileResponse?.js?.block_msg) {
const errorMsg =
profileResponse.js.msg ||
profileResponse.js.block_msg ||
'Unknown profile error';
console.error('[StalkerSession] Profile error:', errorMsg);
throw new Error(`Profile error: ${errorMsg}`);
}
console.log(
'[StalkerSession] Step 2 complete: Profile obtained, token activated'
);
console.log('[StalkerSession] Authentication successful!');
return {
token,
accountInfo: profileResponse?.js?.account_info,
};
} catch (error) {
// Profile fetch failed - this is a real error, propagate it
console.error('[StalkerSession] Profile fetch failed:', error);
throw error;
}
}
/**
* Ensures a valid token exists for a playlist, performing full auth if needed
* IMPORTANT: Based on working traces, each session needs handshake + get_profile
* Returns the token to use for requests, and the serial number to store
*/
async ensureToken(
playlist: Playlist
): Promise<{ token: string | null; serialNumber?: string }> {
console.log(
'[StalkerSession] Ensuring token for playlist:',
playlist._id
);
console.log(
'[StalkerSession] Is full stalker portal:',
playlist.isFullStalkerPortal
);
// If not a full stalker portal, no token needed
if (!playlist.isFullStalkerPortal) {
console.log(
'[StalkerSession] Not a full stalker portal, no token needed'
);
return { token: null };
}
// Check in-memory cache first (valid for current session only)
const cachedToken = this.getCachedToken(playlist._id);
if (cachedToken) {
console.log(
'[StalkerSession] Using cached token:',
cachedToken.substring(0, 10) + '...'
);
return { token: cachedToken };
}
// Check if there's already a pending authentication for this playlist
// This prevents race conditions when multiple resources request a token simultaneously
const pendingPromise = this.pendingAuth.get(playlist._id);
if (pendingPromise) {
console.log(
'[StalkerSession] Waiting for pending authentication...'
);
return pendingPromise;
}
// No cached token - need to do full authentication (handshake + get_profile)
// Don't trust stored tokens as they may be from a different session
if (!playlist.portalUrl || !playlist.macAddress) {
console.error('[StalkerSession] Missing portal URL or MAC address');
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;
console.log(
'[StalkerSession] Generated new serial number:',
serialNumber
);
} else {
console.log(
'[StalkerSession] Using stored serial number:',
serialNumber
);
}
console.log(
'[StalkerSession] No cached token, performing full authentication...'
);
// 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 () => {
try {
const { token } = await this.authenticate(
playlist.portalUrl,
playlist.macAddress,
serialNumber
);
console.log(
'[StalkerSession] Authentication complete, token cached'
);
this.setCachedToken(playlist._id, token);
return { token, serialNumber };
} finally {
// Clean up pending promise regardless of success/failure
this.pendingAuth.delete(playlist._id);
}
})();
// Store the pending promise so other concurrent requests can wait on it
this.pendingAuth.set(playlist._id, authPromise);
return authPromise;
}
/**
* Checks if a response or error indicates an authorization failure
*/
private isAuthorizationError(responseOrError: unknown): boolean {
if (!responseOrError) return false;
const response = responseOrError as Record<string, unknown>;
// Convert response to string for pattern matching
const responseStr = JSON.stringify(responseOrError).toLowerCase();
// Check for "Authorization failed. XX" pattern (like "Authorization failed. 75")
if (/authorization\s*failed\.?\s*\d*/i.test(responseStr)) {
return true;
}
// Check for common auth failure indicators
const jsData = response?.['js'] as Record<string, unknown>;
const errorMessage =
(response?.['message'] as string)?.toLowerCase?.() ||
jsData?.['error']?.toString?.().toLowerCase?.() ||
jsData?.['msg']?.toString?.().toLowerCase?.() ||
'';
return (
errorMessage.includes('authorization') ||
errorMessage.includes('unauthorized') ||
errorMessage.includes('auth failed') ||
errorMessage.includes('invalid token') ||
response?.['status'] === 401 ||
jsData?.['error'] === 'Authorization failed'
);
}
/**
* Wrapper for making stalker requests with automatic token handling and retry on auth failure
* This should be used by all stalker API calls to ensure proper auth handling
*/
async makeAuthenticatedRequest<T>(
playlist: Playlist,
params: Record<string, string | number>,
retryOnAuthFailure = true
): Promise<T> {
// Get token (will wait if auth is in progress)
const { token } = await this.ensureToken(playlist);
const serialNumber = playlist.stalkerSerialNumber;
console.log(
'[StalkerSession] Making authenticated request:',
params['action']
);
try {
const response = await this.dataService.sendIpcEvent(
STALKER_REQUEST,
{
url: playlist.portalUrl,
macAddress: playlist.macAddress,
params,
token,
serialNumber,
}
);
// Check for authorization failure in response
if (this.isAuthorizationError(response)) {
console.warn(
'[StalkerSession] Authorization error detected in response:',
response
);
if (retryOnAuthFailure && playlist.isFullStalkerPortal) {
console.log(
'[StalkerSession] Clearing token and retrying with fresh auth...'
);
// Clear cached token to force re-authentication
this.clearCachedToken(playlist._id);
// Retry once with fresh authentication
return this.makeAuthenticatedRequest<T>(
playlist,
params,
false
);
}
throw new Error('Authorization failed after retry');
}
return response;
} catch (error) {
// Check if error indicates auth failure
if (
this.isAuthorizationError(error) &&
retryOnAuthFailure &&
playlist.isFullStalkerPortal
) {
console.log(
'[StalkerSession] Clearing token and retrying with fresh auth (caught error)...'
);
// Clear cached token and retry with new handshake
this.clearCachedToken(playlist._id);
return this.makeAuthenticatedRequest<T>(
playlist,
params,
false
);
}
throw error;
}
}
}
@@ -21,4 +21,9 @@ export type PlaylistMeta = Pick<
| 'macAddress'
| 'portalUrl'
| 'recentlyViewed'
| 'stalkerSerialNumber'
| 'stalkerDeviceId1'
| 'stalkerDeviceId2'
| 'stalkerSignature1'
| 'stalkerSignature2'
>;
@@ -37,4 +37,25 @@ export interface Playlist {
macAddress?: string;
portalUrl?: string;
recentlyViewed?: any[];
/** Indicates if this is a full stalker portal URL (e.g., /stalker_portal/c) requiring handshake authentication */
isFullStalkerPortal?: boolean;
/** Session token for full stalker portal authentication - persisted for session */
stalkerToken?: string;
/** Serial number for stalker portal - generated once and stored for consistency */
stalkerSerialNumber?: string;
/** Optional device ID 1 for stalker portal - if not provided, auto-generated from MAC */
stalkerDeviceId1?: string;
/** Optional device ID 2 for stalker portal - if not provided, auto-generated from MAC */
stalkerDeviceId2?: string;
/** Optional signature 1 for stalker portal - required by some portals for device verification */
stalkerSignature1?: string;
/** Optional signature 2 for stalker portal - required by some portals for device verification */
stalkerSignature2?: string;
/** Account info from get_profile call */
stalkerAccountInfo?: {
login?: string;
expireDate?: number;
tariffPlanName?: string;
status?: number;
};
}
@@ -28,6 +28,38 @@
<input matInput formControlName="macAddress" />
</mat-form-field>
}
@if (playlist.portalUrl) {
<mat-form-field class="full-width">
<mat-label>{{
'HOME.STALKER_PORTAL.SERIAL_NUMBER' | translate
}}</mat-label>
<input matInput formControlName="stalkerSerialNumber" />
</mat-form-field>
<mat-form-field class="full-width">
<mat-label>{{
'HOME.STALKER_PORTAL.DEVICE_ID_1' | translate
}}</mat-label>
<input matInput formControlName="stalkerDeviceId1" />
</mat-form-field>
<mat-form-field class="full-width">
<mat-label>{{
'HOME.STALKER_PORTAL.DEVICE_ID_2' | translate
}}</mat-label>
<input matInput formControlName="stalkerDeviceId2" />
</mat-form-field>
<mat-form-field class="full-width">
<mat-label>{{
'HOME.STALKER_PORTAL.SIGNATURE_1' | translate
}}</mat-label>
<input matInput formControlName="stalkerSignature1" />
</mat-form-field>
<mat-form-field class="full-width">
<mat-label>{{
'HOME.STALKER_PORTAL.SIGNATURE_2' | translate
}}</mat-label>
<input matInput formControlName="stalkerSignature2" />
</mat-form-field>
}
@if (playlist.serverUrl) {
<mat-form-field class="full-width">
<mat-label>{{
@@ -104,6 +104,13 @@ export class PlaylistInfoComponent {
password: new FormControl(this.playlist.password),
macAddress: new FormControl(this.playlist.macAddress),
portalUrl: new FormControl(this.playlist.portalUrl),
stalkerSerialNumber: new FormControl(
this.playlist.stalkerSerialNumber
),
stalkerDeviceId1: new FormControl(this.playlist.stalkerDeviceId1),
stalkerDeviceId2: new FormControl(this.playlist.stalkerDeviceId2),
stalkerSignature1: new FormControl(this.playlist.stalkerSignature1),
stalkerSignature2: new FormControl(this.playlist.stalkerSignature2),
});
}
@@ -231,7 +238,9 @@ export class PlaylistInfoComponent {
const success = this.clipboard.copy(url);
if (success) {
this.snackBar.open(
this.translate.instant('HOME.PLAYLISTS.INFO_DIALOG.URL_COPIED'),
this.translate.instant(
'HOME.PLAYLISTS.INFO_DIALOG.URL_COPIED'
),
this.translate.instant('CLOSE'),
{ duration: 2000 }
);