diff --git a/apps/electron-backend/src/app/api/main.preload.ts b/apps/electron-backend/src/app/api/main.preload.ts index bf51bc1ee..a9d4208d9 100644 --- a/apps/electron-backend/src/app/api/main.preload.ts +++ b/apps/electron-backend/src/app/api/main.preload.ts @@ -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; + token?: string; }) => ipcRenderer.invoke('STALKER_REQUEST', payload), xtreamRequest: (payload: { url: string; params: Record }) => ipcRenderer.invoke('XTREAM_REQUEST', payload), diff --git a/apps/electron-backend/src/app/events/stalker.events.ts b/apps/electron-backend/src/app/events/stalker.events.ts index 1d4dc6462..3c2613478 100644 --- a/apps/electron-backend/src/app/events/stalker.events.ts +++ b/apps/electron-backend/src/app/events/stalker.events.ts @@ -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; + 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 = { + 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 && diff --git a/apps/web/src/app/home/stalker-portal-import/stalker-portal-import.component.html b/apps/web/src/app/home/stalker-portal-import/stalker-portal-import.component.html index d7fddc2e4..1b7b704d6 100644 --- a/apps/web/src/app/home/stalker-portal-import/stalker-portal-import.component.html +++ b/apps/web/src/app/home/stalker-portal-import/stalker-portal-import.component.html @@ -31,6 +31,71 @@ formControlName="macAddress" /> + + {{ + 'HOME.STALKER_PORTAL.SERIAL_NUMBER' | translate + }} + + {{ 'HOME.STALKER_PORTAL.SERIAL_NUMBER_HINT' | translate }} + + + {{ + 'HOME.STALKER_PORTAL.DEVICE_ID_1' | translate + }} + + {{ 'HOME.STALKER_PORTAL.DEVICE_ID_HINT' | translate }} + + + {{ + 'HOME.STALKER_PORTAL.DEVICE_ID_2' | translate + }} + + {{ 'HOME.STALKER_PORTAL.DEVICE_ID_HINT' | translate }} + + + {{ + 'HOME.STALKER_PORTAL.SIGNATURE_1' | translate + }} + + {{ 'HOME.STALKER_PORTAL.SIGNATURE_HINT' | translate }} + + + {{ + 'HOME.STALKER_PORTAL.SIGNATURE_2' | translate + }} + + {{ 'HOME.STALKER_PORTAL.SIGNATURE_HINT' | translate }} + - + @if (isLoading()) { +
+ + {{ 'HOME.STALKER_PORTAL.VALIDATING' | translate }} +
+ } @else { + + } diff --git a/apps/web/src/app/home/stalker-portal-import/stalker-portal-import.component.ts b/apps/web/src/app/home/stalker-portal-import/stalker-portal-import.component.ts index f0c4d1a90..58ff6b25e 100644 --- a/apps/web/src/app/home/stalker-portal-import/stalker-portal-import.component.ts +++ b/apps/web/src/app/home/stalker-portal-import/stalker-portal-import.component.ts @@ -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; + } } diff --git a/apps/web/src/app/stalker/stalker-search/stalker-search.component.ts b/apps/web/src/app/stalker/stalker-search/stalker-search.component.ts index 3f2f70073..db4760cb6 100644 --- a/apps/web/src/app/stalker/stalker-search/stalker-search.component.ts +++ b/apps/web/src/app/stalker/stalker-search/stalker-search.component.ts @@ -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) { diff --git a/apps/web/src/app/stalker/stalker-series-view/stalker-series-view.component.html b/apps/web/src/app/stalker/stalker-series-view/stalker-series-view.component.html index 92f488505..4cc7e2886 100644 --- a/apps/web/src/app/stalker/stalker-series-view/stalker-series-view.component.html +++ b/apps/web/src/app/stalker/stalker-series-view/stalker-series-view.component.html @@ -1,6 +1,6 @@ @let seasons = seasonsData;
- @if (selectedItem(); as serial) { + @if (displayItem(); as serial) {
@if (serial.info.movie_image) { {{ serial.info.description }}
-
-
{{ 'XTREAM.DIRECTOR' | translate }}:
- {{ serial.info.director }} -
-
-
{{ 'XTREAM.ACTORS' | translate }}:
- {{ serial.info.actors }} -
-
-
{{ 'XTREAM.YEAR' | translate }}:
- {{ serial.info.releasedate }} -
-
-
{{ 'XTREAM.GENRE' | translate }}:
- {{ serial.info.genre }} -
-
-
{{ 'XTREAM.IMDB_RATING' | translate }}:
- {{ serial.info.rating_imdb }} -
-
-
- {{ 'XTREAM.KINOPOISK_RATING' | translate }}: + @if (serial.info.director) { +
+
{{ 'XTREAM.DIRECTOR' | translate }}:
+ {{ serial.info.director }}
- {{ serial.info.rating_kinopoisk }} -
+ } + @if (serial.info.actors) { +
+
{{ 'XTREAM.ACTORS' | translate }}:
+ {{ serial.info.actors }} +
+ } + @if (serial.info.releasedate || serial.info.year) { +
+
{{ 'XTREAM.YEAR' | translate }}:
+ {{ serial.info.releasedate || serial.info.year }} +
+ } + @if (serial.info.genre || serial.info.genres_str) { +
+
{{ 'XTREAM.GENRE' | translate }}:
+ {{ serial.info.genre || serial.info.genres_str }} +
+ } + @if (serial.info.rating_imdb) { +
+
{{ 'XTREAM.IMDB_RATING' | translate }}:
+ {{ serial.info.rating_imdb }} +
+ } + @if (serial.info.rating_kinopoisk) { +
+
+ {{ 'XTREAM.KINOPOISK_RATING' | translate }}: +
+ {{ serial.info.rating_kinopoisk }} +
+ }
(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', }); diff --git a/apps/web/src/app/stalker/stalker.store.ts b/apps/web/src/app/stalker/stalker.store.ts index 62335697b..ec7919cc6 100644 --- a/apps/web/src/app/stalker/stalker.store.ts +++ b/apps/web/src/app/stalker/stalker.store.ts @@ -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 + ) { + // 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 = { + 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 { + 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); diff --git a/apps/web/src/app/xtream-tauri/category-content-view/category-content-view.component.html b/apps/web/src/app/xtream-tauri/category-content-view/category-content-view.component.html index 9b936b05a..5966d0972 100644 --- a/apps/web/src/app/xtream-tauri/category-content-view/category-content-view.component.html +++ b/apps/web/src/app/xtream-tauri/category-content-view/category-content-view.component.html @@ -1,18 +1,23 @@ @if (selectedCategory()) { @if (isStalker && selectedItem()) { @if (contentType === 'vod') { - + @if (selectedItem().series?.length > 0) { + + + } @else { + + } } @else if (contentType === 'series') { } diff --git a/apps/web/src/app/xtream-tauri/category-content-view/category-content-view.component.ts b/apps/web/src/app/xtream-tauri/category-content-view/category-content-view.component.ts index a3d087914..8bfa21e41 100644 --- a/apps/web/src/app/xtream-tauri/category-content-view/category-content-view.component.ts +++ b/apps/web/src/app/xtream-tauri/category-content-view/category-content-view.component.ts @@ -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, diff --git a/apps/web/src/assets/i18n/en.json b/apps/web/src/assets/i18n/en.json index 914f0e1b8..408e4952d 100644 --- a/apps/web/src/assets/i18n/en.json +++ b/apps/web/src/assets/i18n/en.json @@ -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": { diff --git a/apps/web/src/styles.scss b/apps/web/src/styles.scss index bf39f175d..a516f7251 100644 --- a/apps/web/src/styles.scss +++ b/apps/web/src/styles.scss @@ -82,3 +82,7 @@ textarea, .mat-drawer-inner-container { overflow: hidden !important; } + +.cdk-overlay-backdrop { + backdrop-filter: blur(4px); +} diff --git a/global.d.ts b/global.d.ts index 4f3adfed2..e4f957790 100644 --- a/global.d.ts +++ b/global.d.ts @@ -67,12 +67,18 @@ declare global { limit?: number ) => Promise; updateSettings: (settings: any) => Promise; + getAiSettings: () => Promise<{ + aiProvider: string; + aiModelName: string; + aiApiKey: string; + }>; setMpvPlayerPath: (mpvPlayerPath: string) => Promise; setVlcPlayerPath: (vlcPlayerPath: string) => Promise; stalkerRequest: (payload: { url: string; macAddress: string; params: Record; + token?: string; }) => Promise; xtreamRequest: (payload: { url: string; diff --git a/libs/services/src/index.ts b/libs/services/src/index.ts index 35ee1b2d7..bd835cdde 100644 --- a/libs/services/src/index.ts +++ b/libs/services/src/index.ts @@ -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'; diff --git a/libs/services/src/lib/playlists.service.ts b/libs/services/src/lib/playlists.service.ts index 947e4c978..fecbd6e3e 100644 --- a/libs/services/src/lib/playlists.service.ts +++ b/libs/services/src/lib/playlists.service.ts @@ -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 } + : {}), }) ) ); diff --git a/libs/services/src/lib/stalker-session.service.ts b/libs/services/src/lib/stalker-session.service.ts new file mode 100644 index 000000000..c0615ee39 --- /dev/null +++ b/libs/services/src/lib/stalker-session.service.ts @@ -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(); + + // 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>(); + + /** + * 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 = { + 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 { + // 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 = { + 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 { + const params: Record = { + 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( + playlist: Playlist, + params: Record, + retryOnAuthFailure = true + ): Promise { + 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(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(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; + + // 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; + 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( + playlist: Playlist, + params: Record, + retryOnAuthFailure = true + ): Promise { + // 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(playlist, params, false); + } + + throw new Error('Authorization failed after retry'); + } + + return response; + } +} diff --git a/libs/shared/interfaces/src/lib/playlist-meta.type.ts b/libs/shared/interfaces/src/lib/playlist-meta.type.ts index db2026c01..455dbe9b6 100644 --- a/libs/shared/interfaces/src/lib/playlist-meta.type.ts +++ b/libs/shared/interfaces/src/lib/playlist-meta.type.ts @@ -21,4 +21,9 @@ export type PlaylistMeta = Pick< | 'macAddress' | 'portalUrl' | 'recentlyViewed' + | 'stalkerSerialNumber' + | 'stalkerDeviceId1' + | 'stalkerDeviceId2' + | 'stalkerSignature1' + | 'stalkerSignature2' >; diff --git a/libs/shared/interfaces/src/lib/playlist.interface.ts b/libs/shared/interfaces/src/lib/playlist.interface.ts index c1d87e03d..cdac84c25 100644 --- a/libs/shared/interfaces/src/lib/playlist.interface.ts +++ b/libs/shared/interfaces/src/lib/playlist.interface.ts @@ -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; + }; } diff --git a/libs/ui/components/src/lib/recent-playlists/playlist-info/playlist-info.component.html b/libs/ui/components/src/lib/recent-playlists/playlist-info/playlist-info.component.html index 4a8b850e3..d47228223 100644 --- a/libs/ui/components/src/lib/recent-playlists/playlist-info/playlist-info.component.html +++ b/libs/ui/components/src/lib/recent-playlists/playlist-info/playlist-info.component.html @@ -28,6 +28,38 @@ } + @if (playlist.portalUrl) { + + {{ + 'HOME.STALKER_PORTAL.SERIAL_NUMBER' | translate + }} + + + + {{ + 'HOME.STALKER_PORTAL.DEVICE_ID_1' | translate + }} + + + + {{ + 'HOME.STALKER_PORTAL.DEVICE_ID_2' | translate + }} + + + + {{ + 'HOME.STALKER_PORTAL.SIGNATURE_1' | translate + }} + + + + {{ + 'HOME.STALKER_PORTAL.SIGNATURE_2' | translate + }} + + + } @if (playlist.serverUrl) { {{ diff --git a/libs/ui/components/src/lib/recent-playlists/playlist-info/playlist-info.component.ts b/libs/ui/components/src/lib/recent-playlists/playlist-info/playlist-info.component.ts index 61a674046..86874c4ab 100644 --- a/libs/ui/components/src/lib/recent-playlists/playlist-info/playlist-info.component.ts +++ b/libs/ui/components/src/lib/recent-playlists/playlist-info/playlist-info.component.ts @@ -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 } );