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..57fdf9b3f 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, inject, output, signal } from '@angular/core'; import { FormControl, FormGroup, @@ -9,10 +9,12 @@ import { import { MatButtonModule } from '@angular/material/button'; import { MatFormFieldModule } from '@angular/material/form-field'; import { MatInputModule } from '@angular/material/input'; +import { MatProgressSpinnerModule } from '@angular/material/progress-spinner'; +import { MatSnackBar } from '@angular/material/snack-bar'; import { Store } from '@ngrx/store'; -import { TranslatePipe } from '@ngx-translate/core'; +import { TranslatePipe, TranslateService } from '@ngx-translate/core'; import { PlaylistActions } from 'm3u-state'; -import { DataService } from 'services'; +import { STALKER_SERIAL_NUMBER, StalkerSessionService } from 'services'; import { Playlist } from 'shared-interfaces'; import { v4 as uuid } from 'uuid'; @@ -22,6 +24,7 @@ import { v4 as uuid } from 'uuid'; MatButtonModule, MatFormFieldModule, MatInputModule, + MatProgressSpinnerModule, ReactiveFormsModule, TranslatePipe, ], @@ -38,17 +41,28 @@ import { v4 as uuid } from 'uuid'; form { width: 100%; } + + .loading-container { + display: flex; + align-items: center; + gap: 8px; + } `, ], }) export class StalkerPortalImportComponent { - @Output() addClicked = new EventEmitter(); - URL_REGEX = /^(http|https|file):\/\/[^ "]+$/; + readonly addClicked = output(); + readonly URL_REGEX = /^(http|https|file):\/\/[^ "]+$/; - form = new FormGroup({ + readonly form = new FormGroup({ _id: new FormControl(uuid()), title: new FormControl('', [Validators.required]), macAddress: new FormControl('', [Validators.required]), + serialNumber: new FormControl(''), + deviceId1: new FormControl(''), + deviceId2: new FormControl(''), + signature1: new FormControl(''), + signature2: new FormControl(''), password: new FormControl(''), username: new FormControl(''), portalUrl: new FormControl('', [ @@ -59,38 +73,238 @@ export class StalkerPortalImportComponent { userAgent: new FormControl(''), }); - dataService = inject(DataService); - store = inject(Store); + private readonly stalkerSessionService = inject(StalkerSessionService); + private readonly store = inject(Store); + private readonly snackBar = inject(MatSnackBar); + readonly translate = inject(TranslateService); - addPlaylist() { - this.form.value.portalUrl = this.transformPortalUrl( - this.form.value.portalUrl - ); - this.store.dispatch( - PlaylistActions.addPlaylist({ playlist: this.form.value as Playlist }) - ); - this.addClicked.emit(); + readonly isLoading = signal(false); + + async addPlaylist() { + if (!this.form.valid || this.isLoading()) { + return; + } + + this.isLoading.set(true); + + try { + const originalUrl = this.form.value.portalUrl; + const transformedUrl = this.transformPortalUrl(originalUrl); + const isFullStalkerPortal = + this.isFullStalkerPortalUrl(originalUrl); + + console.log('[StalkerImport] Starting import process'); + console.log('[StalkerImport] Original URL:', originalUrl); + console.log('[StalkerImport] Transformed URL:', transformedUrl); + console.log( + '[StalkerImport] Is Full Stalker Portal:', + isFullStalkerPortal + ); + console.log( + '[StalkerImport] MAC Address:', + this.form.value.macAddress + ); + + let stalkerToken: string | undefined; + let stalkerAccountInfo: Playlist['stalkerAccountInfo'] | undefined; + let stalkerSerialNumber: string | undefined; + let stalkerDeviceId1: string | undefined; + let stalkerDeviceId2: string | undefined; + let stalkerSignature1: string | undefined; + let stalkerSignature2: string | undefined; + + // For full stalker portal URLs, perform handshake and get profile + if (isFullStalkerPortal) { + console.log( + '[StalkerImport] Full stalker portal detected, starting authentication...' + ); + + // Use provided serial number or generate a new one + stalkerSerialNumber = + this.form.value.serialNumber?.trim() || + STALKER_SERIAL_NUMBER; + console.log( + '[StalkerImport] Using serial number:', + stalkerSerialNumber, + this.form.value.serialNumber?.trim() + ? '(user provided)' + : '(auto-generated)' + ); + + // Use provided device IDs if available (64 hex chars each) + stalkerDeviceId1 = + this.form.value.deviceId1?.trim() || undefined; + stalkerDeviceId2 = + this.form.value.deviceId2?.trim() || undefined; + stalkerSignature1 = + this.form.value.signature1?.trim() || undefined; + stalkerSignature2 = + this.form.value.signature2?.trim() || undefined; + console.log( + '[StalkerImport] Device ID 1:', + stalkerDeviceId1 + ? stalkerDeviceId1.substring(0, 16) + + '... (user provided)' + : '(auto-generated)' + ); + console.log( + '[StalkerImport] Device ID 2:', + stalkerDeviceId2 + ? stalkerDeviceId2.substring(0, 16) + + '... (user provided)' + : '(auto-generated)' + ); + console.log( + '[StalkerImport] Signature 1:', + stalkerSignature1 + ? stalkerSignature1.substring(0, 16) + + '... (user provided)' + : '(not provided)' + ); + console.log( + '[StalkerImport] Signature 2:', + stalkerSignature2 + ? stalkerSignature2.substring(0, 16) + + '... (user provided)' + : '(not provided)' + ); + + try { + const authResult = + await this.stalkerSessionService.authenticate( + transformedUrl, + this.form.value.macAddress, + stalkerSerialNumber, + stalkerDeviceId1, + stalkerDeviceId2 + ); + + stalkerToken = authResult.token; + console.log( + '[StalkerImport] Authentication successful, token received:', + stalkerToken?.substring(0, 10) + '...' + ); + + if (authResult.accountInfo) { + stalkerAccountInfo = { + login: authResult.accountInfo.login, + expireDate: authResult.accountInfo.expire_date, + tariffPlanName: + authResult.accountInfo.tariff_plan_name, + status: authResult.accountInfo.status, + }; + console.log( + '[StalkerImport] Account info received:', + stalkerAccountInfo + ); + } + + // Show success notification with account info if available + if (stalkerAccountInfo?.expireDate) { + const expireDate = new Date( + stalkerAccountInfo.expireDate * 1000 + ); + this.snackBar.open( + `Portal validated. Expires: ${expireDate.toLocaleDateString()}`, + null, + { duration: 3000 } + ); + } + } catch (error) { + console.error( + '[StalkerImport] Authentication failed:', + error + ); + this.snackBar.open( + 'Failed to authenticate with portal. Please check URL and MAC address.', + null, + { duration: 5000 } + ); + this.isLoading.set(false); + return; + } + } else { + console.log( + '[StalkerImport] Simple portal URL, skipping authentication' + ); + } + + const playlist: Playlist = { + ...this.form.value, + portalUrl: transformedUrl, + isFullStalkerPortal, + stalkerToken, + stalkerAccountInfo, + stalkerSerialNumber, + stalkerDeviceId1, + stalkerDeviceId2, + stalkerSignature1, + stalkerSignature2, + } as Playlist; + + console.log('[StalkerImport] Creating playlist with config:', { + portalUrl: playlist.portalUrl, + isFullStalkerPortal: playlist.isFullStalkerPortal, + hasToken: !!playlist.stalkerToken, + }); + + this.store.dispatch(PlaylistActions.addPlaylist({ playlist })); + this.addClicked.emit(); + } finally { + this.isLoading.set(false); + } } - transformPortalUrl(url: string) { - // if the url ends with "/c" it should be to end with "/portal.php" + /** + * Checks if the URL is a full stalker portal URL that requires handshake authentication + * Pattern: example.com/stalker_portal/c or example.com/stalker_portal/... + */ + isFullStalkerPortalUrl(url: string): boolean { + return url.includes('/stalker_portal'); + } + + /** + * Transforms the portal URL to the correct API endpoint + * - Simple URL (example.com/c) -> example.com/portal.php + * - Full stalker portal (example.com/stalker_portal/c) -> example.com/stalker_portal/server/load.php + */ + transformPortalUrl(url: string): string { + // Remove trailing slashes + url = url.replace(/\/+$/, ''); + + // Case 1: Simple URL ending with /c -> convert to /portal.php if (url.endsWith('/c')) { - return url.replace('/c', '/portal.php'); + // Check if it's a full stalker portal URL + if (url.includes('/stalker_portal')) { + // example.com/stalker_portal/c -> example.com/stalker_portal/server/load.php + return url.replace( + /\/stalker_portal\/c$/, + '/stalker_portal/server/load.php' + ); + } + // Simple URL: example.com/c -> example.com/portal.php + return url.replace(/\/c$/, '/portal.php'); } - if (url.endsWith('/c/')) { - return url.replace('/c/', '/portal.php'); + // Case 2: Full stalker portal URL without /c at the end + if ( + url.includes('/stalker_portal') && + !url.includes('/server/load.php') + ) { + // example.com/stalker_portal -> example.com/stalker_portal/server/load.php + if (url.endsWith('/stalker_portal')) { + return url + '/server/load.php'; + } + // If it has other path segments after /stalker_portal, append server/load.php + if (!url.endsWith('/load.php')) { + return url.replace( + /\/stalker_portal(\/.*)?$/, + '/stalker_portal/server/load.php' + ); + } } - // if the url ends with "/stalker_portal" it should be extended to "/stalker_portal/server/load.php" - if (url.endsWith('/stalker_portal')) { - return url.replace( - '/stalker_portal/c', - '/stalker_portal/server/load.php' - ); - } - - // otherwise keep the provided url + // Otherwise keep the provided url return url; } } 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..d10cfe418 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; +@let seasons = seasonsData();
- @if (selectedItem(); as serial) { + @if (displayItem(); as serial) {
@if (serial.info.movie_image) { Series Cover } @else {
@@ -20,32 +21,48 @@
{{ 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 }} +
+ }
}
+ + +@if (isVodSeries() && isVodSeriesSeasonsLoading()) { +
+ + {{ 'PORTALS.LOADING_SEASONS' | translate }} +
+} + + +@if (!isVodSeries() && isSerialSeasonsLoading()) { +
+ + {{ 'PORTALS.LOADING_SEASONS' | translate }} +
+} +
- @for (item of seasons(); track $index) { - -

{{ item.name }}

-
- @for (episode of item.series; track $index) { - + @if (isVodSeries()) { + + @for (item of vodSeriesSeasons(); track item.id) { + +
+

{{ item.name }}

+ {{ + item.isExpanded ? '▼' : '►' + }} +
+ + @if (item.isExpanded) { + @if (item.isLoading) { +
+ +
+ } @else { +
+ @for ( + episode of item.episodes; + track episode.id || $index + ) { + + } + @if (!item.episodes || item.episodes.length === 0) { + {{ + 'PORTALS.NO_EPISODES' | translate + }} + } +
+ } } -
+ } + } @else { + + @for (item of seasons; track item.id || $index) { + +

{{ item.name }}

+
+ @for (episode of $any(item).series; track $index) { + + } +
+ } }
diff --git a/apps/web/src/app/stalker/stalker-series-view/stalker-series-view.component.ts b/apps/web/src/app/stalker/stalker-series-view/stalker-series-view.component.ts index 7e694cf7a..067dca687 100644 --- a/apps/web/src/app/stalker/stalker-series-view/stalker-series-view.component.ts +++ b/apps/web/src/app/stalker/stalker-series-view/stalker-series-view.component.ts @@ -1,24 +1,220 @@ -import { Component, inject } from '@angular/core'; +import { + Component, + computed, + effect, + inject, + input, + signal, +} from '@angular/core'; import { MatButton } from '@angular/material/button'; import { MatDivider } from '@angular/material/divider'; +import { MatProgressSpinner } from '@angular/material/progress-spinner'; import { TranslatePipe } from '@ngx-translate/core'; import { FavoritesButtonComponent } from '../favorites-button/favorites-button.component'; import { StalkerStore } from '../stalker.store'; +/** + * VOD series season with episodes loaded dynamically + */ +interface VodSeriesSeason { + id: string; + video_id: string; + name: string; + season_number: string; + episodes?: any[]; + isLoading?: boolean; + isExpanded?: boolean; +} + +/** + * Component for displaying series/episodes for Stalker portal content. + * Supports three modes: + * 1. Regular series (type=series): Fetches seasons from API via serialSeasonsResource + * 2. VOD with embedded series (vclub): Uses the series array from the vodWithSeries input + * 3. VOD series (Ministra is_series=1): Fetches seasons/episodes using movie_id and season_id + */ @Component({ selector: 'app-stalker-series-view', templateUrl: './stalker-series-view.component.html', styleUrls: ['../../xtream/detail-view.scss'], - imports: [FavoritesButtonComponent, MatButton, MatDivider, TranslatePipe], + imports: [ + FavoritesButtonComponent, + MatButton, + MatDivider, + MatProgressSpinner, + TranslatePipe, + ], }) export class StalkerSeriesViewComponent { readonly stalkerStore = inject(StalkerStore); - readonly selectedItem = this.stalkerStore.selectedItem; - readonly seasonsData = this.stalkerStore.getSerialSeasonsResource; + /** + * Optional input for VOD items with embedded series array (vclub mode) + * When provided, uses this instead of fetching seasons from API + */ + readonly vodWithSeries = input(null); + readonly selectedItem = this.stalkerStore.selectedItem; + + /** + * Track VOD series seasons with their loaded episodes + */ + readonly vodSeriesSeasons = signal([]); + + /** + * Indicates if this is a VOD series item (Ministra is_series=1) + */ + readonly isVodSeries = computed(() => { + const item = this.displayItem(); + return item?.is_series === true; + }); + + /** + * Loading state for VOD series seasons + */ + readonly isVodSeriesSeasonsLoading = + this.stalkerStore.isVodSeriesSeasonsLoading; + + /** + * Loading state for regular series seasons + */ + readonly isSerialSeasonsLoading = this.stalkerStore.isSerialSeasonsLoading; + + constructor() { + // Effect to load VOD series seasons when a VOD series item is selected + effect(() => { + const item = this.displayItem(); + if (item?.is_series === true) { + // Get seasons from the resource + const seasons = this.stalkerStore.getVodSeriesSeasonsResource(); + if (seasons && seasons.length > 0) { + // Map seasons to our VodSeriesSeason structure + const mappedSeasons: VodSeriesSeason[] = seasons.map( + (season: any) => ({ + id: season.id, + video_id: season.video_id, + name: + season.name || `Season ${season.season_number}`, + season_number: season.season_number, + episodes: [], + isLoading: false, + isExpanded: false, + }) + ); + this.vodSeriesSeasons.set(mappedSeasons); + } + } else { + this.vodSeriesSeasons.set([]); + } + }); + } + + /** + * For VOD with embedded series, we create a single "season" with the episodes + * For regular series, we use the API-fetched seasons + * For VOD series (is_series=1), we use the fetched seasons with dynamic episode loading + */ + readonly seasonsData = computed(() => { + const vodItem = this.vodWithSeries(); + if (vodItem?.series?.length > 0) { + // VOD with embedded series - create a pseudo-season structure + return [ + { + id: vodItem.id, + name: vodItem.info?.name || 'Episodes', + cmd: vodItem.cmd, + series: vodItem.series, + }, + ]; + } + + // Check if this is a VOD series (is_series=1) + const displayItem = this.displayItem(); + if (displayItem?.is_series === true) { + // Return VOD series seasons (episodes loaded dynamically) + return this.vodSeriesSeasons(); + } + + // Regular series - use API-fetched seasons + return this.stalkerStore.getSerialSeasonsResource(); + }); + + /** + * Get the item to display details for (either vodWithSeries or selectedItem from store) + */ + readonly displayItem = computed(() => { + return this.vodWithSeries() || this.selectedItem(); + }); + + /** + * Toggle season expansion and load episodes if needed (for VOD series) + */ + async toggleSeasonExpanded(season: VodSeriesSeason) { + const seasons = this.vodSeriesSeasons(); + const index = seasons.findIndex((s) => s.id === season.id); + if (index === -1) return; + + const updatedSeasons = [...seasons]; + const currentSeason = { ...updatedSeasons[index] }; + + // Toggle expansion + currentSeason.isExpanded = !currentSeason.isExpanded; + + // Load episodes if expanding and not already loaded + if ( + currentSeason.isExpanded && + (!currentSeason.episodes || currentSeason.episodes.length === 0) + ) { + currentSeason.isLoading = true; + updatedSeasons[index] = currentSeason; + this.vodSeriesSeasons.set(updatedSeasons); + + try { + const episodes = await this.stalkerStore.fetchVodSeriesEpisodes( + currentSeason.video_id, + currentSeason.id + ); + // Update season with loaded episodes + const newSeasons = [...this.vodSeriesSeasons()]; + const seasonIndex = newSeasons.findIndex( + (s) => s.id === season.id + ); + if (seasonIndex !== -1) { + newSeasons[seasonIndex] = { + ...newSeasons[seasonIndex], + episodes: episodes, + isLoading: false, + }; + this.vodSeriesSeasons.set(newSeasons); + } + } catch (error) { + console.error( + '[StalkerSeriesViewComponent] Failed to load episodes:', + error + ); + const newSeasons = [...this.vodSeriesSeasons()]; + const seasonIndex = newSeasons.findIndex( + (s) => s.id === season.id + ); + if (seasonIndex !== -1) { + newSeasons[seasonIndex] = { + ...newSeasons[seasonIndex], + isLoading: false, + }; + this.vodSeriesSeasons.set(newSeasons); + } + } + } else { + updatedSeasons[index] = currentSeason; + this.vodSeriesSeasons.set(updatedSeasons); + } + } + + /** + * Play episode - handles both regular series and VOD series + */ playEpisodeClicked(episode: any, cmd: string) { - const item = this.selectedItem(); + const item = this.displayItem(); this.stalkerStore.createLinkToPlayVod( cmd, item.info.name, @@ -27,12 +223,30 @@ export class StalkerSeriesViewComponent { ); } + /** + * Play VOD series episode + * For VOD series, we need to use a different cmd format + */ + playVodSeriesEpisode(episode: any) { + const item = this.displayItem(); + // For VOD series episodes, the cmd is: /media/file_{episode.id}.mpg + const cmd = `/media/file_${episode.id}.mpg`; + const episodeName = episode.name || `Episode ${episode.series_number}`; + + this.stalkerStore.createLinkToPlayVod( + cmd, + `${item.info.name} - ${episodeName}`, + item.info.movie_image + ); + } + addToFavorites(item: any) { + const displayItem = this.displayItem(); this.stalkerStore.addToFavorites({ ...item, - title: item.info.name, - cover: item.info.movie_image, - series_id: item.id, + title: displayItem.info.name, + cover: displayItem.info.movie_image, + series_id: displayItem.id, added_at: new Date().toISOString(), category_id: 'series', }); diff --git a/apps/web/src/app/stalker/stalker.store.ts b/apps/web/src/app/stalker/stalker.store.ts index 62335697b..2a55c8599 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, @@ -42,6 +43,10 @@ interface StalkerState { itvCategories: StalkerCategoryItem[]; hasMoreChannels: boolean; itvChannels: any[]; + /** For VOD items that are actually series (Ministra plugin is_series=1) */ + vodSeriesSeasons: any[]; + vodSeriesEpisodes: any[]; + selectedVodSeriesSeasonId: string; } const initialState: StalkerState = { @@ -61,6 +66,9 @@ const initialState: StalkerState = { itvCategories: [], hasMoreChannels: false, itvChannels: [], + vodSeriesSeasons: [], + vodSeriesEpisodes: [], + selectedVodSeriesSeasonId: undefined, }; function extractNumericValue(str: string) { @@ -81,52 +89,167 @@ function sortByNumericValue(array: StalkerSeason[]): StalkerSeason[] { }); } +/** + * Sort episodes by series_number in ascending numeric order (1, 2, 3... not "1", "10", "2") + */ +function sortEpisodesByNumber(episodes: any[]): any[] { + if (!episodes) return []; + return episodes.sort((a, b) => { + const numA = parseInt(a.series_number, 10) || 0; + const numB = parseInt(b.series_number, 10) || 0; + return numA - numB; + }); +} + +/** + * Convert relative URLs to absolute URLs using the portal base URL + * Handles screenshot_uri and cmd paths that come as relative from the server + */ +function makeAbsoluteUrl(baseUrl: string, relativePath: string): string { + if (!relativePath) return ''; + // Already absolute URL + if ( + relativePath.startsWith('http://') || + relativePath.startsWith('https://') + ) { + return relativePath; + } + // Parse the base URL to get origin + try { + const url = new URL(baseUrl); + // Ensure the relative path starts with / + const path = relativePath.startsWith('/') + ? relativePath + : `/${relativePath}`; + return `${url.origin}${path}`; + } catch { + return relativePath; + } +} + +/** + * Post-process stalker items to convert relative URLs to absolute + */ +function processItemUrls(item: any, portalUrl: string): any { + const processed = { ...item }; + + // Convert screenshot_uri to absolute URL + if (processed.screenshot_uri) { + processed.screenshot_uri = makeAbsoluteUrl( + portalUrl, + processed.screenshot_uri + ); + } + + return processed; +} + export const StalkerStore = signalStore( { providedIn: 'root' }, withState(initialState), - withProps((store, dataService = inject(DataService)) => ({ - getCategoryResource: resource({ - params: () => ({ - contentType: store.selectedContentType(), - action: StalkerPortalActions.GetCategories, - currentPlaylist: store.currentPlaylist(), - }), - loader: async ({ params }) => { - if (params.currentPlaylist === undefined) return; - - switch (params.contentType) { - case 'itv': - if (store.itvCategories().length > 0) { - return store.itvCategories(); - } - break; - case 'vod': - if (store.vodCategories().length > 0) { - return store.vodCategories(); - } - break; - case 'series': - if (store.seriesCategories().length > 0) { - return store.seriesCategories(); - } - break; + withProps( + ( + store, + dataService = inject(DataService), + stalkerSession = inject(StalkerSessionService) + ) => ({ + /** + * Helper to make stalker requests with automatic token handling + */ + async makeStalkerRequest( + playlist: PlaylistMeta, + params: Record + ) { + // Get token if it's a full stalker portal + let token: string | undefined; + let serialNumber: string | undefined; + if ((playlist as Playlist).isFullStalkerPortal) { + try { + const result = await stalkerSession.ensureToken( + playlist as Playlist + ); + token = result.token ?? undefined; + serialNumber = (playlist as Playlist) + .stalkerSerialNumber; + } catch (error) { + console.error('Failed to get stalker token:', error); + } } - const { portalUrl, macAddress } = params.currentPlaylist; + return dataService.sendIpcEvent(STALKER_REQUEST, { + url: playlist.portalUrl, + macAddress: playlist.macAddress, + params, + token, + serialNumber, + }); + }, + getCategoryResource: resource({ + params: () => ({ + contentType: store.selectedContentType(), + action: StalkerPortalActions.GetCategories, + currentPlaylist: store.currentPlaylist(), + }), + loader: async ({ params }) => { + if (params.currentPlaylist === undefined) return; - const response = await dataService.sendIpcEvent( - STALKER_REQUEST, - { - url: portalUrl, - macAddress, - params: { - action: StalkerContentTypes[params.contentType] - .getCategoryAction, - type: params.contentType, - }, + switch (params.contentType) { + case 'itv': + if (store.itvCategories().length > 0) { + return store.itvCategories(); + } + break; + case 'vod': + if (store.vodCategories().length > 0) { + return store.vodCategories(); + } + break; + case 'series': + if (store.seriesCategories().length > 0) { + return store.seriesCategories(); + } + break; } - ); - if (response) { + + const { portalUrl, macAddress } = params.currentPlaylist; + + // Use makeAuthenticatedRequest for automatic retry on auth failure + const playlist = params.currentPlaylist as Playlist; + const queryParams = { + action: StalkerContentTypes[params.contentType] + .getCategoryAction, + type: params.contentType, + }; + + let response: any; + if (playlist.isFullStalkerPortal) { + // Full stalker portal - use authenticated request with retry + response = + await stalkerSession.makeAuthenticatedRequest( + playlist, + queryParams + ); + } else { + // Simple stalker portal - no auth needed + response = await dataService.sendIpcEvent( + STALKER_REQUEST, + { + url: portalUrl, + macAddress, + params: queryParams, + } + ); + } + + // Guard: ensure response has expected structure + if (!response?.js || !Array.isArray(response.js)) { + console.warn( + '[StalkerStore] Invalid categories response:', + response + ); + return []; + } + const categories = response.js .map((item) => ({ category_name: item.title, @@ -139,58 +262,102 @@ export const StalkerStore = signalStore( [`${params.contentType}Categories`]: categories, }); return categories; - } else { - throw new Error( - `Error: ${response.message} (Status: ${response.status})` - ); - } - }, - }), - getContentResource: resource({ - params: () => ({ - contentType: store.selectedContentType(), - category: store.selectedCategoryId(), - action: StalkerPortalActions.GetOrderedList, - search: store.searchPhrase(), - pageIndex: store.page() + 1, + }, }), - loader: async ({ params }) => { - if ( - !params.category || - params.category === null || - params.category === '' - ) { - return Promise.resolve(undefined); - } - - const currentPlaylist = store.currentPlaylist; - const queryParams = { - action: StalkerContentTypes[params.contentType] - .getContentAction, - type: params.contentType, - category: params.category ?? '', - genre: params.category ?? '', - sortby: 'added', - ...(params.search !== '' ? { search: params.search } : {}), - p: params.pageIndex, - }; - - const response = await dataService.sendIpcEvent( - STALKER_REQUEST, - { - url: currentPlaylist().portalUrl, - macAddress: currentPlaylist().macAddress, - params: queryParams, + getContentResource: resource({ + params: () => ({ + contentType: store.selectedContentType(), + category: store.selectedCategoryId(), + action: StalkerPortalActions.GetOrderedList, + search: store.searchPhrase(), + pageIndex: store.page() + 1, + }), + loader: async ({ params }) => { + if ( + !params.category || + params.category === null || + params.category === '' + ) { + return Promise.resolve(undefined); } - ); - patchState(store, { totalCount: response.js.total_items }); + const currentPlaylist = store.currentPlaylist; - if (response) { - const newItems = response.js.data.map((item) => ({ - ...item, - cover: item.screenshot_uri, - })); + // Guard: ensure currentPlaylist is available (may not be during deep link init) + if (!currentPlaylist() || !currentPlaylist().portalUrl) { + return Promise.resolve(undefined); + } + // VOD uses 'genre' param, series uses 'category' param, itv uses both + // Based on stalker-to-m3u implementation + const categoryParam = params.category ?? ''; + const queryParams: Record = { + action: StalkerContentTypes[params.contentType] + .getContentAction, + type: params.contentType, + sortby: 'added', + ...(params.search !== '' + ? { search: params.search } + : {}), + p: params.pageIndex, + }; + + // Add the correct category/genre param based on content type + // Based on working app traces: VOD uses genre=0 and category={id} + if (params.contentType === 'vod') { + queryParams['genre'] = '0'; + queryParams['category'] = categoryParam; + } else if (params.contentType === 'series') { + queryParams['category'] = categoryParam; + } else { + // itv - use both for compatibility + queryParams['category'] = categoryParam; + queryParams['genre'] = categoryParam; + } + + // Use makeAuthenticatedRequest for automatic retry on auth failure + const playlist = currentPlaylist() as Playlist; + let response: any; + if (playlist.isFullStalkerPortal) { + // Full stalker portal - use authenticated request with retry + response = + await stalkerSession.makeAuthenticatedRequest( + playlist, + queryParams + ); + } else { + // Simple stalker portal - no auth needed + response = await dataService.sendIpcEvent( + STALKER_REQUEST, + { + url: playlist.portalUrl, + macAddress: playlist.macAddress, + params: queryParams, + } + ); + } + + // Guard: ensure response has expected structure + if (!response?.js?.data) { + console.warn( + '[StalkerStore] Invalid response structure:', + response + ); + return []; + } + + patchState(store, { + totalCount: response.js.total_items ?? 0, + }); + + const portalUrl = currentPlaylist().portalUrl; + const newItems = response.js.data.map((item) => { + // Post-process to convert relative URLs to absolute + const processed = processItemUrls(item, portalUrl); + return { + ...processed, + cover: processed.screenshot_uri, + }; + }); if (store.selectedContentType() === 'itv') { // Check if we're loading the first page or loading more @@ -209,41 +376,129 @@ export const StalkerStore = signalStore( const totalLoaded = store.itvChannels().length; patchState(store, { hasMoreChannels: - totalLoaded < response.js.total_items, + totalLoaded < (response.js.total_items ?? 0), }); } return newItems; - } else { - throw new Error( - `Error: ${response.message} (Status: ${response.status})` - ); - } - }, - }), - serialSeasonsResource: resource({ - params: () => ({ - itemId: store.selectedSerialId(), + }, }), - loader: async ({ params }) => { - const { portalUrl, macAddress } = store.currentPlaylist(); - const queryParams = { - action: StalkerContentTypes.series.getContentAction, - type: 'series', - movie_id: params.itemId, - }; - const response = await dataService.sendIpcEvent( - STALKER_REQUEST, - { - url: portalUrl, - macAddress, - params: queryParams, + serialSeasonsResource: resource({ + params: () => ({ + itemId: store.selectedSerialId(), + }), + loader: async ({ params }) => { + // Guard: ensure currentPlaylist and itemId are available + if (!store.currentPlaylist() || !params.itemId) { + return []; } - ); - return sortByNumericValue(response.js.data); - }, - }), - })), + const playlist = store.currentPlaylist() as Playlist; + const queryParams = { + action: StalkerContentTypes.series.getContentAction, + type: 'series', + movie_id: params.itemId, + }; + + // Use makeAuthenticatedRequest for automatic retry on auth failure + let response: any; + if (playlist.isFullStalkerPortal) { + // Full stalker portal - use authenticated request with retry + response = + await stalkerSession.makeAuthenticatedRequest( + playlist, + queryParams + ); + } else { + // Simple stalker portal - no auth needed + response = await dataService.sendIpcEvent( + STALKER_REQUEST, + { + url: playlist.portalUrl, + macAddress: playlist.macAddress, + params: queryParams, + } + ); + } + + // Guard: ensure response has expected structure + if (!response?.js?.data) { + console.warn( + '[StalkerStore] Invalid seasons response:', + response + ); + return []; + } + return sortByNumericValue(response.js.data); + }, + }), + /** + * Resource to fetch seasons for VOD items that are actually series (is_series=1) + * Used for Ministra plugin where VOD items can contain series/seasons + */ + vodSeriesSeasonsResource: resource({ + params: () => ({ + selectedItem: store.selectedItem(), + }), + loader: async ({ params }) => { + const item = params.selectedItem; + // Only fetch if item is a VOD series (has is_series flag) + if (!store.currentPlaylist() || !item || !item.is_series) { + return []; + } + + const playlist = store.currentPlaylist() as Playlist; + const queryParams = { + action: StalkerPortalActions.GetOrderedList, + type: 'vod', + movie_id: item.id, + p: '1', + }; + + console.log( + '[StalkerStore] Fetching VOD series seasons for movie_id:', + item.id + ); + + let response: any; + if (playlist.isFullStalkerPortal) { + response = + await stalkerSession.makeAuthenticatedRequest( + playlist, + queryParams + ); + } else { + response = await dataService.sendIpcEvent( + STALKER_REQUEST, + { + url: playlist.portalUrl, + macAddress: playlist.macAddress, + params: queryParams, + } + ); + } + + console.log( + '[StalkerStore] VOD series seasons response:', + response + ); + + if (!response?.js?.data) { + console.warn( + '[StalkerStore] Invalid VOD series seasons response:', + response + ); + return []; + } + + // Filter for season items (is_season: true) + const seasons = response.js.data.filter( + (item: any) => item.is_season === true + ); + return sortByNumericValue(seasons); + }, + }), + }) + ), withComputed((store) => ({ getTotalPages: computed(() => { return Math.ceil(store.totalCount() / store.limit()); @@ -260,6 +515,16 @@ export const StalkerStore = signalStore( getSerialSeasonsResource: computed(() => store.serialSeasonsResource.value() ), + isSerialSeasonsLoading: computed(() => + store.serialSeasonsResource.isLoading() + ), + /** VOD series (Ministra plugin is_series=1) */ + getVodSeriesSeasonsResource: computed(() => + store.vodSeriesSeasonsResource.value() + ), + isVodSeriesSeasonsLoading: computed(() => + store.vodSeriesSeasonsResource.isLoading() + ), /** category resource */ getCategoryResource: computed(() => store.getCategoryResource.value()), isCategoryResourceLoading: computed(() => @@ -292,6 +557,7 @@ export const StalkerStore = signalStore( dataService = inject(DataService), playerService = inject(PlayerService), playlistService = inject(PlaylistsService), + stalkerSession = inject(StalkerSessionService), snackBar = inject(MatSnackBar), translate = inject(TranslateService) ) => ({ @@ -359,6 +625,76 @@ export const StalkerStore = signalStore( setItvChannels(channels: any[]) { patchState(store, { itvChannels: channels }); }, + /** + * Fetch episodes for a VOD series season (Ministra plugin) + * @param videoId The video_id from the season item + * @param seasonId The season id + * @returns Array of episode items + */ + async fetchVodSeriesEpisodes( + videoId: string, + seasonId: string + ): Promise { + const playlist = store.currentPlaylist() as Playlist; + if (!playlist) return []; + + const queryParams = { + action: StalkerPortalActions.GetOrderedList, + type: 'vod', + movie_id: videoId, + season_id: seasonId, + p: '1', + }; + + console.log( + '[StalkerStore] Fetching VOD series episodes for video_id:', + videoId, + 'season_id:', + seasonId + ); + + let response: any; + if (playlist.isFullStalkerPortal) { + response = await stalkerSession.makeAuthenticatedRequest( + playlist, + queryParams + ); + } else { + response = await dataService.sendIpcEvent(STALKER_REQUEST, { + url: playlist.portalUrl, + macAddress: playlist.macAddress, + params: queryParams, + }); + } + + console.log( + '[StalkerStore] VOD series episodes response:', + response + ); + + if (!response?.js?.data) { + console.warn( + '[StalkerStore] Invalid VOD series episodes response:', + response + ); + return []; + } + + // Filter for episode items (is_episode: true) and sort by series_number + const episodes = sortEpisodesByNumber( + response.js.data.filter( + (item: any) => item.is_episode === true + ) + ); + + // Store episodes and selected season + patchState(store, { + vodSeriesEpisodes: episodes, + selectedVodSeriesSeasonId: seasonId, + }); + + return episodes; + }, /** getters */ getSelectedCategory() { return signal(store.selectedCategoryId()); @@ -373,28 +709,97 @@ export const StalkerStore = signalStore( const type = series ? ContentType.VODS : store.selectedContentType(); + + // Always use create_link to get the tokenized streaming URL + // The server adds the required token for playback authorization + // Note: cmd is already transformed during item processing (has_files items) const params = { action: StalkerContentTypes[store.selectedContentType()] .getLink, + cmd: cmd, type, - cmd, - forced_storage: 'undefined', disable_ad: '0', + download: '0', JsHttpRequest: '1-xml', - ...(series ? { series } : {}), + ...(series ? { series: String(series) } : {}), }; - const response = await dataService.sendIpcEvent( - STALKER_REQUEST, - { - url: portalUrl + '?' + cmd, + + console.log('[StalkerStore] series is :', series); + + // Use makeAuthenticatedRequest for automatic retry on auth failure + const playlist = store.currentPlaylist() as Playlist; + let response: any; + if (playlist?.isFullStalkerPortal) { + // Full stalker portal - use authenticated request with retry + response = await stalkerSession.makeAuthenticatedRequest( + playlist, + params + ); + } else { + // Simple stalker portal - no auth needed + response = await dataService.sendIpcEvent(STALKER_REQUEST, { + url: portalUrl, macAddress, params, - } - ); + }); + } + + // Check for server-side errors + if (response.js?.error) { + const errorMsg = response.js.error; + console.error('[StalkerStore] Server error:', errorMsg); + throw new Error(errorMsg); + } + let url = response.js.cmd as string; - if (url?.startsWith('ffmpeg')) { + + // If cmd is empty, the content is not available + if (!url) { + throw new Error('nothing_to_play'); + } + + if (url.startsWith('ffmpeg')) { url = url.split(' ')[1]; } + // Handle incomplete URLs - some portals return just query params or relative paths + if ( + url && + !url.startsWith('http://') && + !url.startsWith('https://') + ) { + // Extract base URL from portal URL + try { + const portalUrlObj = new URL(portalUrl); + // Get the stalker portal base path (e.g., /stalker_portal from /stalker_portal/server/load.php) + const pathParts = portalUrlObj.pathname.split('/'); + // Find the stalker_portal or c directory and use that as base + let basePath = ''; + for (let i = 0; i < pathParts.length; i++) { + if ( + pathParts[i] === 'stalker_portal' || + pathParts[i] === 'c' || + pathParts[i] === 'portal' + ) { + basePath = + '/' + pathParts.slice(1, i + 1).join('/'); + break; + } + } + + // If url starts with ?, it's just query params + // Combine with the original cmd path to form the complete streaming URL + if (url.startsWith('?')) { + // The streaming URL is: portal origin + base path + original cmd path + token query + // e.g., http://portal.com + /stalker_portal + /media/12345.mpg + ?token=xxx + url = `${portalUrlObj.origin}${basePath}${cmd}${url}`; + } else if (url.startsWith('/')) { + // Relative path - prepend origin and base path + url = `${portalUrlObj.origin}${basePath}${url}`; + } + } catch { + // URL parsing failed, return as-is + } + } return url; }, async getExpireDate() { @@ -405,14 +810,27 @@ export const StalkerStore = signalStore( }; try { - const response = await dataService.sendIpcEvent( - STALKER_REQUEST, - { - url: store.currentPlaylist().portalUrl, - macAddress: store.currentPlaylist().macAddress, - params, - } - ); + // Use makeAuthenticatedRequest for automatic retry on auth failure + const playlist = store.currentPlaylist() as Playlist; + let response: any; + if (playlist?.isFullStalkerPortal) { + // Full stalker portal - use authenticated request with retry + response = + await stalkerSession.makeAuthenticatedRequest( + playlist, + params + ); + } else { + // Simple stalker portal - no auth needed + response = await dataService.sendIpcEvent( + STALKER_REQUEST, + { + url: playlist.portalUrl, + macAddress: playlist.macAddress, + params, + } + ); + } if (response && response.js && response.js.account_info) { // Extract the expire date from the response @@ -467,37 +885,118 @@ export const StalkerStore = signalStore( ); }); }, + /** + * Fetch movie files using get_ordered_list with movie_id parameter. + * This is needed for items with has_files property to get the correct video_id + * for the create_link request. + */ + async fetchMovieFileId(movieId: string): Promise { + 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..6b6d09593 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,28 @@ @if (selectedCategory()) { @if (isStalker && selectedItem()) { @if (contentType === 'vod') { - + @if (selectedItem().series?.length > 0) { + + + } @else if (selectedItem().is_series) { + + + } @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..daa800096 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 @@ -48,7 +48,10 @@ export class CategoryContentViewComponent implements OnInit { const { categoryId } = this.activatedRoute.snapshot.params; // Only set category if it's different from the currently selected one // This preserves the page state when navigating back from detail view - if (categoryId && this.store.selectedCategoryId() !== Number(categoryId)) { + if ( + categoryId && + this.store.selectedCategoryId() !== Number(categoryId) + ) { this.store.setSelectedCategory(categoryId); } } @@ -63,10 +66,22 @@ export class CategoryContentViewComponent implements OnInit { const selectedItem = { id: item.id, cmd: item.cmd, + // For VOD items with embedded series array (Stalker vclub) + series: item.series, + // Preserve has_files for cmd transformation during playback + has_files: item.has_files, + // Flag for VOD items that are actually series (Ministra plugin) + // is_series can be "1" (string) or 1 (number) + // ONLY set this for VOD content type - regular series should use the standard series flow + is_series: + this.contentType === 'vod' && + (item.is_series === '1' || item.is_series === 1), + // Store video_id for season fetching if available + video_id: item.video_id, info: { movie_image: item.screenshot_uri, description: item.description, - name: item.name, + name: item.name || item.o_name, director: item.director, releasedate: item.year, genre: item.genres_str, diff --git a/apps/web/src/app/xtream/detail-view.scss b/apps/web/src/app/xtream/detail-view.scss index c53623e16..e682cc419 100644 --- a/apps/web/src/app/xtream/detail-view.scss +++ b/apps/web/src/app/xtream/detail-view.scss @@ -42,6 +42,40 @@ margin-top: 5px; } } + + .season-header { + display: flex; + align-items: center; + justify-content: space-between; + cursor: pointer; + padding: 10px 0; + + &:hover { + opacity: 0.8; + } + + h4 { + margin: 0; + } + + .expand-icon { + font-size: 12px; + margin-right: 10px; + } + } + + .seasons-loading, + .episodes-loading { + display: flex; + align-items: center; + gap: 10px; + padding: 20px; + } + + .no-episodes { + font-style: italic; + opacity: 0.7; + } } .image { diff --git a/apps/web/src/assets/i18n/en.json b/apps/web/src/assets/i18n/en.json index 914f0e1b8..22c34014c 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,11 @@ "STREAM_URL_COPIED": "Stream URL copied to clipboard", "COPY_STREAM_URL": "Copy Stream URL", "SELECT_CATEGORY": "Select a category", - "PAGE": "Page" + "PAGE": "Page", + "CONTENT_NOT_AVAILABLE": "This content is not available on this server", + "PLAYBACK_ERROR": "Failed to get playback URL", + "LOADING_SEASONS": "Loading seasons...", + "NO_EPISODES": "No episodes available" }, "INFORMATION": "Information", "REMOTE_CONTROL": { 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..5dee22625 --- /dev/null +++ b/libs/services/src/lib/stalker-session.service.ts @@ -0,0 +1,652 @@ +import { Injectable, inject } from '@angular/core'; +import { Playlist, STALKER_REQUEST } from 'shared-interfaces'; +import { DataService } from './data.service'; + +/** + * SHA1 hash using native Web Crypto API + * Produces correct 40-character hex hash matching real Stalker clients + */ +async function sha1(str: string): Promise { + const encoder = new TextEncoder(); + const data = encoder.encode(str); + const hashBuffer = await crypto.subtle.digest('SHA-1', data); + const hashArray = Array.from(new Uint8Array(hashBuffer)); + return hashArray.map((b) => b.toString(16).padStart(2, '0')).join(''); +} + +/** + * Generates SHA1 prehash from MAC address + * This must match what real Stalker clients send + */ +async function generatePrehash(macAddress: string): Promise { + // Use MAC address with colons, uppercase - this is what most clients use + const str = macAddress.toUpperCase(); + return (await sha1(str)).toUpperCase(); +} + +/** + * Generates a random string for metrics + */ +function generateRandom(): string { + const chars = 'abcdef0123456789'; + let result = ''; + for (let i = 0; i < 40; i++) { + result += chars.charAt(Math.floor(Math.random() * chars.length)); + } + return result; +} + +/** + * Deterministic serial number - 13 hex characters + * Constant value for consistency across all sessions + */ +export const STALKER_SERIAL_NUMBER = 'BEDACD4569BAF'; + +export interface StalkerHandshakeResponse { + js: { + token: string; + not_valid?: number; + random?: string; + }; +} + +export interface StalkerProfileResponse { + js: { + id?: string; + name?: string; + mac?: string; + status?: number; + msg?: string; + block_msg?: string; + account_info?: { + login?: string; + expire_date?: number; + tariff_plan_name?: string; + status?: number; + }; + }; +} + +/** + * Service to manage Stalker portal session tokens. + * Handles handshake authentication for full stalker portals (/stalker_portal/c URLs). + * Persists tokens during session and handles re-authentication on auth failures. + */ +@Injectable({ + providedIn: 'root', +}) +export class StalkerSessionService { + private dataService = inject(DataService); + + // In-memory token cache for current session (keyed by playlist ID) + private tokenCache = new Map(); + + // Pending authentication promises to prevent race conditions + // When multiple requests need a token simultaneously, they all wait for the same auth + private pendingAuth = new Map< + string, + Promise<{ token: string; serialNumber?: string }> + >(); + + /** + * Generates a consistent 64-character device ID from MAC address + */ + private generateDeviceId(macAddress: string): string { + // Generate consistent device ID from MAC + const str = macAddress.toUpperCase().replace(/:/g, ''); + const chars = '0123456789ABCDEF'; + let result = ''; + let seed = 0; + for (let i = 0; i < str.length; i++) { + seed = (seed * 31 + str.charCodeAt(i)) >>> 0; + } + for (let i = 0; i < 64; i++) { + seed = (seed * 1103515245 + 12345) >>> 0; + result += chars[seed % 16]; + } + return result; + } + + /** + * Checks if a URL is a full stalker portal URL (requires handshake) + * Full stalker portal URLs contain /stalker_portal/ in the path + */ + isFullStalkerPortal(url: string): boolean { + return ( + url.includes('/stalker_portal/') || url.includes('/server/load.php') + ); + } + + /** + * Gets the cached token for a playlist, or null if not cached + */ + getCachedToken(playlistId: string): string | null { + return this.tokenCache.get(playlistId) || null; + } + + /** + * Sets a token in the cache + */ + setCachedToken(playlistId: string, token: string): void { + this.tokenCache.set(playlistId, token); + } + + /** + * Clears the cached token for a playlist (e.g., on auth failure) + */ + clearCachedToken(playlistId: string): void { + this.tokenCache.delete(playlistId); + } + + /** + * Performs handshake to get a session token for a full stalker portal + * Returns both the token and the random value for use in subsequent requests + */ + async performHandshake( + portalUrl: string, + macAddress: string + ): Promise<{ token: string; random: string }> { + const prehash = await generatePrehash(macAddress); + + const params: Record = { + 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 = await 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 = STALKER_SERIAL_NUMBER; + console.log( + '[StalkerSession] Generated new serial number:', + serialNumber + ); + } else { + console.log( + '[StalkerSession] Using stored serial number:', + serialNumber + ); + } + + console.log( + '[StalkerSession] No cached token, performing full authentication...' + ); + + // Create the authentication promise and store it to prevent concurrent auth attempts + // Use async/await wrapper to properly clean up on both success and failure + const authPromise = (async () => { + try { + const { token } = await this.authenticate( + playlist.portalUrl, + playlist.macAddress, + serialNumber + ); + console.log( + '[StalkerSession] Authentication complete, token cached' + ); + this.setCachedToken(playlist._id, token); + return { token, serialNumber }; + } finally { + // Clean up pending promise regardless of success/failure + this.pendingAuth.delete(playlist._id); + } + })(); + + // Store the pending promise so other concurrent requests can wait on it + this.pendingAuth.set(playlist._id, authPromise); + + return authPromise; + } + + + + /** + * Checks if a response or error indicates an authorization failure + */ + private isAuthorizationError(responseOrError: unknown): boolean { + if (!responseOrError) return false; + + const response = responseOrError as Record; + + // 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'] + ); + + try { + const response = await this.dataService.sendIpcEvent( + STALKER_REQUEST, + { + url: playlist.portalUrl, + macAddress: playlist.macAddress, + params, + token, + serialNumber, + } + ); + + // Check for authorization failure in response + if (this.isAuthorizationError(response)) { + console.warn( + '[StalkerSession] Authorization error detected in response:', + response + ); + + if (retryOnAuthFailure && playlist.isFullStalkerPortal) { + console.log( + '[StalkerSession] Clearing token and retrying with fresh auth...' + ); + // Clear cached token to force re-authentication + this.clearCachedToken(playlist._id); + // Retry once with fresh authentication + return this.makeAuthenticatedRequest( + playlist, + params, + false + ); + } + + throw new Error('Authorization failed after retry'); + } + + return response; + } catch (error) { + // Check if error indicates auth failure + if ( + this.isAuthorizationError(error) && + retryOnAuthFailure && + playlist.isFullStalkerPortal + ) { + console.log( + '[StalkerSession] Clearing token and retrying with fresh auth (caught error)...' + ); + // Clear cached token and retry with new handshake + this.clearCachedToken(playlist._id); + return this.makeAuthenticatedRequest( + playlist, + params, + false + ); + } + throw error; + } + } +} 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 } );