From 16baf54b161bce5aa2f181bcdd2d4f37c1228295 Mon Sep 17 00:00:00 2001 From: 4gray Date: Mon, 5 Jan 2026 21:44:29 +0100 Subject: [PATCH] feat(stalker): support ministra shows under VOD content type Update translations and refactor StalkerPortalImportComponent to use Angular signals and readonly/dependency-injected readonly properties. - Add new i18n keys: LOADING_SEASONS and NO_EPISODES; adjust PLAYBACK_ERROR punctuation. - Replace EventEmitter @Output with a signals-based readonly output and mark form, constants and flags as readonly. - Switch to inject() style for services and mark many injected fields private/readonly to clarify intent. - Replace DataService import with STALKER_SERIAL_NUMBER token import. - Improve logging formatting, whitespace and minor code cleanup. - Minor template/style whitespace fixes. These changes modernize the component for better immutability, improve DX with clearer logging, and add missing user-facing translation strings. --- .../stalker-portal-import.component.ts | 197 ++++--- .../stalker-series-view.component.html | 101 +++- .../stalker-series-view.component.ts | 194 +++++- apps/web/src/app/stalker/stalker.store.ts | 165 ++++++ .../category-content-view.component.html | 7 +- .../category-content-view.component.ts | 13 +- apps/web/src/app/xtream/detail-view.scss | 34 ++ apps/web/src/assets/i18n/en.json | 4 +- .../src/lib/stalker-session.service.ts | 551 +++++++++--------- 9 files changed, 887 insertions(+), 379 deletions(-) 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 58ff6b25e..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, signal } from '@angular/core'; +import { Component, inject, output, signal } from '@angular/core'; import { FormControl, FormGroup, @@ -14,7 +14,7 @@ import { MatSnackBar } from '@angular/material/snack-bar'; import { Store } from '@ngrx/store'; import { TranslatePipe, TranslateService } from '@ngx-translate/core'; import { PlaylistActions } from 'm3u-state'; -import { DataService, StalkerSessionService } from 'services'; +import { STALKER_SERIAL_NUMBER, StalkerSessionService } from 'services'; import { Playlist } from 'shared-interfaces'; import { v4 as uuid } from 'uuid'; @@ -41,7 +41,7 @@ import { v4 as uuid } from 'uuid'; form { width: 100%; } - + .loading-container { display: flex; align-items: center; @@ -51,10 +51,10 @@ import { v4 as uuid } from 'uuid'; ], }) 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]), @@ -73,32 +73,38 @@ export class StalkerPortalImportComponent { userAgent: new FormControl(''), }); - dataService = inject(DataService); - stalkerSessionService = inject(StalkerSessionService); - store = inject(Store); - snackBar = inject(MatSnackBar); - translate = inject(TranslateService); - - isLoading = signal(false); + private readonly stalkerSessionService = inject(StalkerSessionService); + private readonly store = inject(Store); + private readonly snackBar = inject(MatSnackBar); + readonly translate = inject(TranslateService); + + 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); - + 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); - + 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; @@ -106,51 +112,98 @@ export class StalkerPortalImportComponent { 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...'); - + 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)'); - + 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)'); - + 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 - ); - + 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) + '...'); - + 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, + tariffPlanName: + authResult.accountInfo.tariff_plan_name, status: authResult.accountInfo.status, }; - console.log('[StalkerImport] Account info received:', stalkerAccountInfo); + 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); + const expireDate = new Date( + stalkerAccountInfo.expireDate * 1000 + ); this.snackBar.open( `Portal validated. Expires: ${expireDate.toLocaleDateString()}`, null, @@ -158,7 +211,10 @@ export class StalkerPortalImportComponent { ); } } catch (error) { - console.error('[StalkerImport] Authentication failed:', error); + console.error( + '[StalkerImport] Authentication failed:', + error + ); this.snackBar.open( 'Failed to authenticate with portal. Please check URL and MAC address.', null, @@ -168,9 +224,11 @@ export class StalkerPortalImportComponent { return; } } else { - console.log('[StalkerImport] Simple portal URL, skipping authentication'); + console.log( + '[StalkerImport] Simple portal URL, skipping authentication' + ); } - + const playlist: Playlist = { ...this.form.value, portalUrl: transformedUrl, @@ -183,13 +241,13 @@ export class StalkerPortalImportComponent { 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 { @@ -213,43 +271,40 @@ export class StalkerPortalImportComponent { 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')) { // 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'); + 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'); } // Case 2: Full stalker portal URL without /c at the end - if (url.includes('/stalker_portal') && !url.includes('/server/load.php')) { + 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'); + return url.replace( + /\/stalker_portal(\/.*)?$/, + '/stalker_portal/server/load.php' + ); } } // 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-series-view/stalker-series-view.component.html b/apps/web/src/app/stalker/stalker-series-view/stalker-series-view.component.html index 4cc7e2886..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,4 +1,4 @@ -@let seasons = seasonsData; +@let seasons = seasonsData();
@if (displayItem(); as serial) {
@@ -9,6 +9,7 @@ (error)=" $event.target.src = './assets/images/default-poster.png' " + alt="Series Cover" /> } @else {
@@ -22,7 +23,9 @@
@if (serial.info.director) {
-
{{ 'XTREAM.DIRECTOR' | translate }}:
+
+ {{ 'XTREAM.DIRECTOR' | translate }}: +
{{ serial.info.director }}
} @@ -46,7 +49,9 @@ } @if (serial.info.rating_imdb) {
-
{{ 'XTREAM.IMDB_RATING' | translate }}:
+
+ {{ 'XTREAM.IMDB_RATING' | translate }}: +
{{ serial.info.rating_imdb }}
} @@ -70,19 +75,85 @@
} + + +@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 141327eac..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,21 +1,49 @@ -import { Component, computed, inject, input } 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 two modes: + * 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); @@ -28,21 +56,85 @@ export class StalkerSeriesViewComponent { 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, - }]; + 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(); }); @@ -54,6 +146,73 @@ export class StalkerSeriesViewComponent { 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.displayItem(); this.stalkerStore.createLinkToPlayVod( @@ -64,6 +223,23 @@ 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({ diff --git a/apps/web/src/app/stalker/stalker.store.ts b/apps/web/src/app/stalker/stalker.store.ts index ec7919cc6..2a55c8599 100644 --- a/apps/web/src/app/stalker/stalker.store.ts +++ b/apps/web/src/app/stalker/stalker.store.ts @@ -43,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 = { @@ -62,6 +66,9 @@ const initialState: StalkerState = { itvCategories: [], hasMoreChannels: false, itvChannels: [], + vodSeriesSeasons: [], + vodSeriesEpisodes: [], + selectedVodSeriesSeasonId: undefined, }; function extractNumericValue(str: string) { @@ -82,6 +89,18 @@ 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 @@ -412,6 +431,72 @@ export const StalkerStore = signalStore( 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) => ({ @@ -430,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(() => @@ -530,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()); 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 5966d0972..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 @@ -4,6 +4,9 @@ @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 8bfa21e41..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); } } @@ -67,6 +70,14 @@ export class CategoryContentViewComponent implements OnInit { 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, 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 408e4952d..22c34014c 100644 --- a/apps/web/src/assets/i18n/en.json +++ b/apps/web/src/assets/i18n/en.json @@ -413,7 +413,9 @@ "SELECT_CATEGORY": "Select a category", "PAGE": "Page", "CONTENT_NOT_AVAILABLE": "This content is not available on this server", - "PLAYBACK_ERROR": "Failed to get playback URL" + "PLAYBACK_ERROR": "Failed to get playback URL", + "LOADING_SEASONS": "Loading seasons...", + "NO_EPISODES": "No episodes available" }, "INFORMATION": "Information", "REMOTE_CONTROL": { diff --git a/libs/services/src/lib/stalker-session.service.ts b/libs/services/src/lib/stalker-session.service.ts index c0615ee39..5dee22625 100644 --- a/libs/services/src/lib/stalker-session.service.ts +++ b/libs/services/src/lib/stalker-session.service.ts @@ -1,110 +1,27 @@ import { Injectable, inject } from '@angular/core'; +import { Playlist, STALKER_REQUEST } from 'shared-interfaces'; import { DataService } from './data.service'; -import { STALKER_REQUEST, Playlist } from 'shared-interfaces'; /** - * Pure JavaScript SHA1 implementation + * SHA1 hash using native Web Crypto API * 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); +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 */ -function generatePrehash(macAddress: string): string { +async function generatePrehash(macAddress: string): Promise { // Use MAC address with colons, uppercase - this is what most clients use const str = macAddress.toUpperCase(); - return sha1(str).toUpperCase(); + return (await sha1(str)).toUpperCase(); } /** @@ -120,17 +37,10 @@ function generateRandom(): string { } /** - * Generates a random serial number - 13 hex characters - * Note: This is auto-generated and stored in the playlist for consistency + * Deterministic serial number - 13 hex characters + * Constant value for consistency across all sessions */ -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 const STALKER_SERIAL_NUMBER = 'BEDACD4569BAF'; export interface StalkerHandshakeResponse { js: { @@ -167,14 +77,17 @@ export interface StalkerProfileResponse { }) 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>(); - + private pendingAuth = new Map< + string, + Promise<{ token: string; serialNumber?: string }> + >(); + /** * Generates a consistent 64-character device ID from MAC address */ @@ -193,36 +106,38 @@ export class StalkerSessionService { } 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'); + 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 @@ -231,8 +146,8 @@ export class StalkerSessionService { portalUrl: string, macAddress: string ): Promise<{ token: string; random: string }> { - const prehash = generatePrehash(macAddress); - + const prehash = await generatePrehash(macAddress); + const params: Record = { type: 'stb', action: 'handshake', @@ -240,34 +155,41 @@ export class StalkerSessionService { 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, - { + const response: StalkerHandshakeResponse = + await this.dataService.sendIpcEvent(STALKER_REQUEST, { url: portalUrl, macAddress, params, - } + }); + + console.log( + '[StalkerSession] Handshake response:', + JSON.stringify(response, null, 2) ); - - 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.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) { @@ -275,7 +197,7 @@ export class StalkerSessionService { throw error; } } - + /** * Gets account profile information to validate the portal and check subscription * Based on working implementation from stalker-to-m3u repo @@ -292,13 +214,14 @@ export class StalkerSessionService { providedSignature2?: string ): Promise { // Use provided device IDs or generate from MAC (64 hex chars each) - const deviceId1 = providedDeviceId1?.trim() || this.generateDeviceId(macAddress); + 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, @@ -307,10 +230,10 @@ export class StalkerSessionService { random: handshakeRandom, sn: serialNumber, }; - + // Generate prehash for get_profile (same as handshake) - const prehash = generatePrehash(macAddress); - + 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 = { @@ -331,35 +254,42 @@ export class StalkerSessionService { 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)'); - + 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, - { + 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) ); - - 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 */ @@ -377,11 +307,11 @@ export class StalkerSessionService { 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, @@ -392,15 +322,18 @@ export class StalkerSessionService { token, } ); - - console.log('[StalkerSession] do_auth response:', JSON.stringify(response, null, 2)); - + + 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) { @@ -408,7 +341,7 @@ export class StalkerSessionService { throw error; } } - + /** * Performs full authentication flow: handshake -> get_profile (NO do_auth based on working traces) * Returns the token and account info if successful @@ -421,35 +354,80 @@ export class StalkerSessionService { deviceId2?: string, signature1?: string, signature2?: string - ): Promise<{ token: string; accountInfo?: StalkerProfileResponse['js']['account_info'] }> { + ): 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)'); - + 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'); - + 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)...'); + 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); - + 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'; + 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] Step 2 complete: Profile obtained, token activated' + ); console.log('[StalkerSession] Authentication successful!'); return { token, @@ -461,55 +439,78 @@ export class StalkerSessionService { 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); - + 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'); + 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) + '...'); + 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...'); + 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); + serialNumber = STALKER_SERIAL_NUMBER; + console.log( + '[StalkerSession] Generated new serial number:', + serialNumber + ); } else { - console.log('[StalkerSession] Using stored serial number:', serialNumber); + console.log( + '[StalkerSession] Using stored serial number:', + serialNumber + ); } - - console.log('[StalkerSession] No cached token, performing full authentication...'); - + + 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 () => { @@ -517,96 +518,51 @@ export class StalkerSessionService { const { token } = await this.authenticate( playlist.portalUrl, playlist.macAddress, - serialNumber, - playlist.stalkerDeviceId1, - playlist.stalkerDeviceId2, - playlist.stalkerSignature1, - playlist.stalkerSignature2 + serialNumber + ); + console.log( + '[StalkerSession] Authentication complete, token cached' ); - 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?.() || - ''; - + const errorMessage = + (response?.['message'] as string)?.toLowerCase?.() || + jsData?.['error']?.toString?.().toLowerCase?.() || + jsData?.['msg']?.toString?.().toLowerCase?.() || + ''; + return ( errorMessage.includes('authorization') || errorMessage.includes('unauthorized') || @@ -616,7 +572,7 @@ export class StalkerSessionService { 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 @@ -629,35 +585,68 @@ export class StalkerSessionService { // 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, - } + + console.log( + '[StalkerSession] Making authenticated request:', + params['action'] ); - - // 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); + + 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'); } - - 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; } - - return response; } }