mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-11 19:46:16 -08:00
feat(stalker): feat: add auth for stalker portal, expend portal import UI
Refactor stalker-series-view template to guard fields with conditionals so that only available metadata is displayed. Replace selectedItem() with displayItem() to use the updated selection accessor. Add checks for director, actors, year/release date, genre/genres_str, imdb and kinopoisk ratings to avoid showing empty labels. Enhance stalker-portal-import component by adding reactive form fields for serial/device IDs and signatures, importing MatProgressSpinnerModule, and wiring TranslateService and StalkerSessionService into the component. Include a small loading-container style. These changes prepare the UI for improved import UX and safer rendering of optional series metadata.
This commit is contained in:
1 parent
6c17980175
commit
61eef5e00d
20 files changed
+1763
-256
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, EventEmitter, Output, inject, 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 { DataService, 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,6 +41,12 @@ import { v4 as uuid } from 'uuid';
|
||||
form {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.loading-container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
`,
|
||||
],
|
||||
})
|
||||
@@ -49,6 +58,11 @@ export class StalkerPortalImportComponent {
|
||||
_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('', [
|
||||
@@ -60,37 +74,182 @@ export class StalkerPortalImportComponent {
|
||||
});
|
||||
|
||||
dataService = inject(DataService);
|
||||
stalkerSessionService = inject(StalkerSessionService);
|
||||
store = inject(Store);
|
||||
snackBar = inject(MatSnackBar);
|
||||
translate = inject(TranslateService);
|
||||
|
||||
isLoading = signal(false);
|
||||
|
||||
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();
|
||||
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() || this.generateSerialNumber();
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates a random serial number - 13 hex characters
|
||||
*/
|
||||
private generateSerialNumber(): string {
|
||||
const chars = '0123456789ABCDEF';
|
||||
let result = '';
|
||||
for (let i = 0; i < 13; i++) {
|
||||
result += chars.charAt(Math.floor(Math.random() * chars.length));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
<div class="container">
|
||||
@if (selectedItem(); as serial) {
|
||||
@if (displayItem(); as serial) {
|
||||
<div class="image">
|
||||
@if (serial.info.movie_image) {
|
||||
<img
|
||||
@@ -20,32 +20,44 @@
|
||||
<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"
|
||||
|
||||
@@ -1,10 +1,16 @@
|
||||
import { Component, inject } from '@angular/core';
|
||||
import { Component, computed, inject, input } from '@angular/core';
|
||||
import { MatButton } from '@angular/material/button';
|
||||
import { MatDivider } from '@angular/material/divider';
|
||||
import { TranslatePipe } from '@ngx-translate/core';
|
||||
import { FavoritesButtonComponent } from '../favorites-button/favorites-button.component';
|
||||
import { StalkerStore } from '../stalker.store';
|
||||
|
||||
/**
|
||||
* Component for displaying series/episodes for Stalker portal content.
|
||||
* Supports two 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
|
||||
*/
|
||||
@Component({
|
||||
selector: 'app-stalker-series-view',
|
||||
templateUrl: './stalker-series-view.component.html',
|
||||
@@ -14,11 +20,42 @@ import { StalkerStore } from '../stalker.store';
|
||||
export class StalkerSeriesViewComponent {
|
||||
readonly stalkerStore = inject(StalkerStore);
|
||||
|
||||
/**
|
||||
* 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;
|
||||
readonly seasonsData = this.stalkerStore.getSerialSeasonsResource;
|
||||
|
||||
/**
|
||||
* For VOD with embedded series, we create a single "season" with the episodes
|
||||
* For regular series, we use the API-fetched seasons
|
||||
*/
|
||||
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,
|
||||
}];
|
||||
}
|
||||
// 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();
|
||||
});
|
||||
|
||||
playEpisodeClicked(episode: any, cmd: string) {
|
||||
const item = this.selectedItem();
|
||||
const item = this.displayItem();
|
||||
this.stalkerStore.createLinkToPlayVod(
|
||||
cmd,
|
||||
item.info.name,
|
||||
@@ -28,11 +65,12 @@ export class StalkerSeriesViewComponent {
|
||||
}
|
||||
|
||||
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',
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
@@ -81,52 +82,155 @@ function sortByNumericValue(array: StalkerSeason[]): StalkerSeason[] {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 +243,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 +357,63 @@ 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);
|
||||
},
|
||||
}),
|
||||
})
|
||||
),
|
||||
withComputed((store) => ({
|
||||
getTotalPages: computed(() => {
|
||||
return Math.ceil(store.totalCount() / store.limit());
|
||||
@@ -292,6 +462,7 @@ export const StalkerStore = signalStore(
|
||||
dataService = inject(DataService),
|
||||
playerService = inject(PlayerService),
|
||||
playlistService = inject(PlaylistsService),
|
||||
stalkerSession = inject(StalkerSessionService),
|
||||
snackBar = inject(MatSnackBar),
|
||||
translate = inject(TranslateService)
|
||||
) => ({
|
||||
@@ -373,28 +544,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 +645,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 +720,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);
|
||||
|
||||
+17
-12
@@ -1,18 +1,23 @@
|
||||
@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 {
|
||||
<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 />
|
||||
}
|
||||
|
||||
+5
-1
@@ -63,10 +63,14 @@ 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,
|
||||
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,
|
||||
|
||||
@@ -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,9 @@
|
||||
"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"
|
||||
},
|
||||
"INFORMATION": "Information",
|
||||
"REMOTE_CONTROL": {
|
||||
|
||||
@@ -82,3 +82,7 @@ textarea,
|
||||
.mat-drawer-inner-container {
|
||||
overflow: hidden !important;
|
||||
}
|
||||
|
||||
.cdk-overlay-backdrop {
|
||||
backdrop-filter: blur(4px);
|
||||
}
|
||||
Vendored
+6
@@ -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;
|
||||
|
||||
@@ -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,663 @@
|
||||
import { Injectable, inject } from '@angular/core';
|
||||
import { DataService } from './data.service';
|
||||
import { STALKER_REQUEST, Playlist } from 'shared-interfaces';
|
||||
|
||||
/**
|
||||
* Pure JavaScript SHA1 implementation
|
||||
* Produces correct 40-character hex hash matching real Stalker clients
|
||||
*/
|
||||
function sha1(str: string): string {
|
||||
function rotl(n: number, s: number): number {
|
||||
return (n << s) | (n >>> (32 - s));
|
||||
}
|
||||
|
||||
function toHex(n: number): string {
|
||||
let hex = '';
|
||||
for (let i = 7; i >= 0; i--) {
|
||||
hex += ((n >>> (i * 4)) & 0xf).toString(16);
|
||||
}
|
||||
return hex;
|
||||
}
|
||||
|
||||
// Convert string to bytes
|
||||
const bytes: number[] = [];
|
||||
for (let i = 0; i < str.length; i++) {
|
||||
bytes.push(str.charCodeAt(i) & 0xff);
|
||||
}
|
||||
|
||||
// Pre-processing: adding padding bits
|
||||
const originalLength = bytes.length * 8;
|
||||
bytes.push(0x80);
|
||||
while ((bytes.length % 64) !== 56) {
|
||||
bytes.push(0);
|
||||
}
|
||||
|
||||
// Append original length in bits as 64-bit big-endian
|
||||
for (let i = 7; i >= 0; i--) {
|
||||
bytes.push((originalLength >>> (i * 8)) & 0xff);
|
||||
}
|
||||
|
||||
// Initialize hash values
|
||||
let h0 = 0x67452301;
|
||||
let h1 = 0xefcdab89;
|
||||
let h2 = 0x98badcfe;
|
||||
let h3 = 0x10325476;
|
||||
let h4 = 0xc3d2e1f0;
|
||||
|
||||
// Process each 64-byte chunk
|
||||
for (let chunkStart = 0; chunkStart < bytes.length; chunkStart += 64) {
|
||||
const w: number[] = [];
|
||||
|
||||
// Break chunk into sixteen 32-bit big-endian words
|
||||
for (let i = 0; i < 16; i++) {
|
||||
w[i] = (bytes[chunkStart + i * 4] << 24) |
|
||||
(bytes[chunkStart + i * 4 + 1] << 16) |
|
||||
(bytes[chunkStart + i * 4 + 2] << 8) |
|
||||
(bytes[chunkStart + i * 4 + 3]);
|
||||
}
|
||||
|
||||
// Extend to 80 words
|
||||
for (let i = 16; i < 80; i++) {
|
||||
w[i] = rotl(w[i - 3] ^ w[i - 8] ^ w[i - 14] ^ w[i - 16], 1);
|
||||
}
|
||||
|
||||
let a = h0, b = h1, c = h2, d = h3, e = h4;
|
||||
|
||||
for (let i = 0; i < 80; i++) {
|
||||
let f: number, k: number;
|
||||
if (i < 20) {
|
||||
f = (b & c) | ((~b) & d);
|
||||
k = 0x5a827999;
|
||||
} else if (i < 40) {
|
||||
f = b ^ c ^ d;
|
||||
k = 0x6ed9eba1;
|
||||
} else if (i < 60) {
|
||||
f = (b & c) | (b & d) | (c & d);
|
||||
k = 0x8f1bbcdc;
|
||||
} else {
|
||||
f = b ^ c ^ d;
|
||||
k = 0xca62c1d6;
|
||||
}
|
||||
|
||||
const temp = (rotl(a, 5) + f + e + k + w[i]) >>> 0;
|
||||
e = d;
|
||||
d = c;
|
||||
c = rotl(b, 30) >>> 0;
|
||||
b = a;
|
||||
a = temp;
|
||||
}
|
||||
|
||||
h0 = (h0 + a) >>> 0;
|
||||
h1 = (h1 + b) >>> 0;
|
||||
h2 = (h2 + c) >>> 0;
|
||||
h3 = (h3 + d) >>> 0;
|
||||
h4 = (h4 + e) >>> 0;
|
||||
}
|
||||
|
||||
return toHex(h0) + toHex(h1) + toHex(h2) + toHex(h3) + toHex(h4);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates SHA1 prehash from MAC address
|
||||
* This must match what real Stalker clients send
|
||||
*/
|
||||
function generatePrehash(macAddress: string): string {
|
||||
// Use MAC address with colons, uppercase - this is what most clients use
|
||||
const str = macAddress.toUpperCase();
|
||||
return 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;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates a random serial number - 13 hex characters
|
||||
* Note: This is auto-generated and stored in the playlist for consistency
|
||||
*/
|
||||
function generateSerialNumber(): string {
|
||||
const chars = '0123456789ABCDEF';
|
||||
let result = '';
|
||||
for (let i = 0; i < 13; i++) {
|
||||
result += chars.charAt(Math.floor(Math.random() * chars.length));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
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 = 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 = 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 = generateSerialNumber();
|
||||
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,
|
||||
playlist.stalkerDeviceId1,
|
||||
playlist.stalkerDeviceId2,
|
||||
playlist.stalkerSignature1,
|
||||
playlist.stalkerSignature2
|
||||
);
|
||||
console.log('[StalkerSession] Authentication complete, token cached');
|
||||
this.setCachedToken(playlist._id, token);
|
||||
return { token, serialNumber };
|
||||
} catch (error) {
|
||||
// Re-throw the error after cleanup
|
||||
throw error;
|
||||
} 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;
|
||||
}
|
||||
|
||||
/**
|
||||
* Wrapper for making stalker requests with automatic token handling and retry on auth failure
|
||||
*/
|
||||
async makeRequest<T>(
|
||||
playlist: Playlist,
|
||||
params: Record<string, string>,
|
||||
retryOnAuthFailure = true
|
||||
): Promise<T> {
|
||||
const { token } = await this.ensureToken(playlist);
|
||||
|
||||
try {
|
||||
const response = await this.dataService.sendIpcEvent(
|
||||
STALKER_REQUEST,
|
||||
{
|
||||
url: playlist.portalUrl,
|
||||
macAddress: playlist.macAddress,
|
||||
params,
|
||||
token,
|
||||
}
|
||||
);
|
||||
|
||||
// Check for authorization failure in response
|
||||
if (this.isAuthorizationError(response)) {
|
||||
if (retryOnAuthFailure && playlist.isFullStalkerPortal) {
|
||||
// Clear cached token and retry with new handshake
|
||||
this.clearCachedToken(playlist._id);
|
||||
return this.makeRequest<T>(playlist, params, false);
|
||||
}
|
||||
throw new Error('Authorization failed');
|
||||
}
|
||||
|
||||
return response;
|
||||
} catch (error) {
|
||||
// Check if error indicates auth failure
|
||||
if (this.isAuthorizationError(error) && retryOnAuthFailure && playlist.isFullStalkerPortal) {
|
||||
// Clear cached token and retry with new handshake
|
||||
this.clearCachedToken(playlist._id);
|
||||
return this.makeRequest<T>(playlist, params, false);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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']);
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
};
|
||||
}
|
||||
+32
@@ -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>{{
|
||||
|
||||
+10
-1
@@ -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 }
|
||||
);
|
||||
|
||||
Reference in new issue
Block a user