From 94bc3f1f0afe1df9a864d15b47511d743cfcf5ce Mon Sep 17 00:00:00 2001 From: 4gray Date: Sat, 14 Feb 2026 11:44:06 +0100 Subject: [PATCH 01/12] chore(events): enhance error handling in player and xtream events --- .../src/app/events/player.events.ts | 45 ++++++++++++++----- .../src/app/events/xtream.events.ts | 42 ++++++++++++++++- 2 files changed, 76 insertions(+), 11 deletions(-) diff --git a/apps/electron-backend/src/app/events/player.events.ts b/apps/electron-backend/src/app/events/player.events.ts index 54e40ec65..0edb88137 100644 --- a/apps/electron-backend/src/app/events/player.events.ts +++ b/apps/electron-backend/src/app/events/player.events.ts @@ -140,6 +140,20 @@ function stopPositionPolling() { } } +function maskUrlForLogs(rawUrl: string): string { + try { + const parsed = new URL(rawUrl); + return `${parsed.origin}${parsed.pathname}`; + } catch { + return rawUrl; + } +} + +function shouldIgnoreMpvStdoutLine(line: string): boolean { + // Suppress MPV realtime progress spam. + return /^(\(Paused\)\s*)?AV:\s/.test(line); +} + function startPositionPolling(socketPath: string, contentInfo: any) { stopPositionPolling(); @@ -304,14 +318,16 @@ ipcMain.handle( const mpvPath = getMpvPath(); const reuseInstance = store.get(MPV_REUSE_INSTANCE, false); - console.log('Opening MPV player with path:', mpvPath); - console.log('Reuse instance:', reuseInstance); - console.log('URL:', url); - console.log('User-Agent:', userAgent); - console.log('Referer:', referer); - console.log('Origin:', origin); - console.log('Content Info:', contentInfo); - console.log('Start Time:', startTime); + console.log('[MPV] Opening player', { + path: mpvPath, + reuseInstance, + stream: maskUrlForLogs(url), + hasUserAgent: Boolean(userAgent), + hasReferer: Boolean(referer), + hasOrigin: Boolean(origin), + hasContentInfo: Boolean(contentInfo), + startTime: startTime ?? null, + }); // If reuse is enabled and there's an existing process, try to use it if ( @@ -410,8 +426,17 @@ ipcMain.handle( // Capture stdout if (proc.stdout) { proc.stdout.on('data', (data) => { - const output = data.toString().trim(); - if (output) { + const lines = data + .toString() + .split('\n') + .map((line) => line.trim()) + .filter(Boolean); + + for (const output of lines) { + if (shouldIgnoreMpvStdoutLine(output)) { + continue; + } + console.log('[MPV stdout]:', output); // MPV sometimes outputs errors to stdout instead of stderr diff --git a/apps/electron-backend/src/app/events/xtream.events.ts b/apps/electron-backend/src/app/events/xtream.events.ts index f53e366f4..26bfb0ab4 100644 --- a/apps/electron-backend/src/app/events/xtream.events.ts +++ b/apps/electron-backend/src/app/events/xtream.events.ts @@ -12,6 +12,43 @@ export default class XtreamEvents { } } +function formatXtreamError(error: unknown, requestUrl: string, action?: string) { + const parsedUrl = new URL(requestUrl); + const base = { + action, + host: parsedUrl.host, + pathname: parsedUrl.pathname, + }; + + if (axios.isAxiosError(error)) { + return { + ...base, + type: 'AxiosError', + code: error.code, + status: error.response?.status, + message: error.message, + syscall: (error as NodeJS.ErrnoException).syscall, + hostname: (error as any).hostname, + }; + } + + if (error && typeof error === 'object') { + const errObj = error as Record; + return { + ...base, + type: 'ErrorObject', + status: errObj.status, + message: errObj.message, + }; + } + + return { + ...base, + type: 'UnknownError', + message: String(error), + }; +} + /** * Handle Xtream Codes API requests */ @@ -63,7 +100,10 @@ ipcMain.handle( action: params.action, }; } catch (error) { - console.error('Xtream request error:', error); + console.error( + '[XTREAM_REQUEST] Failed', + formatXtreamError(error, payload.url, payload.params?.action) + ); // Format error response if (axios.isAxiosError(error)) { From 648ba9d5b46119c32c2da195080d61ce366b5fd5 Mon Sep 17 00:00:00 2001 From: 4gray Date: Sat, 14 Feb 2026 11:44:20 +0100 Subject: [PATCH 02/12] chore(tsconfig): correct typeRoots path for module resolution --- apps/web/tsconfig.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/web/tsconfig.json b/apps/web/tsconfig.json index 1d8d0b1d3..b17319d3b 100644 --- a/apps/web/tsconfig.json +++ b/apps/web/tsconfig.json @@ -10,7 +10,7 @@ "emitDecoratorMetadata": true, "experimentalDecorators": true, "target": "ES2022", - "typeRoots": ["node_modules/@types"], + "typeRoots": ["../../node_modules/@types"], "lib": ["es2018", "es2017", "es2016", "es2015", "dom"], "resolveJsonModule": true, "strict": false, From b9ac1ade7839465485a3155d07eefba7c9eb487a Mon Sep 17 00:00:00 2001 From: 4gray Date: Sat, 14 Feb 2026 11:44:30 +0100 Subject: [PATCH 03/12] chore(logger): implement logger utility with debug, info, warn, and error methods --- apps/web/src/app/shared/utils/logger.ts | 32 +++++++++++++++++++++++++ 1 file changed, 32 insertions(+) create mode 100644 apps/web/src/app/shared/utils/logger.ts diff --git a/apps/web/src/app/shared/utils/logger.ts b/apps/web/src/app/shared/utils/logger.ts new file mode 100644 index 000000000..8da8726a8 --- /dev/null +++ b/apps/web/src/app/shared/utils/logger.ts @@ -0,0 +1,32 @@ +import { AppConfig } from '../../../environments/environment'; + +export interface Logger { + debug: (...args: unknown[]) => void; + info: (...args: unknown[]) => void; + warn: (...args: unknown[]) => void; + error: (...args: unknown[]) => void; +} + +export function createLogger(scope: string): Logger { + const prefix = `[${scope}]`; + const debugEnabled = !AppConfig.production; + + return { + debug: (...args: unknown[]) => { + if (debugEnabled) { + console.debug(prefix, ...args); + } + }, + info: (...args: unknown[]) => { + if (debugEnabled) { + console.info(prefix, ...args); + } + }, + warn: (...args: unknown[]) => { + console.warn(prefix, ...args); + }, + error: (...args: unknown[]) => { + console.error(prefix, ...args); + }, + }; +} From 52947bdfacd5f7b9b8155c4e9cc6bfacd3244e2e Mon Sep 17 00:00:00 2001 From: 4gray Date: Sat, 14 Feb 2026 11:45:56 +0100 Subject: [PATCH 04/12] refactor(logging): replace console.error and console.warn with logger utility in multiple components and services --- .../account-info/account-info.component.ts | 6 ++++-- .../category-management-dialog.component.ts | 6 ++++-- .../data-sources/pwa-xtream-data-source.ts | 4 +++- .../recently-viewed/recently-viewed.component.ts | 4 +++- .../search-results/search-results.component.ts | 4 +++- .../season-container.component.ts | 16 ++++++++++------ .../xtream-tauri/services/epg-queue.service.ts | 6 ++++-- .../stores/features/with-content.feature.ts | 13 ++++++------- .../stores/features/with-epg.feature.ts | 6 ++++-- .../stores/features/with-portal.feature.ts | 6 ++++-- .../stores/features/with-search.feature.ts | 4 +++- .../src/app/xtream-tauri/stores/xtream.store.ts | 10 ++++++---- .../vod-details/vod-details-route.component.ts | 11 +++++++---- .../app/xtream-tauri/with-favorites.feature.ts | 6 ++++-- .../src/app/xtream-tauri/with-recent-items.ts | 9 ++++----- 15 files changed, 69 insertions(+), 42 deletions(-) diff --git a/apps/web/src/app/xtream-tauri/account-info/account-info.component.ts b/apps/web/src/app/xtream-tauri/account-info/account-info.component.ts index 94cae7146..f463a92a3 100644 --- a/apps/web/src/app/xtream-tauri/account-info/account-info.component.ts +++ b/apps/web/src/app/xtream-tauri/account-info/account-info.component.ts @@ -7,6 +7,7 @@ import { TranslatePipe } from '@ngx-translate/core'; import { selectActivePlaylist } from 'm3u-state'; import { XtreamApiService } from '../services/xtream-api.service'; import { XtreamAccountInfo } from './account-info.interface'; +import { createLogger } from '../../shared/utils/logger'; @Component({ selector: 'app-account-info', @@ -22,6 +23,7 @@ export class AccountInfoComponent { }>(MAT_DIALOG_DATA); private readonly xtreamApiService = inject(XtreamApiService); private readonly store = inject(Store); + private readonly logger = createLogger('XtreamAccountInfo'); accountInfo: XtreamAccountInfo; formattedExpDate: string; @@ -59,7 +61,7 @@ export class AccountInfoComponent { ).toLocaleDateString(); } } catch (error) { - console.error('Failed to fetch account info:', error); + this.logger.error('Failed to fetch account info', error); } } @@ -70,4 +72,4 @@ export class AccountInfoComponent { get isTrial(): boolean { return this.accountInfo?.user_info?.is_trial === '1'; } -} \ No newline at end of file +} diff --git a/apps/web/src/app/xtream-tauri/category-management-dialog/category-management-dialog.component.ts b/apps/web/src/app/xtream-tauri/category-management-dialog/category-management-dialog.component.ts index 373ca23e3..69b6319ba 100644 --- a/apps/web/src/app/xtream-tauri/category-management-dialog/category-management-dialog.component.ts +++ b/apps/web/src/app/xtream-tauri/category-management-dialog/category-management-dialog.component.ts @@ -14,6 +14,7 @@ import { MatProgressSpinnerModule } from '@angular/material/progress-spinner'; import { MatSnackBar } from '@angular/material/snack-bar'; import { TranslatePipe } from '@ngx-translate/core'; import { DatabaseService, XCategoryFromDb } from 'services'; +import { createLogger } from '../../shared/utils/logger'; export interface CategoryManagementDialogData { playlistId: string; @@ -47,6 +48,7 @@ export class CategoryManagementDialogComponent implements OnInit { MatDialogRef ); readonly data = inject(MAT_DIALOG_DATA); + private readonly logger = createLogger('CategoryManagementDialog'); readonly isLoading = signal(true); readonly isSaving = signal(false); @@ -95,7 +97,7 @@ export class CategoryManagementDialogComponent implements OnInit { })) ); } catch (error) { - console.error('Error loading categories:', error); + this.logger.error('Error loading categories', error); } finally { this.isLoading.set(false); } @@ -175,7 +177,7 @@ export class CategoryManagementDialogComponent implements OnInit { this.dialogRef.close(true); } catch (error) { - console.error('Error saving category visibility:', error); + this.logger.error('Error saving category visibility', error); inject(MatSnackBar).open( 'Failed to save category visibility', 'Close', diff --git a/apps/web/src/app/xtream-tauri/data-sources/pwa-xtream-data-source.ts b/apps/web/src/app/xtream-tauri/data-sources/pwa-xtream-data-source.ts index 9fa5ab037..befc0cffd 100644 --- a/apps/web/src/app/xtream-tauri/data-sources/pwa-xtream-data-source.ts +++ b/apps/web/src/app/xtream-tauri/data-sources/pwa-xtream-data-source.ts @@ -14,6 +14,7 @@ import { XtreamContentItem, XtreamPlaylistData, } from './xtream-data-source.interface'; +import { createLogger } from '../../shared/utils/logger'; /** * LocalStorage keys for PWA persistence @@ -33,6 +34,7 @@ const STORAGE_KEYS = { @Injectable({ providedIn: 'root' }) export class PwaXtreamDataSource implements IXtreamDataSource { private readonly apiService = inject(XtreamApiService); + private readonly logger = createLogger('PwaXtreamDataSource'); // In-memory cache for the current session private categoryCache = new Map(); @@ -151,7 +153,7 @@ export class PwaXtreamDataSource implements IXtreamDataSource { hidden: boolean ): Promise { // Category visibility is not supported in PWA mode - console.warn('Category visibility not supported in PWA mode'); + this.logger.warn('Category visibility not supported in PWA mode'); } // ========================================================================= diff --git a/apps/web/src/app/xtream-tauri/recently-viewed/recently-viewed.component.ts b/apps/web/src/app/xtream-tauri/recently-viewed/recently-viewed.component.ts index 8bdbc4fc0..f91b302f9 100644 --- a/apps/web/src/app/xtream-tauri/recently-viewed/recently-viewed.component.ts +++ b/apps/web/src/app/xtream-tauri/recently-viewed/recently-viewed.component.ts @@ -7,6 +7,7 @@ import { ActivatedRoute, Router } from '@angular/router'; import groupBy from 'lodash/groupBy'; import { ContentCardComponent } from '../../shared/components/content-card/content-card.component'; import { XtreamStore } from '../stores/xtream.store'; +import { createLogger } from '../../shared/utils/logger'; @Component({ selector: 'app-recently-viewed', @@ -26,6 +27,7 @@ export class RecentlyViewedComponent { private activatedRoute = inject(ActivatedRoute); private router = inject(Router); private dialogData = inject(MAT_DIALOG_DATA, { optional: true }); + private readonly logger = createLogger('XtreamRecentlyViewed'); readonly isGlobal = this.dialogData?.isGlobal ?? false; readonly recentItems = computed(() => @@ -51,7 +53,7 @@ export class RecentlyViewedComponent { try { await this.xtreamStore.loadGlobalRecentItems(); } catch (error) { - console.error('Error loading global items:', error); + this.logger.error('Error loading global items', error); } } diff --git a/apps/web/src/app/xtream-tauri/search-results/search-results.component.ts b/apps/web/src/app/xtream-tauri/search-results/search-results.component.ts index 84e6df502..a58cadc0f 100644 --- a/apps/web/src/app/xtream-tauri/search-results/search-results.component.ts +++ b/apps/web/src/app/xtream-tauri/search-results/search-results.component.ts @@ -28,6 +28,7 @@ import { ContentCardComponent } from '../../shared/components/content-card/conte import { SearchLayoutComponent } from '../../shared/components/search-layout/search-layout.component'; import { XtreamStore } from '../stores/xtream.store'; import { SearchFilters } from '../stores/features/with-search.feature'; +import { createLogger } from '../../shared/utils/logger'; interface SearchResultsData { isGlobalSearch: boolean; @@ -55,6 +56,7 @@ export class SearchResultsComponent implements AfterViewInit { readonly router = inject(Router); readonly activatedRoute = inject(ActivatedRoute); readonly databaseService = inject(DatabaseService); + private readonly logger = createLogger('XtreamSearchResults'); /** Search term from store */ readonly searchTerm = this.xtreamStore.searchTerm; @@ -182,7 +184,7 @@ export class SearchResultsComponent implements AfterViewInit { this.xtreamStore.setIsSearching(false); } } catch (error) { - console.error('Error in global search:', error); + this.logger.error('Error in global search', error); this.xtreamStore.resetSearchResults(); } } diff --git a/apps/web/src/app/xtream-tauri/season-container/season-container.component.ts b/apps/web/src/app/xtream-tauri/season-container/season-container.component.ts index fe1ed9085..84bba4cc5 100644 --- a/apps/web/src/app/xtream-tauri/season-container/season-container.component.ts +++ b/apps/web/src/app/xtream-tauri/season-container/season-container.component.ts @@ -12,6 +12,7 @@ import { StalkerStore } from '../../stalker/stalker.store'; import { ProgressCapsuleComponent } from '../shared/progress-capsule/progress-capsule.component'; import { WatchedBadgeComponent } from '../shared/watched-badge/watched-badge.component'; import { DownloadsService } from '../../services/downloads.service'; +import { createLogger } from '../../shared/utils/logger'; type EpisodeViewMode = 'grid' | 'list'; const EPISODE_VIEW_MODE_KEY = 'iptvnator_episode_view_mode'; @@ -36,6 +37,7 @@ export class SeasonContainerComponent implements OnInit { private readonly xtreamStore = inject(XtreamStore); private readonly stalkerStore = inject(StalkerStore); private readonly downloadsService = inject(DownloadsService); + private readonly logger = createLogger('SeasonContainer'); readonly seasons = input.required>(); readonly seriesId = input.required(); @@ -177,7 +179,7 @@ export class SeasonContainerComponent implements OnInit { event.stopPropagation(); const playlistId = this.getPlaylistId(episode); if (!playlistId) { - console.warn('[SeasonContainer] Cannot toggle watched: no playlist ID'); + this.logger.warn('Cannot toggle watched: no playlist ID'); return; } @@ -218,9 +220,11 @@ export class SeasonContainerComponent implements OnInit { const contentId = this.getEpisodeContentId(episode); const inProgress = this.xtreamStore.isInProgress(contentId, 'episode'); if (inProgress) { - console.log( - `[SeasonContainer] Episode ${episode.title} (contentId=${contentId}, originalId=${(episode as any).originalId}) is IN PROGRESS` - ); + this.logger.debug('Episode in progress', { + title: episode.title, + contentId, + originalId: (episode as any).originalId, + }); } return inProgress; } @@ -354,11 +358,11 @@ export class SeasonContainerComponent implements OnInit { episode.episode_num // series param for episode number ); if (!url) { - console.error('[SeasonContainer] Failed to resolve Stalker stream URL'); + this.logger.error('Failed to resolve Stalker stream URL'); return; } } catch (error) { - console.error('[SeasonContainer] Error resolving Stalker stream URL:', error); + this.logger.error('Error resolving Stalker stream URL', error); return; } diff --git a/apps/web/src/app/xtream-tauri/services/epg-queue.service.ts b/apps/web/src/app/xtream-tauri/services/epg-queue.service.ts index 41a23327e..8909d1873 100644 --- a/apps/web/src/app/xtream-tauri/services/epg-queue.service.ts +++ b/apps/web/src/app/xtream-tauri/services/epg-queue.service.ts @@ -2,6 +2,7 @@ import { inject, Injectable, OnDestroy } from '@angular/core'; import { Subject } from 'rxjs'; import { EpgItem } from 'shared-interfaces'; import { XtreamApiService, XtreamCredentials } from './xtream-api.service'; +import { createLogger } from '../../shared/utils/logger'; interface CacheEntry { data: EpgItem[]; @@ -17,6 +18,7 @@ interface CacheEntry { @Injectable({ providedIn: 'root' }) export class EpgQueueService implements OnDestroy { private readonly apiService = inject(XtreamApiService); + private readonly logger = createLogger('EpgQueueService'); private readonly cache = new Map(); private queue: number[] = []; @@ -115,8 +117,8 @@ export class EpgQueueService implements OnDestroy { }); this.epgResult$.next({ streamId, items }); } catch (error) { - console.error( - `EpgQueueService: failed to load EPG for stream ${streamId}:`, + this.logger.error( + `Failed to load EPG for stream ${streamId}`, error ); } finally { diff --git a/apps/web/src/app/xtream-tauri/stores/features/with-content.feature.ts b/apps/web/src/app/xtream-tauri/stores/features/with-content.feature.ts index 97d2981f4..0959b8c9c 100644 --- a/apps/web/src/app/xtream-tauri/stores/features/with-content.feature.ts +++ b/apps/web/src/app/xtream-tauri/stores/features/with-content.feature.ts @@ -18,6 +18,7 @@ import { } from '../../data-sources/xtream-data-source.interface'; import { XtreamCredentials } from '../../services/xtream-api.service'; import { ContentType } from '../../xtream-state'; +import { createLogger } from '../../../shared/utils/logger'; /** * Content state for managing categories and streams @@ -61,6 +62,7 @@ const initialContentState: ContentState = { * - Import progress tracking */ export function withContent() { + const logger = createLogger('withContent'); return signalStoreFeature( withState(initialContentState), @@ -174,7 +176,7 @@ export function withContent() { isLoadingCategories: false, }); } catch (error) { - console.error('Error fetching categories:', error); + logger.error('Error fetching categories', error); patchState(store, { isLoadingCategories: false }); } }, @@ -234,7 +236,7 @@ export function withContent() { isLoadingContent: false, }); } catch (error) { - console.error('Error fetching content:', error); + logger.error('Error fetching content', error); patchState(store, { isLoadingContent: false }); } }, @@ -275,10 +277,7 @@ export function withContent() { ); localStorage.removeItem(restoreKey); } catch (err) { - console.error( - 'Error restoring user data:', - err - ); + logger.error('Error restoring user data', err); } } } finally { @@ -318,7 +317,7 @@ export function withContent() { serialCategories: series, }); } catch (error) { - console.error('Error reloading categories:', error); + logger.error('Error reloading categories', error); } }, diff --git a/apps/web/src/app/xtream-tauri/stores/features/with-epg.feature.ts b/apps/web/src/app/xtream-tauri/stores/features/with-epg.feature.ts index acebcb1df..f34272f0e 100644 --- a/apps/web/src/app/xtream-tauri/stores/features/with-epg.feature.ts +++ b/apps/web/src/app/xtream-tauri/stores/features/with-epg.feature.ts @@ -10,6 +10,7 @@ import { XtreamApiService, XtreamCredentials, } from '../../services/xtream-api.service'; +import { createLogger } from '../../../shared/utils/logger'; /** * EPG state for managing Electronic Program Guide data @@ -34,6 +35,7 @@ const initialEpgState: EpgState = { * - Loading channel EPG for preview */ export function withEpg() { + const logger = createLogger('withEpg'); return signalStoreFeature( withState(initialEpgState), @@ -94,7 +96,7 @@ export function withEpg() { return epgItems; } catch (error) { - console.error('Error loading EPG:', error); + logger.error('Error loading EPG', error); patchState(store, { epgItems: [], isLoadingEpg: false, @@ -119,7 +121,7 @@ export function withEpg() { 1 ); } catch (error) { - console.error('Error loading channel EPG:', error); + logger.error('Error loading channel EPG', error); return []; } }, diff --git a/apps/web/src/app/xtream-tauri/stores/features/with-portal.feature.ts b/apps/web/src/app/xtream-tauri/stores/features/with-portal.feature.ts index 032f35973..e41b6bda7 100644 --- a/apps/web/src/app/xtream-tauri/stores/features/with-portal.feature.ts +++ b/apps/web/src/app/xtream-tauri/stores/features/with-portal.feature.ts @@ -17,6 +17,7 @@ import { XtreamCredentials, } from '../../services/xtream-api.service'; import { PortalStatusType } from '../../xtream-state'; +import { createLogger } from '../../../shared/utils/logger'; /** * Portal state for managing playlist and portal status @@ -44,6 +45,7 @@ const initialPortalState: PortalState = { * - Checking portal status (active, inactive, expired, unavailable) */ export function withPortal() { + const logger = createLogger('withPortal'); return signalStoreFeature( withState(initialPortalState), @@ -98,7 +100,7 @@ export function withPortal() { } } } catch (error) { - console.error('Error fetching playlist:', error); + logger.error('Error fetching playlist', error); } }, @@ -140,7 +142,7 @@ export function withPortal() { patchState(store, { portalStatus: 'inactive' }); } } catch (error) { - console.error('Error checking portal status:', error); + logger.error('Error checking portal status', error); patchState(store, { portalStatus: 'unavailable' }); } }, diff --git a/apps/web/src/app/xtream-tauri/stores/features/with-search.feature.ts b/apps/web/src/app/xtream-tauri/stores/features/with-search.feature.ts index 2d9b1d4dc..5493fc8be 100644 --- a/apps/web/src/app/xtream-tauri/stores/features/with-search.feature.ts +++ b/apps/web/src/app/xtream-tauri/stores/features/with-search.feature.ts @@ -10,6 +10,7 @@ import { XTREAM_DATA_SOURCE, XtreamContentItem, } from '../../data-sources/xtream-data-source.interface'; +import { createLogger } from '../../../shared/utils/logger'; /** * Search filters configuration @@ -58,6 +59,7 @@ const initialSearchState: SearchState = { * - Global search results from external sources */ export function withSearch() { + const logger = createLogger('withSearch'); return signalStoreFeature( withState(initialSearchState), @@ -99,7 +101,7 @@ export function withSearch() { return results; } catch (error) { - console.error('Error searching content:', error); + logger.error('Error searching content', error); patchState(store, { searchResults: [], isSearching: false, diff --git a/apps/web/src/app/xtream-tauri/stores/xtream.store.ts b/apps/web/src/app/xtream-tauri/stores/xtream.store.ts index 5516500e5..e00d46971 100644 --- a/apps/web/src/app/xtream-tauri/stores/xtream.store.ts +++ b/apps/web/src/app/xtream-tauri/stores/xtream.store.ts @@ -28,6 +28,7 @@ import { withPlayer, withPlaybackPositions, } from './features'; +import { createLogger } from '../../shared/utils/logger'; /** * XtreamStore - Facade composing all feature stores. @@ -80,6 +81,7 @@ export const XtreamStore = signalStore( withMethods((store) => { const xtreamApiService = inject(XtreamApiService); const ngrxStore = inject(Store); + const logger = createLogger('XtreamStore'); const searchContent = (store as any) .searchContent as (term: string, types: string[], excludeHidden?: boolean) => Promise; @@ -143,7 +145,7 @@ export const XtreamStore = signalStore( stream_id: params.vodId, }); }).catch((error: unknown) => { - console.error('Error fetching VOD details:', error); + logger.error('Error fetching VOD details', error); store.setDetailsError( error instanceof Error ? error.message : 'Unknown error' ); @@ -175,7 +177,7 @@ export const XtreamStore = signalStore( series_id: params.serialId, }); }).catch((error: unknown) => { - console.error('Error fetching series details:', error); + logger.error('Error fetching series details', error); store.setDetailsError( error instanceof Error ? error.message : 'Unknown error' ); @@ -192,11 +194,11 @@ export const XtreamStore = signalStore( }, addToFavorites(item: unknown): void { - console.log('Legacy addToFavorites called', item); + logger.debug('Legacy addToFavorites called', item); }, removeFromFavorites(favoriteId: string): void { - console.log('Legacy removeFromFavorites called', favoriteId); + logger.debug('Legacy removeFromFavorites called', favoriteId); }, // Alias methods for backward compatibility diff --git a/apps/web/src/app/xtream-tauri/vod-details/vod-details-route.component.ts b/apps/web/src/app/xtream-tauri/vod-details/vod-details-route.component.ts index dedcd17f4..10058bef8 100644 --- a/apps/web/src/app/xtream-tauri/vod-details/vod-details-route.component.ts +++ b/apps/web/src/app/xtream-tauri/vod-details/vod-details-route.component.ts @@ -10,6 +10,7 @@ import { DownloadsService } from '../../services/downloads.service'; import { SettingsStore } from '../../services/settings-store.service'; import { XtreamStore } from '../stores/xtream.store'; import { SafePipe } from '@iptvnator/pipes'; +import { createLogger } from '../../shared/utils/logger'; /** * Route-based VOD details container for Xtream. @@ -39,6 +40,7 @@ export class VodDetailsRouteComponent implements OnInit, OnDestroy { private route = inject(ActivatedRoute); private readonly xtreamStore = inject(XtreamStore); private readonly downloadsService = inject(DownloadsService); + private readonly logger = createLogger('VodDetailsRoute'); readonly theme = this.settingsStore.theme; readonly isElectron = this.downloadsService.isAvailable; @@ -51,9 +53,10 @@ export class VodDetailsRouteComponent implements OnInit, OnDestroy { readonly hasPlaybackPosition = computed(() => { const vodId = this.route.snapshot.params.vodId; const inProgress = this.xtreamStore.isInProgress(Number(vodId), 'vod'); - console.log( - `[VodDetails] hasPlaybackPosition check: vodId=${vodId}, inProgress=${inProgress}` - ); + this.logger.debug('hasPlaybackPosition check', { + vodId, + inProgress, + }); return inProgress; }); @@ -106,7 +109,7 @@ export class VodDetailsRouteComponent implements OnInit, OnDestroy { vodItem.movie_data?.stream_id || (vodItem as any).stream_id ); - console.log(`[VodDetails] playVod: Resolved ID=${id} for item`, vodItem); + this.logger.debug('playVod resolved ID', { id, vodItem }); const contentInfo = { playlistId: this.xtreamStore.currentPlaylist().id, diff --git a/apps/web/src/app/xtream-tauri/with-favorites.feature.ts b/apps/web/src/app/xtream-tauri/with-favorites.feature.ts index 2b6e21dae..f4ee5bd07 100644 --- a/apps/web/src/app/xtream-tauri/with-favorites.feature.ts +++ b/apps/web/src/app/xtream-tauri/with-favorites.feature.ts @@ -7,8 +7,10 @@ import { } from '@ngrx/signals'; import { DatabaseService } from 'services'; import { FavoritesService } from './services/favorites.service'; +import { createLogger } from '../shared/utils/logger'; export const withFavorites = function () { + const logger = createLogger('withFavorites'); return signalStoreFeature( withState({ isFavorite: false, @@ -30,8 +32,8 @@ export const withFavorites = function () { playlistId ); if (!content) { - console.error( - 'Content not found for xtream ID:', + logger.error( + 'Content not found for xtream ID', xtreamId ); return false; diff --git a/apps/web/src/app/xtream-tauri/with-recent-items.ts b/apps/web/src/app/xtream-tauri/with-recent-items.ts index 284568b91..68753ad31 100644 --- a/apps/web/src/app/xtream-tauri/with-recent-items.ts +++ b/apps/web/src/app/xtream-tauri/with-recent-items.ts @@ -8,6 +8,7 @@ import { import { rxMethod } from '@ngrx/signals/rxjs-interop'; import { pipe, switchMap, tap } from 'rxjs'; import { DatabaseService } from 'services'; +import { createLogger } from '../shared/utils/logger'; export interface RecentlyViewedItem { id: number; @@ -22,6 +23,7 @@ export interface RecentlyViewedItem { } export const withRecentItems = function () { + const logger = createLogger('withRecentItems'); return signalStoreFeature( withState({ recentItems: [], @@ -110,7 +112,7 @@ export const withRecentItems = function () { recentItems: items || [], }); } catch (error) { - console.error('Error loading global recent items:', error); + logger.error('Error loading global recent items', error); patchState(store, { recentItems: [] }); } }, @@ -119,10 +121,7 @@ export const withRecentItems = function () { await dbService.clearGlobalRecentlyViewed(); patchState(store, { recentItems: [] }); } catch (error) { - console.error( - 'Error clearing global recently viewed:', - error - ); + logger.error('Error clearing global recently viewed', error); } }, })) From b262b98df42bbe62e57f94273be7cebf4255d62b Mon Sep 17 00:00:00 2001 From: 4gray Date: Sat, 14 Feb 2026 11:46:15 +0100 Subject: [PATCH 05/12] refactor(stalker-vod): implement utility functions and tests for handling VOD details and favorites --- .../src/app/stalker/stalker-vod.utils.spec.ts | 109 ++++++ apps/web/src/app/stalker/stalker-vod.utils.ts | 328 ++++++++++++++++++ 2 files changed, 437 insertions(+) create mode 100644 apps/web/src/app/stalker/stalker-vod.utils.spec.ts create mode 100644 apps/web/src/app/stalker/stalker-vod.utils.ts diff --git a/apps/web/src/app/stalker/stalker-vod.utils.spec.ts b/apps/web/src/app/stalker/stalker-vod.utils.spec.ts new file mode 100644 index 000000000..09fc0141c --- /dev/null +++ b/apps/web/src/app/stalker/stalker-vod.utils.spec.ts @@ -0,0 +1,109 @@ +import { VodDetailsItem } from 'shared-interfaces'; +import { + createStalkerDetailViewState, + toggleStalkerVodFavorite, +} from './stalker-vod.utils'; + +describe('stalker-vod.utils regressions', () => { + it('routes embedded series[] items to series view state', () => { + const state = createStalkerDetailViewState( + { + id: '101', + cmd: '/media/file_101.mpg', + series: [1, 2, 3], + info: { + name: 'Series in VOD', + movie_image: '', + description: '', + actors: '', + director: '', + releasedate: '', + genre: '', + rating_imdb: '', + rating_kinopoisk: '', + }, + }, + 'playlist-1' + ); + + expect(state.itemDetails?.series?.length).toBe(3); + expect(state.vodDetailsItem).toBeNull(); + }); + + it('routes is_series=1 items to series view state (lazy season flow)', () => { + const state = createStalkerDetailViewState( + { + id: '1507', + cmd: '/media/file_1507.mpg', + is_series: true, + info: { + name: 'Flagged Series', + movie_image: '', + description: '', + actors: '', + director: '', + releasedate: '', + genre: '', + rating_imdb: '', + rating_kinopoisk: '', + }, + }, + 'playlist-1' + ); + + expect(state.itemDetails?.is_series).toBe(true); + expect(state.vodDetailsItem).toBeNull(); + }); + + it('favorite toggle uses completion callback path without delayed state update', () => { + const addToFavorites = jest.fn( + (_item: Record, onDone?: () => void) => { + onDone?.(); + } + ); + const removeFromFavorites = jest.fn( + (_id: string, onDone?: () => void) => { + onDone?.(); + } + ); + const onComplete = jest.fn(); + + const item = { + type: 'stalker', + data: { + id: '42', + cmd: '/media/file_42.mpg', + info: { + name: 'Movie', + movie_image: '', + description: '', + actors: '', + director: '', + releasedate: '', + genre: '', + rating_imdb: '', + rating_kinopoisk: '', + }, + }, + playlistId: 'playlist-1', + vodId: 42, + cmd: '/media/file_42.mpg', + } as unknown as VodDetailsItem; + + toggleStalkerVodFavorite( + { item, isFavorite: true }, + { addToFavorites, removeFromFavorites, onComplete } + ); + expect(addToFavorites).toHaveBeenCalledTimes(1); + expect(removeFromFavorites).not.toHaveBeenCalled(); + expect(onComplete).toHaveBeenCalledTimes(1); + + onComplete.mockClear(); + toggleStalkerVodFavorite( + { item, isFavorite: false }, + { addToFavorites, removeFromFavorites, onComplete } + ); + expect(removeFromFavorites).toHaveBeenCalledTimes(1); + expect(onComplete).toHaveBeenCalledTimes(1); + }); +}); diff --git a/apps/web/src/app/stalker/stalker-vod.utils.ts b/apps/web/src/app/stalker/stalker-vod.utils.ts new file mode 100644 index 000000000..30fd731c1 --- /dev/null +++ b/apps/web/src/app/stalker/stalker-vod.utils.ts @@ -0,0 +1,328 @@ +import { + signal, + WritableSignal, +} from '@angular/core'; +import { rxResource } from '@angular/core/rxjs-interop'; +import { Observable } from 'rxjs'; +import { PlaylistsService } from 'services'; +import { + createStalkerVodItem, + StalkerVodDetails, + StalkerVodInfo, + VodDetailsItem, +} from 'shared-interfaces'; +import { + StalkerFavoriteItem, + StalkerSelectedVodItem, + StalkerSeriesFlag, + StalkerVodSource, +} from './models/stalker-favorite-item.interface'; + +export interface StalkerDetailViewState { + itemDetails: StalkerSelectedVodItem | null; + vodDetailsItem: VodDetailsItem | null; +} + +export interface StalkerFavoriteToggleEvent { + item: VodDetailsItem; + isFavorite: boolean; +} + +export interface StalkerFavoriteToggleOptions { + addToFavorites: ( + item: Record, + onComplete?: () => void + ) => void; + removeFromFavorites: ( + favoriteId: string, + onComplete?: () => void + ) => void; + onComplete?: () => void; +} + +export function isStalkerSeriesFlag(value: unknown): boolean { + return value === true || value === 1 || value === '1'; +} + +export function normalizeStalkerSeriesFlag( + value: unknown +): StalkerSeriesFlag | undefined { + if (value === true || value === 1 || value === '1') { + return true; + } + return undefined; +} + +export function isStalkerSeriesItem(item: { + is_series?: unknown; + series?: unknown; +}): boolean { + return ( + isStalkerSeriesFlag(item?.is_series) || + (Array.isArray(item?.series) && item.series.length > 0) + ); +} + +export function buildStalkerFavoritePayload( + data: StalkerVodDetails +): Record { + return { + ...data, + category_id: 'vod', + title: data.info?.name, + cover: data.info?.movie_image, + added_at: new Date().toISOString(), + }; +} + +export function matchesFavoriteById( + favorite: Partial, + itemId: string | number +): boolean { + const expectedId = normalizeStalkerEntityId(itemId); + return ( + normalizeStalkerEntityId(favorite?.id) === expectedId || + normalizeStalkerEntityId(favorite?.movie_id) === expectedId || + normalizeStalkerEntityId(favorite?.stream_id) === expectedId || + normalizeStalkerEntityId(favorite?.series_id) === expectedId + ); +} + +export function normalizeStalkerEntityId(value: unknown): string { + if (value === null || value === undefined) { + return ''; + } + return String(value).trim(); +} + +export function normalizeStalkerEntityIdAsNumber( + value: unknown +): number | null { + const normalized = normalizeStalkerEntityId(value); + if (!normalized) { + return null; + } + const parsed = Number(normalized); + return Number.isFinite(parsed) ? parsed : null; +} + +function toStringOrFallback(value: unknown, fallback = ''): string { + if (typeof value === 'string') return value; + if (value === null || value === undefined) return fallback; + return String(value); +} + +function toOptionalString(value: unknown): string | undefined { + if (value === null || value === undefined || value === '') { + return undefined; + } + return String(value); +} + +export function createStalkerInfo(item: StalkerVodSource): StalkerVodInfo { + const info = item.info ?? {}; + + return { + name: toStringOrFallback( + info.name ?? info.o_name ?? item.o_name ?? item.name ?? item.title, + 'Unknown' + ), + o_name: toOptionalString(info.o_name ?? item.o_name), + movie_image: toStringOrFallback( + info.movie_image ?? item.cover ?? item.screenshot_uri ?? item.logo + ), + description: toStringOrFallback(info.description ?? item.description), + actors: toStringOrFallback(info.actors ?? item.actors), + director: toStringOrFallback(info.director ?? item.director), + releasedate: toStringOrFallback( + info.releasedate ?? item.releasedate ?? item.year + ), + genre: toStringOrFallback(info.genre ?? item.genre ?? item.genres_str), + rating_imdb: toStringOrFallback(info.rating_imdb ?? item.rating_imdb), + rating_kinopoisk: toStringOrFallback( + info.rating_kinopoisk ?? item.rating_kinopoisk + ), + }; +} + +export function buildStalkerSelectedVodItem( + item: StalkerVodSource, + forceSeries = false +): StalkerSelectedVodItem { + return { + id: toStringOrFallback(item.id ?? item.stream_id), + cmd: toStringOrFallback(item.cmd), + series: item.series, + has_files: item.has_files, + is_series: + forceSeries || isStalkerSeriesFlag(item?.is_series) + ? true + : undefined, + video_id: item.video_id, + category_id: + typeof item.category_id === 'string' + ? item.category_id + : undefined, + info: createStalkerInfo(item), + }; +} + +export interface NormalizedStalkerFavoriteItem extends StalkerFavoriteItem { + details: StalkerSelectedVodItem; +} + +export function normalizeStalkerFavoriteItem( + item: StalkerFavoriteItem +): NormalizedStalkerFavoriteItem { + if (item.details && item.details.info) { + return item as NormalizedStalkerFavoriteItem; + } + + const source = (item.details || item) as StalkerVodSource; + const info = createStalkerInfo({ + ...item, + ...source, + }); + const normalizedIsSeries = normalizeStalkerSeriesFlag( + (item as StalkerVodSource).is_series ?? source.is_series + ); + + return { + ...item, + details: { + ...source, + info, + cmd: toStringOrFallback( + (item as StalkerVodSource).cmd ?? source.cmd + ), + id: toStringOrFallback(item.stream_id ?? item.id ?? source.id), + series: (item as StalkerVodSource).series ?? source.series, + is_series: normalizedIsSeries ? true : undefined, + video_id: + (item as StalkerVodSource).video_id ?? source.video_id, + category_id: + typeof (item as StalkerVodSource).category_id === 'string' + ? (item as StalkerVodSource).category_id + : typeof source.category_id === 'string' + ? source.category_id + : undefined, + }, + }; +} + +export function normalizeStalkerVodDetailsItem( + item: StalkerVodSource +): StalkerSelectedVodItem { + return { + ...buildStalkerSelectedVodItem(item), + info: createStalkerInfo(item), + is_series: normalizeStalkerSeriesFlag(item?.is_series) + ? true + : undefined, + }; +} + +export function createStalkerDetailViewState( + normalizedItem: StalkerSelectedVodItem, + playlistId: string +): StalkerDetailViewState { + if (isStalkerSeriesItem(normalizedItem)) { + return { + itemDetails: normalizedItem, + vodDetailsItem: null, + }; + } + + return { + itemDetails: normalizedItem, + vodDetailsItem: createStalkerVodItem( + normalizedItem as StalkerVodDetails, + playlistId + ), + }; +} + +export function clearStalkerDetailViewState(): StalkerDetailViewState { + return { + itemDetails: null, + vodDetailsItem: null, + }; +} + +export function createRefreshTrigger(): { + refreshVersion: WritableSignal; + refresh: () => void; +} { + const refreshVersion = signal(Date.now()); + return { + refreshVersion, + refresh: () => refreshVersion.set(Date.now()), + }; +} + +export function createPortalFavoritesResource( + playlistService: PlaylistsService, + getPortalId: () => string | undefined, + getRefreshVersion: () => number +) { + return createPortalCollectionResource( + playlistService, + getPortalId, + getRefreshVersion, + (service, portalId) => service.getPortalFavorites(portalId) + ); +} + +export function createPortalCollectionResource( + playlistService: PlaylistsService, + getPortalId: () => string | undefined, + getRefreshVersion: () => number, + streamFactory: ( + playlistService: PlaylistsService, + portalId: string + ) => Observable +) { + return rxResource({ + params: () => { + const portalId = getPortalId(); + if (!portalId) return undefined; + return { + portalId, + refreshVersion: getRefreshVersion(), + }; + }, + stream: ({ params }) => + streamFactory(playlistService, params.portalId), + }); +} + +export function isSelectedStalkerVodFavorite( + item: VodDetailsItem | null, + favorites: ReadonlyArray | undefined +): boolean { + if (!item || item.type !== 'stalker') { + return false; + } + + return (favorites ?? []).some((favorite) => + matchesFavoriteById(favorite, item.data.id) + ); +} + +export function toggleStalkerVodFavorite( + event: StalkerFavoriteToggleEvent, + options: StalkerFavoriteToggleOptions +): void { + if (event.item.type !== 'stalker') { + return; + } + + if (event.isFavorite) { + options.addToFavorites( + buildStalkerFavoritePayload(event.item.data), + options.onComplete + ); + } else { + options.removeFromFavorites(event.item.data.id, options.onComplete); + } +} From 29552b6e7d15b0941b9093886a31d5557194cb0f Mon Sep 17 00:00:00 2001 From: 4gray Date: Sat, 14 Feb 2026 11:46:52 +0100 Subject: [PATCH 06/12] refactor(stalker-models): add new interfaces for category, content, ITV channel, and VOD series; update favorite item interface --- apps/web/src/app/stalker/models/index.ts | 4 ++ .../models/stalker-category-item.interface.ts | 4 ++ .../models/stalker-content-item.interface.ts | 9 +++ .../models/stalker-favorite-item.interface.ts | 72 +++++++++++++++++-- .../models/stalker-itv-channel.interface.ts | 13 ++++ .../models/stalker-vod-series.interface.ts | 24 +++++++ 6 files changed, 120 insertions(+), 6 deletions(-) create mode 100644 apps/web/src/app/stalker/models/stalker-category-item.interface.ts create mode 100644 apps/web/src/app/stalker/models/stalker-content-item.interface.ts create mode 100644 apps/web/src/app/stalker/models/stalker-itv-channel.interface.ts create mode 100644 apps/web/src/app/stalker/models/stalker-vod-series.interface.ts diff --git a/apps/web/src/app/stalker/models/index.ts b/apps/web/src/app/stalker/models/index.ts index 95c1ca9e1..3ab94b330 100644 --- a/apps/web/src/app/stalker/models/index.ts +++ b/apps/web/src/app/stalker/models/index.ts @@ -1,4 +1,8 @@ export * from './stalker-favorite-item.interface'; +export * from './stalker-category-item.interface'; +export * from './stalker-content-item.interface'; +export * from './stalker-itv-channel.interface'; export * from './stalker-season.interface'; export * from './stalker-serial-details.interface'; export * from './stalker-vod-details.interface'; +export * from './stalker-vod-series.interface'; diff --git a/apps/web/src/app/stalker/models/stalker-category-item.interface.ts b/apps/web/src/app/stalker/models/stalker-category-item.interface.ts new file mode 100644 index 000000000..60d729ca4 --- /dev/null +++ b/apps/web/src/app/stalker/models/stalker-category-item.interface.ts @@ -0,0 +1,4 @@ +export interface StalkerCategoryItem { + category_id: string; + category_name: string; +} diff --git a/apps/web/src/app/stalker/models/stalker-content-item.interface.ts b/apps/web/src/app/stalker/models/stalker-content-item.interface.ts new file mode 100644 index 000000000..4af45d37d --- /dev/null +++ b/apps/web/src/app/stalker/models/stalker-content-item.interface.ts @@ -0,0 +1,9 @@ +import { StalkerVodSource } from './stalker-favorite-item.interface'; + +/** + * Generic content item returned by Stalker category content endpoints. + * Used for VOD, series, and ITV category lists. + */ +export interface StalkerContentItem extends StalkerVodSource { + cover?: string; +} diff --git a/apps/web/src/app/stalker/models/stalker-favorite-item.interface.ts b/apps/web/src/app/stalker/models/stalker-favorite-item.interface.ts index 46536c288..7f767319b 100644 --- a/apps/web/src/app/stalker/models/stalker-favorite-item.interface.ts +++ b/apps/web/src/app/stalker/models/stalker-favorite-item.interface.ts @@ -1,7 +1,67 @@ -export interface StalkerFavoriteItem { - id?: string; - stream_id?: string; - movie_id?: string; - name: string; - details?: any; +import { + StalkerVodDetails, + StalkerVodInfo, +} from 'shared-interfaces'; + +export type StalkerSeriesFlag = true | 1 | '1'; + +/** + * Loose source shape coming from Stalker responses / favorites payloads. + * Keep optional to model heterogeneous portal responses. + */ +export interface StalkerVodSource { + id?: string | number; + stream_id?: string | number; + movie_id?: string | number; + series_id?: string | number; + cmd?: string; + series?: unknown[]; + has_files?: number; + is_series?: boolean | number | string | null | undefined; + video_id?: string; + category_id?: string; + title?: string; + cover?: string; + logo?: string; + screenshot_uri?: string; + name?: string; + o_name?: string; + description?: string; + actors?: string; + director?: string; + releasedate?: string; + year?: string; + genre?: string; + genres_str?: string; + rating_imdb?: string | number; + rating_kinopoisk?: string | number; + info?: Partial | null; +} + +/** + * Normalized VOD item used by Stalker detail/favorites flows. + */ +export interface StalkerSelectedVodItem extends StalkerVodDetails { + series?: unknown[]; + has_files?: number; + is_series?: true; + video_id?: string; + category_id?: string; +} + +/** + * Favorite item persisted for Stalker portal. + */ +export interface StalkerFavoriteItem { + id?: string | number; + stream_id?: string | number; + movie_id?: string | number; + series_id?: string | number; + category_id?: string; + cmd?: string; + name?: string; + o_name?: string; + logo?: string; + details?: StalkerSelectedVodItem; + [key: string]: unknown; } diff --git a/apps/web/src/app/stalker/models/stalker-itv-channel.interface.ts b/apps/web/src/app/stalker/models/stalker-itv-channel.interface.ts new file mode 100644 index 000000000..9fae5e652 --- /dev/null +++ b/apps/web/src/app/stalker/models/stalker-itv-channel.interface.ts @@ -0,0 +1,13 @@ +import { StalkerVodSource } from './stalker-favorite-item.interface'; + +/** + * ITV/live channel shape used by Stalker live TV flow. + * Stalker channel payloads are heterogeneous, so this extends the shared source shape. + */ +export interface StalkerItvChannel extends StalkerVodSource { + id: string | number; + cmd: string; + name?: string; + o_name?: string; + logo?: string; +} diff --git a/apps/web/src/app/stalker/models/stalker-vod-series.interface.ts b/apps/web/src/app/stalker/models/stalker-vod-series.interface.ts new file mode 100644 index 000000000..24fe9aca6 --- /dev/null +++ b/apps/web/src/app/stalker/models/stalker-vod-series.interface.ts @@ -0,0 +1,24 @@ +/** + * Season item returned by Stalker VOD-series (Ministra is_series=1) endpoints. + */ +export interface StalkerVodSeriesSeason { + id: string; + video_id: string; + season_number?: string; + name?: string; + is_season?: boolean; +} + +/** + * Episode item returned by Stalker VOD-series season endpoints. + */ +export interface StalkerVodSeriesEpisode { + id: string; + series_number?: number; + episode_num?: number; + name?: string; + cover?: string; + description?: string; + duration?: number | string; + is_episode?: boolean; +} From f0be21486d32e4090162f6f610a901ebfebdc597 Mon Sep 17 00:00:00 2001 From: 4gray Date: Sat, 14 Feb 2026 11:47:36 +0100 Subject: [PATCH 07/12] refactor(stalker): add VOD series adapters and tests - Implemented `stalker-series.adapters.ts` for mapping VOD series seasons and episodes. - Added utility functions for handling VOD series items and mapping to view models. - Created unit tests for the new adapter functions in `stalker-series.adapters.spec.ts`. - Updated `stalker.store.ts` to manage VOD series state and handle favorites. - Enhanced `category-content-view` component to support favorite functionality for VOD items. - Refactored favorite management in `playlists.service.ts` to ensure proper ID handling. --- .../favorites-button.component.ts | 41 ++- .../recently-viewed.component.ts | 155 +++++----- .../stalker-favorites.component.html | 14 +- .../stalker-favorites.component.ts | 171 +++++------ .../stalker-live-stream-layout.component.ts | 63 ++-- .../stalker-search.component.html | 1 + .../stalker-search.component.ts | 136 +++++---- .../stalker-series-view.component.html | 14 +- .../stalker-series-view.component.ts | 288 +++++------------- .../stalker/stalker-series.adapters.spec.ts | 90 ++++++ .../app/stalker/stalker-series.adapters.ts | 232 ++++++++++++++ apps/web/src/app/stalker/stalker.store.ts | 251 ++++++++++----- .../category-content-view.component.html | 1 + .../category-content-view.component.ts | 107 ++++--- libs/services/src/lib/playlists.service.ts | 28 +- 15 files changed, 967 insertions(+), 625 deletions(-) create mode 100644 apps/web/src/app/stalker/stalker-series.adapters.spec.ts create mode 100644 apps/web/src/app/stalker/stalker-series.adapters.ts diff --git a/apps/web/src/app/stalker/favorites-button/favorites-button.component.ts b/apps/web/src/app/stalker/favorites-button/favorites-button.component.ts index 9e4213dc0..f5332c020 100644 --- a/apps/web/src/app/stalker/favorites-button/favorites-button.component.ts +++ b/apps/web/src/app/stalker/favorites-button/favorites-button.component.ts @@ -1,10 +1,11 @@ import { AsyncPipe } from '@angular/common'; -import { Component, inject, input, output } from '@angular/core'; +import { Component, inject, input } from '@angular/core'; import { MatIconModule } from '@angular/material/icon'; import { TranslateModule } from '@ngx-translate/core'; import { BehaviorSubject, map, switchMap } from 'rxjs'; import { PlaylistsService } from 'services'; import { StalkerStore } from '../stalker.store'; +import { matchesFavoriteById } from '../stalker-vod.utils'; @Component({ selector: 'app-favorites-button', @@ -17,9 +18,7 @@ export class FavoritesButtonComponent { private stalkerStore = inject(StalkerStore); readonly itemId = input.required(); - - readonly addToFavoritesClicked = output(); - readonly removeFromFavoritesClicked = output(); + readonly item = input.required(); private readonly favoritesChanged$ = new BehaviorSubject(undefined); @@ -30,25 +29,39 @@ export class FavoritesButtonComponent { ) ), map((favorites) => - favorites.some( - (i) => - (i as any).movie_id === this.itemId() || - (i as any).id === this.itemId() + favorites.some((favorite) => + matchesFavoriteById(favorite, this.itemId()) ) ) ); removeFromFavorites() { - this.removeFromFavoritesClicked.emit(); - setTimeout(() => { + this.stalkerStore.removeFromFavorites(this.itemId(), () => { this.favoritesChanged$.next(); - }, 100); + }); } addToFavorites() { - this.addToFavoritesClicked.emit(); - setTimeout(() => { + const item = this.item(); + this.stalkerStore.addToFavorites( + { + ...item, + title: item.info?.name ?? item.name ?? item.o_name, + cover: item.info?.movie_image ?? item.cover, + series_id: item.id, + added_at: new Date().toISOString(), + category_id: 'series', + }, + () => { + this.favoritesChanged$.next(); + } + ); + } + + forceRefresh() { + // Exposed for callers that need manual refresh after external updates. + if (this.stalkerStore.currentPlaylist()?._id) { this.favoritesChanged$.next(); - }, 100); + } } } diff --git a/apps/web/src/app/stalker/recently-viewed/recently-viewed.component.ts b/apps/web/src/app/stalker/recently-viewed/recently-viewed.component.ts index 5e21ebe57..51965e356 100644 --- a/apps/web/src/app/stalker/recently-viewed/recently-viewed.component.ts +++ b/apps/web/src/app/stalker/recently-viewed/recently-viewed.component.ts @@ -1,16 +1,27 @@ import { Component, computed, inject, signal } from '@angular/core'; -import { rxResource } from '@angular/core/rxjs-interop'; import { TranslateService } from '@ngx-translate/core'; import { PlaylistsService } from 'services'; import { VodDetailsItem, - StalkerVodDetails, - createStalkerVodItem, } from 'shared-interfaces'; import { FavoritesLayoutComponent } from '../../shared/components/favorites-layout/favorites-layout.component'; import { VodDetailsComponent } from '../../xtream/vod-details/vod-details.component'; import { StalkerSeriesViewComponent } from '../stalker-series-view/stalker-series-view.component'; import { StalkerStore } from '../stalker.store'; +import { createLogger } from '../../shared/utils/logger'; +import { + StalkerSelectedVodItem, + StalkerVodSource, +} from '../models'; +import { + createPortalCollectionResource, + normalizeStalkerEntityIdAsNumber, + toggleStalkerVodFavorite, + clearStalkerDetailViewState, + createStalkerDetailViewState, + createRefreshTrigger, + normalizeStalkerVodDetailsItem, +} from '../stalker-vod.utils'; @Component({ selector: 'app-recently-viewed', @@ -36,28 +47,29 @@ import { StalkerStore } from '../stalker.store'; ], }) export class RecentlyViewedComponent { + private static isCategoryType( + value: string + ): value is 'vod' | 'series' | 'itv' { + return value === 'vod' || value === 'series' || value === 'itv'; + } private readonly playlistService = inject(PlaylistsService); - private readonly refreshTimestamp = signal(Date.now()); + private readonly collectionRefresh = createRefreshTrigger(); private readonly stalkerStore = inject(StalkerStore); private readonly translate = inject(TranslateService); + private readonly logger = createLogger('StalkerRecentlyViewed'); - itemDetails: any = null; + itemDetails: (StalkerVodSource & { category_id?: string }) | null = null; vodDetailsItem: VodDetailsItem | null = null; readonly currentPlaylist = this.stalkerStore.currentPlaylist; - readonly allFavorites = rxResource({ - params: () => { - const portalId = this.stalkerStore.currentPlaylist()?._id; - if (!portalId) return undefined; - return { - portalId, - refreshTimestamp: this.refreshTimestamp(), - }; - }, - stream: ({ params }) => - this.playlistService.getPortalRecentlyViewed(params.portalId), - }); + readonly allFavorites = createPortalCollectionResource( + this.playlistService, + () => this.stalkerStore.currentPlaylist()?._id, + () => this.collectionRefresh.refreshVersion(), + (playlistService, portalId) => + playlistService.getPortalRecentlyViewed(portalId) + ); readonly categories = computed(() => [ { @@ -119,18 +131,23 @@ export class RecentlyViewedComponent { this.allFavorites.value()?.filter((item) => item.category_id === 'itv') ); - removeFromRecentlyViewed(item: any) { - this.stalkerStore.removeFromRecentlyViewed(item.id); - setTimeout(() => { - this.refreshTimestamp.set(Date.now()); - }, 100); + removeFromRecentlyViewed(item: Pick) { + const id = normalizeStalkerEntityIdAsNumber(item.id); + if (id === null) return; + this.stalkerStore.removeFromRecentlyViewed(id, () => { + this.collectionRefresh.refresh(); + }); } - setCategoryId(categoryId: any) { + setCategoryId(categoryId: string) { this.selectedCategoryId.set(categoryId); } - openItem(item: any) { + openItem(item: StalkerVodSource & { category_id: string }) { + if (!RecentlyViewedComponent.isCategoryType(item.category_id)) { + return; + } + this.stalkerStore.setSelectedContentType(item.category_id); switch (item.category_id) { case 'itv': @@ -139,24 +156,16 @@ export class RecentlyViewedComponent { case 'vod': // Normalize the item to ensure is_series flag is properly set const normalizedItem = this.normalizeRecentItem(item); - this.itemDetails = normalizedItem; - this.stalkerStore.setSelectedItem(normalizedItem); - - // Check if this VOD item is actually a series (Ministra is_series=1) - const isVodSeries = - normalizedItem.is_series === true || - normalizedItem.series?.length > 0; - - if (isVodSeries) { - // VOD series - will be rendered as series view - this.vodDetailsItem = null; - } else { - // Regular VOD - create VodDetailsItem for the component - this.vodDetailsItem = createStalkerVodItem( - normalizedItem as StalkerVodDetails, - this.currentPlaylist()?._id ?? '' - ); - } + const detailViewState = createStalkerDetailViewState( + normalizedItem, + this.currentPlaylist()?._id ?? '' + ); + this.itemDetails = { + ...(detailViewState.itemDetails ?? normalizedItem), + category_id: 'vod', + }; + this.vodDetailsItem = detailViewState.vodDetailsItem; + this.stalkerStore.setSelectedItem(detailViewState.itemDetails); break; case 'series': this.itemDetails = item; @@ -180,25 +189,18 @@ export class RecentlyViewedComponent { /** Handle favorite toggle from vod-details component */ onVodFavoriteToggled(event: { item: VodDetailsItem; isFavorite: boolean }): void { - if (event.item.type === 'stalker') { - if (event.isFavorite) { - this.addToFavorites({ - ...event.item.data, - category_id: 'vod', - title: event.item.data.info?.name, - cover: event.item.data.info?.movie_image, - added_at: new Date().toISOString(), - }); - } else { - this.removeFromFavorites(event.item.data.id); - } - } + toggleStalkerVodFavorite(event, { + addToFavorites: (item, onDone) => this.addToFavorites(item, onDone), + removeFromFavorites: (favoriteId, onDone) => + this.removeFromFavorites(favoriteId, onDone), + }); } /** Handle back from vod-details component */ onVodBack(): void { - this.itemDetails = null; - this.vodDetailsItem = null; + const cleared = clearStalkerDetailViewState(); + this.itemDetails = cleared.itemDetails; + this.vodDetailsItem = cleared.vodDetailsItem; } async createLinkToPlayVodItv( @@ -206,44 +208,27 @@ export class RecentlyViewedComponent { title?: string, thumbnail?: string ) { - console.debug('Create link to play VOD/ITV', cmd, title, thumbnail); + this.logger.debug('Create link to play VOD/ITV', { + cmd, + title, + thumbnail, + }); await this.stalkerStore.createLinkToPlayVod(cmd, title, thumbnail); } - addToFavorites(item: any) { - this.stalkerStore.addToFavorites(item); + addToFavorites(item: Record, onDone?: () => void) { + this.stalkerStore.addToFavorites(item, onDone); } - removeFromFavorites(favoriteId: string) { - this.stalkerStore.removeFromFavorites(favoriteId); + removeFromFavorites(favoriteId: string, onDone?: () => void) { + this.stalkerStore.removeFromFavorites(favoriteId, onDone); } /** * Normalize recent item to ensure is_series flag is properly set * and all required fields are present for the series view */ - private normalizeRecentItem(item: any): any { - // Normalize is_series to boolean true (can be 1, "1", or true) - const rawIsSeries = item.is_series; - const normalizedIsSeries = - rawIsSeries === true || - rawIsSeries === 1 || - rawIsSeries === '1'; - - // Ensure info object exists - const info = item.info || { - name: item.o_name || item.name || item.title || 'Unknown', - o_name: item.o_name, - movie_image: item.cover || item.screenshot_uri || item.logo, - description: item.description, - }; - - return { - ...item, - info: info, - is_series: normalizedIsSeries ? true : undefined, - // Preserve video_id for season fetching - video_id: item.video_id, - }; + private normalizeRecentItem(item: StalkerVodSource): StalkerSelectedVodItem { + return normalizeStalkerVodDetailsItem(item); } } diff --git a/apps/web/src/app/stalker/stalker-favorites/stalker-favorites.component.html b/apps/web/src/app/stalker/stalker-favorites/stalker-favorites.component.html index 5e26b7692..7475827e4 100644 --- a/apps/web/src/app/stalker/stalker-favorites/stalker-favorites.component.html +++ b/apps/web/src/app/stalker/stalker-favorites/stalker-favorites.component.html @@ -5,22 +5,18 @@ - } @else if ( - itemDetails.details?.is_series === true || - itemDetails.details?.is_series === 1 || - itemDetails.details?.is_series === '1' - ) { + } @else if (itemDetails.details?.is_series === true) { } @else if (vodDetailsItem) { + } } } @else { diff --git a/apps/web/src/app/stalker/stalker-favorites/stalker-favorites.component.ts b/apps/web/src/app/stalker/stalker-favorites/stalker-favorites.component.ts index e9d3b8037..03f283742 100644 --- a/apps/web/src/app/stalker/stalker-favorites/stalker-favorites.component.ts +++ b/apps/web/src/app/stalker/stalker-favorites/stalker-favorites.component.ts @@ -1,16 +1,27 @@ -import { Component, computed, inject, signal } from '@angular/core'; -import { rxResource } from '@angular/core/rxjs-interop'; +import { Component, computed, effect, inject, signal } from '@angular/core'; import { TranslateService } from '@ngx-translate/core'; import { PlaylistsService } from 'services'; import { VodDetailsItem, - StalkerVodDetails, - createStalkerVodItem, } from 'shared-interfaces'; import { FavoritesLayoutComponent } from '../../shared/components/favorites-layout/favorites-layout.component'; import { VodDetailsComponent } from '../../xtream/vod-details/vod-details.component'; import { StalkerSeriesViewComponent } from '../stalker-series-view/stalker-series-view.component'; import { StalkerStore } from '../stalker.store'; +import { createLogger } from '../../shared/utils/logger'; +import { + StalkerFavoriteItem, +} from '../models'; +import { + clearStalkerDetailViewState, + createPortalFavoritesResource, + createRefreshTrigger, + createStalkerDetailViewState, + normalizeStalkerEntityId, + NormalizedStalkerFavoriteItem, + normalizeStalkerFavoriteItem, + isSelectedStalkerVodFavorite, +} from '../stalker-vod.utils'; @Component({ selector: 'app-stalker-favorites', @@ -37,27 +48,22 @@ import { StalkerStore } from '../stalker.store'; }) export class StalkerFavoritesComponent { private readonly playlistService = inject(PlaylistsService); - private readonly refreshTimestamp = signal(Date.now()); + private readonly favoritesRefresh = createRefreshTrigger(); private readonly stalkerStore = inject(StalkerStore); private readonly translate = inject(TranslateService); + private readonly logger = createLogger('StalkerFavorites'); - itemDetails: any = null; + itemDetails: NormalizedStalkerFavoriteItem | null = null; vodDetailsItem: VodDetailsItem | null = null; + readonly isSelectedVodFavorite = signal(false); readonly currentPlaylist = this.stalkerStore.currentPlaylist; - readonly allFavorites = rxResource({ - params: () => { - const portalId = this.stalkerStore.currentPlaylist()?._id; - if (!portalId) return undefined; - return { - portalId, - refreshTimestamp: this.refreshTimestamp(), - }; - }, - stream: ({ params }) => - this.playlistService.getPortalFavorites(params.portalId), - }); + readonly allFavorites = createPortalFavoritesResource( + this.playlistService, + () => this.stalkerStore.currentPlaylist()?._id, + () => this.favoritesRefresh.refreshVersion() + ); readonly categories = computed(() => [ { @@ -119,20 +125,31 @@ export class StalkerFavoritesComponent { this.allFavorites.value()?.filter((item) => item.category_id === 'itv') ); - removeFromFavorites(item: any) { - this.stalkerStore.removeFromFavorites(item.id); - setTimeout(() => { - this.refreshTimestamp.set(Date.now()); - }, 100); + constructor() { + effect(() => { + this.allFavorites.value(); + this.syncSelectedVodFavorite(); + }); } - setCategoryId(categoryId: any) { + removeFromFavorites(item: Pick, onDone?: () => void) { + this.stalkerStore.removeFromFavorites( + normalizeStalkerEntityId(item.id), + () => { + this.favoritesRefresh.refresh(); + this.syncSelectedVodFavorite(); + onDone?.(); + } + ); + } + + setCategoryId(categoryId: string) { this.selectedCategoryId.set(categoryId); } - openItem(item: any) { - console.debug('Open item', item); - const normalizedItem = this.normalizeFavoriteItem(item); + openItem(item: StalkerFavoriteItem) { + this.logger.debug('Open item', item); + const normalizedItem = normalizeStalkerFavoriteItem(item); switch (item.category_id) { case 'itv': @@ -144,25 +161,16 @@ export class StalkerFavoritesComponent { this.stalkerStore.setSelectedItem(normalizedItem.details); this.stalkerStore.setSelectedContentType('vod'); - // Check if this VOD item is actually a series (Ministra is_series=1) - const isVodSeries = - normalizedItem.details?.is_series === true || - normalizedItem.details?.is_series === 1 || - normalizedItem.details?.is_series === '1' || - item.is_series === true || - item.is_series === 1 || - item.is_series === '1'; - - if (isVodSeries || normalizedItem.details?.series?.length > 0) { - // VOD series - will be rendered as series view - this.vodDetailsItem = null; - } else { - // Regular VOD - create VodDetailsItem for the component - this.vodDetailsItem = createStalkerVodItem( - normalizedItem.details as StalkerVodDetails, - this.currentPlaylist()?._id ?? '' - ); - } + const detailViewState = createStalkerDetailViewState( + normalizedItem.details, + this.currentPlaylist()?._id ?? '' + ); + this.itemDetails = { + ...normalizedItem, + details: detailViewState.itemDetails, + }; + this.vodDetailsItem = detailViewState.vodDetailsItem; + this.syncSelectedVodFavorite(); break; case 'series': this.itemDetails = normalizedItem; @@ -189,59 +197,36 @@ export class StalkerFavoritesComponent { onVodFavoriteToggled(event: { item: VodDetailsItem; isFavorite: boolean }): void { if (!event.isFavorite && event.item.type === 'stalker') { this.removeFromFavorites({ id: event.item.data.id }); + this.isSelectedVodFavorite.set(false); } } /** Handle back from vod-details component */ onVodBack(): void { - this.itemDetails = null; - this.vodDetailsItem = null; + this.resetDetailsView(true); } - private normalizeFavoriteItem(item: any): any { - if (item.details && item.details.info) { - return item; + onSeriesBack(): void { + this.resetDetailsView(true); + } + + private resetDetailsView(refreshList: boolean): void { + const cleared = clearStalkerDetailViewState(); + this.itemDetails = null; + this.vodDetailsItem = cleared.vodDetailsItem; + this.isSelectedVodFavorite.set(false); + if (refreshList) { + this.favoritesRefresh.refresh(); } + } - const source = item.details || item; - const info = source.info || { - name: item.o_name || item.name || item.title || 'Unknown', - o_name: item.o_name, - movie_image: item.cover || item.screenshot_uri || item.logo, - description: item.description, - releasedate: item.releasedate || item.year, - genre: item.genre || item.genres_str, - duration: item.duration, - rating_imdb: item.rating_imdb || item.rating, - country: item.country, - actors: item.actors, - director: item.director, - backdrop_path: - item.backdrop_path || - [item.cover, item.screenshot_uri].filter(Boolean), - }; - - // Normalize is_series to boolean true (can be 1, "1", or true) - const rawIsSeries = - item.is_series || source.is_series; - const normalizedIsSeries = - rawIsSeries === true || - rawIsSeries === 1 || - rawIsSeries === '1'; - - return { - ...item, - details: { - ...source, - info: info, - cmd: item.cmd || source.cmd, - id: item.stream_id || item.id || source.id, - series: item.series || source.series, - is_series: normalizedIsSeries ? true : undefined, - // Preserve video_id for season fetching - video_id: item.video_id || source.video_id, - }, - }; + private syncSelectedVodFavorite(): void { + this.isSelectedVodFavorite.set( + isSelectedStalkerVodFavorite( + this.vodDetailsItem, + this.allFavorites.value() ?? [] + ) + ); } async createLinkToPlayVodItv( @@ -249,7 +234,11 @@ export class StalkerFavoritesComponent { title?: string, thumbnail?: string ) { - console.debug('Create link to play VOD/ITV', cmd, title, thumbnail); + this.logger.debug('Create link to play VOD/ITV', { + cmd, + title, + thumbnail, + }); await this.stalkerStore.createLinkToPlayVod(cmd, title, thumbnail); } } diff --git a/apps/web/src/app/stalker/stalker-live-stream-layout/stalker-live-stream-layout.component.ts b/apps/web/src/app/stalker/stalker-live-stream-layout/stalker-live-stream-layout.component.ts index 1c86f6df9..9129a8497 100644 --- a/apps/web/src/app/stalker/stalker-live-stream-layout/stalker-live-stream-layout.component.ts +++ b/apps/web/src/app/stalker/stalker-live-stream-layout/stalker-live-stream-layout.component.ts @@ -34,6 +34,15 @@ import { CategoryViewComponent } from '../../xtream-tauri/category-view/category import { PlaylistErrorViewComponent } from '../../xtream/playlist-error-view/playlist-error-view.component'; import { StalkerStore } from '../stalker.store'; import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader'; +import { createLogger } from '../../shared/utils/logger'; +import { + StalkerCategoryItem, + StalkerFavoriteItem, + StalkerItvChannel, +} from '../models'; +import { + normalizeStalkerEntityId, +} from '../stalker-vod.utils'; @Component({ selector: 'app-stalker-live-stream-layout', @@ -69,6 +78,7 @@ export class StalkerLiveStreamLayoutComponent implements OnDestroy { private readonly playerService = inject(PlayerService); private readonly snackBar = inject(MatSnackBar); private readonly translate = inject(TranslateService); + private readonly logger = createLogger('StalkerLiveStream'); /** Categories */ readonly categories = this.stalkerStore.getCategoryResource; @@ -97,15 +107,15 @@ export class StalkerLiveStreamLayoutComponent implements OnDestroy { private epgChannelId: number | string | null = null; /** Channel list EPG preview */ - readonly currentPrograms = new Map(); - readonly currentProgramsProgress = new Map(); - readonly programTimings = new Map(); - private readonly requestedEpgChannels = new Set(); + readonly currentPrograms = new Map(); + readonly currentProgramsProgress = new Map(); + readonly programTimings = new Map(); + private readonly requestedEpgChannels = new Set(); private readonly cdr = inject(ChangeDetectorRef); private readonly ngZone = inject(NgZone); /** Favorites */ - readonly favorites = new Map(); + readonly favorites = new Map(); /** Scroll */ readonly scrollContainer = viewChild('scrollContainer'); @@ -120,8 +130,10 @@ export class StalkerLiveStreamLayoutComponent implements OnDestroy { .getPortalFavorites(this.stalkerStore.currentPlaylist()?._id) .pipe(takeUntilDestroyed()) .subscribe((favs) => { - favs.forEach((fav: any) => { - this.favorites.set(fav.id, true); + favs.forEach((fav: StalkerFavoriteItem) => { + if (fav.id !== undefined) { + this.favorites.set(normalizeStalkerEntityId(fav.id), true); + } }); }); @@ -228,7 +240,7 @@ export class StalkerLiveStreamLayoutComponent implements OnDestroy { } } - selectCategory(item: { category_name: string; category_id: string }) { + selectCategory(item: StalkerCategoryItem) { this.stalkerStore.setSelectedCategory(item.category_id || '*'); this.stalkerStore.setPage(0); } @@ -239,7 +251,7 @@ export class StalkerLiveStreamLayoutComponent implements OnDestroy { this.stalkerStore.setSelectedCategory(null); } - async playChannel(item: any) { + async playChannel(item: StalkerItvChannel) { this.stalkerStore.setSelectedItem(item); const isEmbeddedPlayer = @@ -279,7 +291,7 @@ export class StalkerLiveStreamLayoutComponent implements OnDestroy { title: item.o_name || item.name, }); } catch (error) { - console.error('[StalkerLiveStream] Playback failed:', error); + this.logger.error('Playback failed', error); const errorMessage = error?.message === 'nothing_to_play' ? this.translate.instant('PORTALS.CONTENT_NOT_AVAILABLE') @@ -288,10 +300,11 @@ export class StalkerLiveStreamLayoutComponent implements OnDestroy { } } - toggleFavorite(item: any) { - if (this.favorites.has(item.id)) { - this.stalkerStore.removeFromFavorites(item.id); - this.favorites.delete(item.id); + toggleFavorite(item: StalkerItvChannel) { + const itemId = normalizeStalkerEntityId(item.id); + if (this.favorites.has(itemId)) { + this.stalkerStore.removeFromFavorites(itemId); + this.favorites.delete(itemId); } else { this.stalkerStore.addToFavorites({ ...item, @@ -300,7 +313,7 @@ export class StalkerLiveStreamLayoutComponent implements OnDestroy { cover: item.logo, added_at: new Date().toISOString(), }); - this.favorites.set(item.id, true); + this.favorites.set(itemId, true); } } @@ -349,15 +362,17 @@ export class StalkerLiveStreamLayoutComponent implements OnDestroy { } } - private async loadEpgPreviewsForChannels(channels: any[]) { + private async loadEpgPreviewsForChannels(channels: StalkerItvChannel[]) { const newChannels = channels.filter( - (ch) => ch.id && !this.requestedEpgChannels.has(ch.id) + (ch) => + ch.id && + !this.requestedEpgChannels.has(normalizeStalkerEntityId(ch.id)) ); if (newChannels.length === 0) return; // Mark all as requested immediately to avoid duplicates for (const ch of newChannels) { - this.requestedEpgChannels.add(ch.id); + this.requestedEpgChannels.add(normalizeStalkerEntityId(ch.id)); } // Process in batches of 3 with a small delay between batches @@ -376,7 +391,7 @@ export class StalkerLiveStreamLayoutComponent implements OnDestroy { } } - private async loadSingleEpgPreview(channelId: number) { + private async loadSingleEpgPreview(channelId: number | string) { try { const items = await this.stalkerStore.fetchChannelEpg( channelId, @@ -384,7 +399,8 @@ export class StalkerLiveStreamLayoutComponent implements OnDestroy { ); if (items.length > 0) { const program = items[0]; - this.currentPrograms.set(channelId, program.title); + const id = normalizeStalkerEntityId(channelId); + this.currentPrograms.set(id, program.title); const now = Date.now() / 1000; const start = parseInt(program.start_timestamp, 10); @@ -393,11 +409,8 @@ export class StalkerLiveStreamLayoutComponent implements OnDestroy { if (start && end && now >= start && now <= end) { const progress = ((now - start) / (end - start)) * 100; - this.currentProgramsProgress.set( - channelId, - progress - ); - this.programTimings.set(channelId, { + this.currentProgramsProgress.set(id, progress); + this.programTimings.set(id, { start: start * 1000, end: end * 1000, }); diff --git a/apps/web/src/app/stalker/stalker-search/stalker-search.component.html b/apps/web/src/app/stalker/stalker-search/stalker-search.component.html index b34713c25..59a88f193 100644 --- a/apps/web/src/app/stalker/stalker-search/stalker-search.component.html +++ b/apps/web/src/app/stalker/stalker-search/stalker-search.component.html @@ -16,6 +16,7 @@ this.currentPlaylist()?._id, + () => this.favoritesRefresh.refreshVersion() + ); + readonly searchResultsResource = resource({ params: () => ({ contentType: this.selectedFilterType(), @@ -101,7 +123,7 @@ export class StalkerSearchComponent { token = result.token ?? undefined; serialNumber = (playlist as Playlist).stalkerSerialNumber; } catch (error) { - console.error('Failed to get stalker token:', error); + this.logger.error('Failed to get stalker token', error); } } @@ -123,7 +145,7 @@ export class StalkerSearchComponent { ); if (response) { const items = response.js?.data || []; - return items.map((item: any) => + return items.map((item: StalkerVodSource) => this.processItemUrls(item, portalUrl) ); } else { @@ -134,6 +156,16 @@ export class StalkerSearchComponent { }, }); + readonly isSelectedVodFavorite = signal(false); + + constructor() { + effect(() => { + // Re-evaluate favorite state whenever favorites resource changes. + this.portalFavorites.value(); + this.syncSelectedVodFavorite(); + }); + } + /** Check if showing item details */ get showingDetails(): boolean { return this.itemDetails !== null; @@ -166,33 +198,14 @@ export class StalkerSearchComponent { } } - selectItem(item: any) { + selectItem(item: StalkerVodSource) { const hasEmbeddedSeries = item.series?.length > 0; const needsSeriesFetch = this.selectedFilterType() === 'vod' && !hasEmbeddedSeries && - (item.is_series === '1' || item.is_series === 1); + isStalkerSeriesFlag(item.is_series); - this.itemDetails = { - id: item.id, - cmd: item.cmd, - series: item.series, - has_files: item.has_files, - is_series: needsSeriesFetch ? true : undefined, - video_id: item.video_id, - info: { - movie_image: item.screenshot_uri, - description: item.description, - name: item.o_name || item.name, - o_name: item.o_name, - director: item.director, - releasedate: item.year, - genre: item.genres_str, - actors: item.actors, - rating_imdb: item.rating_imdb, - rating_kinopoisk: item.rating_kinopoisk, - }, - }; + this.itemDetails = buildStalkerSelectedVodItem(item, needsSeriesFetch); this.stalkerStore.setSelectedItem(this.itemDetails); @@ -200,13 +213,17 @@ export class StalkerSearchComponent { case 'vod': this.stalkerStore.setSelectedContentType('vod'); if (!hasEmbeddedSeries && !needsSeriesFetch) { - const playlist = this.currentPlaylist(); - this.vodDetailsItem = createStalkerVodItem( - this.itemDetails as StalkerVodDetails, - playlist?._id ?? '' + const detailViewState = createStalkerDetailViewState( + this.itemDetails, + this.currentPlaylist()?._id ?? '' ); + this.itemDetails = detailViewState.itemDetails; + this.vodDetailsItem = detailViewState.vodDetailsItem; + this.syncSelectedVodFavorite(); } else { - this.vodDetailsItem = null; + const cleared = clearStalkerDetailViewState(); + this.vodDetailsItem = cleared.vodDetailsItem; + this.isSelectedVodFavorite.set(false); } break; case 'series': @@ -231,35 +248,46 @@ export class StalkerSearchComponent { item: VodDetailsItem; isFavorite: boolean; }): void { - if (event.item.type === 'stalker') { - if (event.isFavorite) { - this.addToFavorites({ - ...event.item.data, - category_id: 'vod', - title: event.item.data.info?.name, - cover: event.item.data.info?.movie_image, - added_at: new Date().toISOString(), - }); - } else { - this.removeFromFavorites(event.item.data.id); - } - } + toggleStalkerVodFavorite(event, { + addToFavorites: (item, onDone) => this.addToFavorites(item, onDone), + removeFromFavorites: (favoriteId, onDone) => + this.removeFromFavorites(favoriteId, onDone), + onComplete: () => { + this.favoritesRefresh.refresh(); + this.syncSelectedVodFavorite(); + }, + }); } onVodBack(): void { - this.itemDetails = null; - this.vodDetailsItem = null; + const cleared = clearStalkerDetailViewState(); + this.itemDetails = cleared.itemDetails; + this.vodDetailsItem = cleared.vodDetailsItem; + this.isSelectedVodFavorite.set(false); } - addToFavorites(item: any) { - this.stalkerStore.addToFavorites(item); + removeFromFavorites(favoriteId: string, onDone?: () => void) { + this.stalkerStore.removeFromFavorites(favoriteId, onDone); } - removeFromFavorites(favoriteId: string) { - this.stalkerStore.removeFromFavorites(favoriteId); + addToFavorites(item: Record, onDone?: () => void) { + this.stalkerStore.addToFavorites(item, onDone); } - private processItemUrls(item: any, portalUrl: string): any { + private syncSelectedVodFavorite(): void { + const item = this.vodDetailsItem; + this.isSelectedVodFavorite.set( + isSelectedStalkerVodFavorite( + item, + this.portalFavorites.value() ?? [] + ) + ); + } + + private processItemUrls( + item: StalkerVodSource, + portalUrl: string + ): StalkerVodSource { const processed = { ...item }; if (processed.screenshot_uri) { 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 d490f8b55..1741709cf 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 @@ -7,14 +7,14 @@ > - @if (serial.info.releasedate || serial.info.year) { + @if (serial.info.releasedate) { {{ - serial.info.releasedate || serial.info.year + serial.info.releasedate }} } - @if (serial.info.genre || serial.info.genres_str) { + @if (serial.info.genre) { {{ - serial.info.genre || serial.info.genres_str + serial.info.genre }} } @if (serial.info.rating_imdb) { @@ -53,8 +53,7 @@ @@ -62,7 +61,7 @@ @if (mappedSeasons()) { { - const item = this.displayItem(); - return ( - item?.is_series === true || - item?.is_series === 1 || - item?.is_series === '1' - ); + return isVodSeriesItem(this.displayItem()); }); /** @@ -101,30 +88,10 @@ export class StalkerSeriesViewComponent { constructor() { // Effect to load VOD series seasons when a VOD series item is selected effect(() => { - const item = this.displayItem(); - const isSeries = - item?.is_series === true || - item?.is_series === 1 || - item?.is_series === '1'; - if (isSeries) { + if (this.isVodSeries()) { // 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); - } + this.vodSeriesSeasons.set(mapVodSeriesSeasonsToVm(seasons)); } else { this.vodSeriesSeasons.set([]); } @@ -136,8 +103,13 @@ export class StalkerSeriesViewComponent { const playlist = this.stalkerStore.currentPlaylist(); if (item && playlist?._id) { const seriesId = Number(item.id); - console.log( - `[StalkerSeriesView] Loading positions for series: id=${item.id}, seriesId=${seriesId}, is_series=${item.is_series}` + this.logger.debug( + 'Loading positions for series', + { + id: item.id, + seriesId, + isSeries: item.is_series, + } ); if (!isNaN(seriesId)) { // Load playback positions from the XtreamStore (works for any playlist type) @@ -151,133 +123,38 @@ export class StalkerSeriesViewComponent { * For VOD with embedded series, we create a single "season" with the episodes * For regular series, we use the API-fetched seasons */ - readonly regularSeasons = computed(() => { - const vodItem = this.vodWithSeries(); - if (vodItem?.series?.length > 0) { - // VOD with embedded series - create a pseudo-season structure - return [ - { - id: vodItem.id, - name: vodItem.info?.name || 'Episodes', - cmd: vodItem.cmd, - series: vodItem.series, - }, - ]; - } - - // Regular series - use API-fetched seasons - return this.stalkerStore.getSerialSeasonsResource() as unknown as StalkerSeriesSeason[]; - }); + readonly regularSeasons = computed(() => + mapRegularSeriesSeasons( + this.vodWithSeries(), + this.stalkerStore.getSerialSeasonsResource() + ) + ); /** * Get the item to display details for (either vodWithSeries or selectedItem from store) */ - readonly displayItem = computed(() => { - return this.vodWithSeries() || this.selectedItem(); + readonly displayItem = computed(() => { + const item = this.vodWithSeries() || this.selectedItem(); + return item ? normalizeStalkerVodDetailsItem(item) : null; }); - /** - * Simple hash function to generate consistent numeric IDs from strings - */ - private hashString(str: string): number { - let hash = 0; - for (let i = 0; i < str.length; i++) { - const char = str.charCodeAt(i); - hash = (hash << 5) - hash + char; - hash = hash & hash; // Convert to 32-bit integer - } - return Math.abs(hash); - } - - /** - * Generate a unique episode ID for Stalker episodes. - * For VOD series: ALWAYS hash with season + episode number because - * the API returns the same ID (series/movie ID) for all episodes. - * For regular series: hash the cmd + episode number - */ - private generateEpisodeId( - episodeId: any, - episodeNum: number, - seasonKey: string, - isVodSeries: boolean - ): number { - if (isVodSeries) { - // For VOD series, ALWAYS create unique ID from season + episode number - // The API-provided ep.id is the same for all episodes (it's the series ID) - return this.hashString(`vod_${seasonKey}_${episodeNum}`); - } else { - // For regular series, create unique ID from cmd + episode number - const cmd = String(episodeId || ''); - return this.hashString(`${cmd}_ep_${episodeNum}`); - } - } - /** * Adapts both Regular and VOD series data into the format expected by SeasonContainerComponent. * Record where string is season number/name. */ readonly mappedSeasons = computed>( () => { - const mapped: Record = {}; - if (this.isVodSeries()) { - // Map VOD Series (async loaded) - this.vodSeriesSeasons().forEach((season) => { - const seasonKey = - season.season_number || season.name || season.id; - // Map existing episodes or empty array - mapped[seasonKey] = (season.episodes || []).map( - (ep) => { - const episodeNum = ep.series_number || ep.episode_num || 0; - return { - episode_num: episodeNum, - title: - ep.name || - `Episode ${episodeNum}`, - container_extension: 'mpg', - info: { - movie_image: - ep.cover || - this.displayItem().info.movie_image, - plot: ep.description || '', - duration: ep.duration - ? `${ep.duration} min` - : '', - }, - // Use unique ID for watched status tracking - id: this.generateEpisodeId(ep.id, episodeNum, seasonKey, true), - // Store original API id for playback - originalId: ep.id, - custom_sid: 'vod-series', // marker - } as unknown as XtreamSerieEpisode; - } - ); - }); - } else { - // Map Regular Series (just numbers) - (this.regularSeasons() || []).forEach((season, index) => { - const seasonKey = (index + 1).toString(); - mapped[seasonKey] = (season.series || []).map( - (epNum) => - ({ - episode_num: epNum, - title: `Episode ${epNum}`, - container_extension: '', - info: { - movie_image: - this.displayItem().info.movie_image, - }, - custom_sid: 'regular-series', // marker - // Use unique ID for watched status tracking - id: this.generateEpisodeId(season.cmd, epNum, seasonKey, false), - // Store cmd for playback - originalCmd: season.cmd, - }) as unknown as XtreamSerieEpisode - ); - }); + return mapVodSeriesEpisodes( + this.vodSeriesSeasons(), + this.displayItem()?.info?.movie_image + ); } - return mapped; + return mapRegularSeriesEpisodes( + this.regularSeasons(), + this.displayItem()?.info?.movie_image + ); } ); @@ -290,10 +167,10 @@ export class StalkerSeriesViewComponent { const seasons = this.vodSeriesSeasons(); const season = seasons.find( - (s) => (s.season_number || s.name || s.id) == seasonKey + (s) => getVodSeriesSeasonKey(s) === seasonKey ); - if (season && (!season.episodes || season.episodes.length === 0)) { + if (season && season.episodes.length === 0) { this.loadEpisodesForSeason(season); } } @@ -301,7 +178,7 @@ export class StalkerSeriesViewComponent { /** * Loads episodes for a specific VOD season */ - async loadEpisodesForSeason(season: VodSeriesSeason) { + async loadEpisodesForSeason(season: VodSeriesSeasonVm) { // Set loading state in local signal const seasons = this.vodSeriesSeasons(); const index = seasons.findIndex((s) => s.id === season.id); @@ -329,10 +206,7 @@ export class StalkerSeriesViewComponent { this.vodSeriesSeasons.set(newSeasons); } } catch (error) { - console.error( - '[StalkerSeriesViewComponent] Failed to load episodes:', - error - ); + this.logger.error('Failed to load episodes', error); const newSeasons = [...this.vodSeriesSeasons()]; const newIndex = newSeasons.findIndex((s) => s.id === season.id); if (newIndex !== -1) { @@ -351,20 +225,22 @@ export class StalkerSeriesViewComponent { isCurrentSeasonLoading(seasonKey: string): boolean { if (!this.isVodSeries()) return false; const season = this.vodSeriesSeasons().find( - (s) => (s.season_number || s.name || s.id) == seasonKey + (s) => getVodSeriesSeasonKey(s) === seasonKey ); - return season?.isLoading || false; + return season?.isLoading ?? false; } /** * Handles episode click from the container */ onEpisodeClicked(episode: XtreamSerieEpisode) { - if ((episode as any).custom_sid === 'vod-series') { + const mappedEpisode = episode as StalkerMappedEpisode; + + if (mappedEpisode.custom_sid === 'vod-series') { // It's a VOD series episode (is_series=1 mode) // Use originalId for playback URL, but use generated id for tracking this.playVodSeriesEpisode({ - originalId: (episode as any).originalId, // For constructing playback URL + originalId: mappedEpisode.originalId, // For constructing playback URL trackingId: episode.id, // The generated unique ID for position tracking name: episode.title, series_number: episode.episode_num, @@ -375,7 +251,7 @@ export class StalkerSeriesViewComponent { const trackingId = Number(episode.id); this.playEpisodeClicked( episode.episode_num, - (episode as any).originalCmd, + mappedEpisode.originalCmd, trackingId ); } @@ -384,11 +260,15 @@ export class StalkerSeriesViewComponent { /** * Play episode - handles regular series and vclub mode */ - playEpisodeClicked(episodeNum: any, cmd?: string, trackingId?: number) { + playEpisodeClicked(episodeNum: number, cmd?: string, trackingId?: number) { const item = this.displayItem(); - console.log( - `[StalkerSeriesView] playEpisodeClicked: episodeNum=${episodeNum}, cmd=${cmd}, trackingId=${trackingId}, seriesId=${item.id}` - ); + if (!item) return; + this.logger.debug('playEpisodeClicked', { + episodeNum, + cmd, + trackingId, + seriesId: item.id, + }); // Get playback position from XtreamStore if available const position = trackingId @@ -409,17 +289,26 @@ export class StalkerSeriesViewComponent { /** * Play VOD series episode */ - playVodSeriesEpisode(episode: any) { + playVodSeriesEpisode(episode: { + originalId?: string; + trackingId: string; + name: string; + series_number: number; + }) { const item = this.displayItem(); + if (!item) return; // Use originalId for playback URL (this is what the Stalker API expects) - const cmd = `/media/file_${episode.originalId}.mpg`; + const cmd = `/media/file_${episode.originalId ?? ''}.mpg`; const episodeName = episode.name || `Episode ${episode.series_number}`; // Use trackingId (generated unique ID) for playback position tracking const trackingId = Number(episode.trackingId); - console.log( - `[StalkerSeriesView] playVodSeriesEpisode: originalId=${episode.originalId}, trackingId=${trackingId}, seriesId=${item.id}, episodeName=${episodeName}` - ); + this.logger.debug('playVodSeriesEpisode', { + originalId: episode.originalId, + trackingId, + seriesId: item.id, + episodeName, + }); // Get playback position from XtreamStore if available const position = this.xtreamStore @@ -437,24 +326,13 @@ export class StalkerSeriesViewComponent { ); } - addToFavorites(item: any) { - const displayItem = this.displayItem(); - this.stalkerStore.addToFavorites({ - ...item, - title: displayItem.info.name, - cover: displayItem.info.movie_image, - series_id: displayItem.id, - added_at: new Date().toISOString(), - category_id: 'series', - }); - } - - removeFromFavorites(serialId: string) { - this.stalkerStore.removeFromFavorites(serialId); - } - goBack() { this.backClicked.emit(); this.stalkerStore.clearSelectedItem(); } -} \ No newline at end of file + + toSeriesId(id: string): number { + const parsed = Number(id); + return Number.isFinite(parsed) ? parsed : 0; + } +} diff --git a/apps/web/src/app/stalker/stalker-series.adapters.spec.ts b/apps/web/src/app/stalker/stalker-series.adapters.spec.ts new file mode 100644 index 000000000..6d552b097 --- /dev/null +++ b/apps/web/src/app/stalker/stalker-series.adapters.spec.ts @@ -0,0 +1,90 @@ +import { + isVodSeriesItem, + mapRegularSeriesEpisodes, + mapRegularSeriesSeasons, + mapVodSeriesEpisodes, + mapVodSeriesSeasonsToVm, +} from './stalker-series.adapters'; +import { StalkerSelectedVodItem } from './models'; + +describe('stalker-series.adapters', () => { + it('detects vod-series flags from heterogeneous payloads', () => { + expect(isVodSeriesItem({ is_series: true })).toBe(true); + expect(isVodSeriesItem({ is_series: 1 })).toBe(true); + expect(isVodSeriesItem({ is_series: '1' })).toBe(true); + expect(isVodSeriesItem({ is_series: false })).toBe(false); + }); + + it('maps VOD-series seasons to VM shape with defaults', () => { + const seasons = mapVodSeriesSeasonsToVm([ + { + id: '10', + video_id: '20', + season_number: '2', + }, + ]); + + expect(seasons).toEqual([ + { + id: '10', + video_id: '20', + season_number: '2', + name: 'Season 2', + episodes: [], + isLoading: false, + isExpanded: false, + }, + ]); + }); + + it('maps VOD-series episodes with stable tracking IDs and originalId metadata', () => { + const mapped = mapVodSeriesEpisodes( + [ + { + id: 's1', + video_id: 'v1', + name: 'Season 1', + season_number: '1', + episodes: [ + { + id: 'api-shared-id', + series_number: 1, + name: 'Episode 1', + }, + { + id: 'api-shared-id', + series_number: 2, + name: 'Episode 2', + }, + ], + isLoading: false, + isExpanded: false, + }, + ], + 'poster.jpg' + ); + + expect(mapped['1']).toHaveLength(2); + expect(mapped['1'][0].custom_sid).toBe('vod-series'); + expect((mapped['1'][0] as any).originalId).toBe('api-shared-id'); + expect(mapped['1'][0].id).not.toBe(mapped['1'][1].id); + }); + + it('maps embedded series payload into regular season episodes', () => { + const regularSeasons = mapRegularSeriesSeasons( + { + id: '100', + cmd: '/media/file_100.mpg', + series: ['1', 2, 'x'], + info: { name: 'Embedded Series' }, + } as StalkerSelectedVodItem, + [] + ); + + const mapped = mapRegularSeriesEpisodes(regularSeasons, 'poster.jpg'); + expect(regularSeasons[0].series).toEqual([1, 2]); + expect(mapped['1']).toHaveLength(2); + expect(mapped['1'][0].custom_sid).toBe('regular-series'); + expect((mapped['1'][0] as any).originalCmd).toBe('/media/file_100.mpg'); + }); +}); diff --git a/apps/web/src/app/stalker/stalker-series.adapters.ts b/apps/web/src/app/stalker/stalker-series.adapters.ts new file mode 100644 index 000000000..d18fcf12a --- /dev/null +++ b/apps/web/src/app/stalker/stalker-series.adapters.ts @@ -0,0 +1,232 @@ +import { XtreamSerieEpisode } from 'shared-interfaces'; +import { + StalkerSeason, + StalkerVodSeriesEpisode, + StalkerVodSeriesSeason, + StalkerVodSource, +} from './models'; +import { isStalkerSeriesFlag } from './stalker-vod.utils'; + +export interface VodSeriesSeasonVm { + id: string; + video_id: string; + name: string; + season_number: string; + episodes: StalkerVodSeriesEpisode[]; + isLoading: boolean; + isExpanded: boolean; +} + +export interface StalkerSeriesSeasonVm { + id: string; + name: string; + cmd?: string; + series: number[]; +} + +export interface StalkerMappedEpisode extends XtreamSerieEpisode { + originalId?: string; + originalCmd?: string; +} + +function hashString(str: string): number { + let hash = 0; + for (let i = 0; i < str.length; i++) { + const char = str.charCodeAt(i); + hash = (hash << 5) - hash + char; + hash = hash & hash; + } + return Math.abs(hash); +} + +function generateEpisodeId( + seed: string, + episodeNum: number, + seasonKey: string, + isVodSeries: boolean +): number { + if (isVodSeries) { + return hashString(`vod_${seasonKey}_${episodeNum}`); + } + return hashString(`${seed}_ep_${episodeNum}`); +} + +function toEpisodeNumber(value: unknown): number { + if (typeof value === 'number') { + return Number.isFinite(value) ? value : 0; + } + if (typeof value === 'string') { + const parsed = Number(value); + return Number.isFinite(parsed) ? parsed : 0; + } + return 0; +} + +function toNonEmptyString(value: unknown, fallback: string): string { + if (typeof value === 'string' && value.trim() !== '') { + return value; + } + return fallback; +} + +function toSeriesNumbers(values: unknown[] | undefined): number[] { + if (!Array.isArray(values)) { + return []; + } + return values + .map((value) => toEpisodeNumber(value)) + .filter((value) => value > 0); +} + +export function isVodSeriesItem( + item: Pick | null | undefined +): boolean { + return isStalkerSeriesFlag(item?.is_series); +} + +export function mapVodSeriesSeasonsToVm( + seasons: StalkerVodSeriesSeason[] | undefined +): VodSeriesSeasonVm[] { + return (seasons ?? []).map((season) => ({ + id: String(season.id), + video_id: String(season.video_id), + name: toNonEmptyString( + season.name, + `Season ${toNonEmptyString(season.season_number, '0')}` + ), + season_number: toNonEmptyString(season.season_number, ''), + episodes: [], + isLoading: false, + isExpanded: false, + })); +} + +export function mapRegularSeriesSeasons( + vodWithSeries: StalkerVodSource | null, + serialSeasons: StalkerSeason[] | undefined +): StalkerSeriesSeasonVm[] { + if (vodWithSeries?.series && vodWithSeries.series.length > 0) { + return [ + { + id: String(vodWithSeries.id), + name: vodWithSeries.info?.name || 'Episodes', + cmd: vodWithSeries.cmd, + series: toSeriesNumbers(vodWithSeries.series), + }, + ]; + } + + return (serialSeasons ?? []).map((season) => ({ + id: String(season.id), + name: season.name, + cmd: season.cmd, + series: toSeriesNumbers(season.series), + })); +} + +function createBaseEpisode( + id: number, + episodeNum: number, + title: string, + containerExtension: string, + customSid: string, + season: number, + info: XtreamSerieEpisode['info'] +): XtreamSerieEpisode { + return { + id: String(id), + episode_num: episodeNum, + title, + container_extension: containerExtension, + info, + custom_sid: customSid, + added: '', + season, + direct_source: '', + }; +} + +export function mapVodSeriesEpisodes( + seasons: ReadonlyArray, + fallbackPoster?: string +): Record { + const mapped: Record = {}; + + seasons.forEach((season) => { + const seasonKey = season.season_number || season.name || season.id; + const seasonNum = toEpisodeNumber(seasonKey) || 1; + + mapped[seasonKey] = (season.episodes ?? []).map((episode) => { + const episodeNum = + toEpisodeNumber(episode.series_number) || + toEpisodeNumber(episode.episode_num); + const trackingId = generateEpisodeId( + String(episode.id ?? ''), + episodeNum, + seasonKey, + true + ); + + return { + ...createBaseEpisode( + trackingId, + episodeNum, + episode.name || `Episode ${episodeNum}`, + 'mpg', + 'vod-series', + seasonNum, + { + movie_image: episode.cover || fallbackPoster, + plot: episode.description || '', + duration: episode.duration + ? `${episode.duration} min` + : '', + } + ), + originalId: String(episode.id ?? ''), + } as StalkerMappedEpisode; + }); + }); + + return mapped; +} + +export function mapRegularSeriesEpisodes( + seasons: ReadonlyArray, + fallbackPoster?: string +): Record { + const mapped: Record = {}; + + seasons.forEach((season, index) => { + const seasonKey = String(index + 1); + mapped[seasonKey] = (season.series ?? []).map((episodeNum) => { + const trackingId = generateEpisodeId( + String(season.cmd ?? ''), + episodeNum, + seasonKey, + false + ); + + return { + ...createBaseEpisode( + trackingId, + episodeNum, + `Episode ${episodeNum}`, + '', + 'regular-series', + Number(seasonKey), + { + movie_image: fallbackPoster, + } + ), + originalCmd: season.cmd, + } as StalkerMappedEpisode; + }); + }); + + return mapped; +} + +export function getVodSeriesSeasonKey(season: VodSeriesSeasonVm): string { + return season.season_number || season.name || season.id; +} diff --git a/apps/web/src/app/stalker/stalker.store.ts b/apps/web/src/app/stalker/stalker.store.ts index 897488678..952595e01 100644 --- a/apps/web/src/app/stalker/stalker.store.ts +++ b/apps/web/src/app/stalker/stalker.store.ts @@ -19,35 +19,43 @@ import { } from 'shared-interfaces'; import { PlayerService } from '../services/player.service'; import { ContentType } from '../xtream/content-type.enum'; -import { StalkerSeason } from './models/stalker-season.interface'; +import { + StalkerCategoryItem, + StalkerContentItem, + StalkerItvChannel, + StalkerSeason, + StalkerVodSeriesEpisode, + StalkerVodSeriesSeason, + StalkerVodSource, +} from './models'; import { StalkerContentTypes } from './stalker-content-types'; - -interface StalkerCategoryItem { - category_id: string; - category_name: string; -} +import { createLogger } from '../shared/utils/logger'; +import { + normalizeStalkerEntityId, + normalizeStalkerEntityIdAsNumber, +} from './stalker-vod.utils'; interface StalkerState { selectedContentType: 'vod' | 'itv' | 'series'; - selectedCategoryId: string; - selectedVodId: string; - selectedSerialId: string; - selectedItvId: string; + selectedCategoryId: string | null | undefined; + selectedVodId: string | undefined; + selectedSerialId: string | undefined; + selectedItvId: string | undefined; limit: number; page: number; searchPhrase: string; currentPlaylist: PlaylistMeta; totalCount: number; - selectedItem: any; + selectedItem: StalkerVodSource | null | undefined; vodCategories: StalkerCategoryItem[]; seriesCategories: StalkerCategoryItem[]; itvCategories: StalkerCategoryItem[]; hasMoreChannels: boolean; - itvChannels: any[]; + itvChannels: StalkerItvChannel[]; /** For VOD items that are actually series (Ministra plugin is_series=1) */ - vodSeriesSeasons: any[]; - vodSeriesEpisodes: any[]; - selectedVodSeriesSeasonId: string; + vodSeriesSeasons: StalkerVodSeriesSeason[]; + vodSeriesEpisodes: StalkerVodSeriesEpisode[]; + selectedVodSeriesSeasonId: string | undefined; } const initialState: StalkerState = { @@ -72,6 +80,8 @@ const initialState: StalkerState = { selectedVodSeriesSeasonId: undefined, }; +const logger = createLogger('StalkerStore'); + function extractNumericValue(str: string) { const matches = str.match(/\d+/); if (matches) { @@ -90,14 +100,27 @@ function sortByNumericValue(array: StalkerSeason[]): StalkerSeason[] { }); } +function sortVodSeriesSeasonsByNumber( + array: StalkerVodSeriesSeason[] +): StalkerVodSeriesSeason[] { + if (!array) return []; + return [...array].sort((a, b) => { + const numA = Number(a.season_number ?? 0) || 0; + const numB = Number(b.season_number ?? 0) || 0; + return numA - numB; + }); +} + /** * Sort episodes by series_number in ascending numeric order (1, 2, 3... not "1", "10", "2") */ -function sortEpisodesByNumber(episodes: any[]): any[] { +function sortEpisodesByNumber( + episodes: StalkerVodSeriesEpisode[] +): StalkerVodSeriesEpisode[] { if (!episodes) return []; return episodes.sort((a, b) => { - const numA = parseInt(a.series_number, 10) || 0; - const numB = parseInt(b.series_number, 10) || 0; + const numA = Number(a.series_number ?? 0) || 0; + const numB = Number(b.series_number ?? 0) || 0; return numA - numB; }); } @@ -131,7 +154,10 @@ function makeAbsoluteUrl(baseUrl: string, relativePath: string): string { /** * Post-process stalker items to convert relative URLs to absolute */ -function processItemUrls(item: any, portalUrl: string): any { +function processItemUrls( + item: T, + portalUrl: string +): T { const processed = { ...item }; // Convert screenshot_uri to absolute URL @@ -145,6 +171,31 @@ function processItemUrls(item: any, portalUrl: string): any { return processed; } +function toStalkerContentItem( + item: StalkerVodSource, + portalUrl: string +): StalkerContentItem { + const processed = processItemUrls(item, portalUrl); + return { + ...processed, + cover: processed.screenshot_uri, + }; +} + +function toStalkerItvChannel(item: StalkerContentItem): StalkerItvChannel { + return { + ...item, + id: item.id ?? item.stream_id ?? '', + cmd: String(item.cmd ?? ''), + name: + typeof item.name === 'string' ? item.name : undefined, + o_name: + typeof item.o_name === 'string' ? item.o_name : undefined, + logo: + typeof item.logo === 'string' ? item.logo : undefined, + }; +} + export const StalkerStore = signalStore( { providedIn: 'root' }, withState(initialState), @@ -173,7 +224,7 @@ export const StalkerStore = signalStore( serialNumber = (playlist as Playlist) .stalkerSerialNumber; } catch (error) { - console.error('Failed to get stalker token:', error); + logger.error('Failed to get stalker token', error); } } @@ -191,7 +242,7 @@ export const StalkerStore = signalStore( action: StalkerPortalActions.GetCategories, currentPlaylist: store.currentPlaylist(), }), - loader: async ({ params }) => { + loader: async ({ params }): Promise => { if (params.currentPlaylist === undefined) return; switch (params.contentType) { @@ -244,17 +295,14 @@ export const StalkerStore = signalStore( // Guard: ensure response has expected structure if (!response?.js || !Array.isArray(response.js)) { - console.warn( - '[StalkerStore] Invalid categories response:', - response - ); + logger.warn('Invalid categories response', response); return []; } const categories = response.js - .map((item) => ({ + .map((item): StalkerCategoryItem => ({ category_name: item.title, - category_id: item.id, + category_id: String(item.id), })) .sort((a, b) => a.category_name.localeCompare(b.category_name) @@ -273,7 +321,9 @@ export const StalkerStore = signalStore( search: store.searchPhrase(), pageIndex: store.page() + 1, }), - loader: async ({ params }) => { + loader: async ({ + params, + }): Promise => { if ( !params.category || params.category === null || @@ -340,10 +390,7 @@ export const StalkerStore = signalStore( // Guard: ensure response has expected structure if (!response?.js?.data) { - console.warn( - '[StalkerStore] Invalid response structure:', - response - ); + logger.warn('Invalid response structure', response); return []; } @@ -352,24 +399,21 @@ export const StalkerStore = signalStore( }); 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, - }; - }); + const newItems = response.js.data.map( + (item: StalkerVodSource) => + toStalkerContentItem(item, portalUrl) + ); if (store.selectedContentType() === 'itv') { + const channels = newItems.map(toStalkerItvChannel); // Check if we're loading the first page or loading more if (params.pageIndex === 1) { - patchState(store, { itvChannels: newItems }); + patchState(store, { itvChannels: channels }); } else { patchState(store, { itvChannels: [ ...store.itvChannels(), - ...newItems, + ...channels, ], }); } @@ -424,10 +468,7 @@ export const StalkerStore = signalStore( // Guard: ensure response has expected structure if (!response?.js?.data) { - console.warn( - '[StalkerStore] Invalid seasons response:', - response - ); + logger.warn('Invalid seasons response', response); return []; } return sortByNumericValue(response.js.data); @@ -441,17 +482,24 @@ export const StalkerStore = signalStore( params: () => ({ selectedItem: store.selectedItem(), }), - loader: async ({ params }) => { + loader: async ({ + params, + }): Promise => { const item = params.selectedItem; - // Debug logging - console.log('[StalkerStore] vodSeriesSeasonsResource loader called'); - console.log('[StalkerStore] item:', item); - console.log('[StalkerStore] item?.is_series:', item?.is_series); - console.log('[StalkerStore] currentPlaylist:', store.currentPlaylist()); + logger.debug( + 'vodSeriesSeasonsResource loader called', + { + item, + isSeries: item?.is_series, + currentPlaylist: store.currentPlaylist(), + } + ); // Only fetch if item is a VOD series (has is_series flag) if (!store.currentPlaylist() || !item || !item.is_series) { - console.log('[StalkerStore] vodSeriesSeasonsResource - early return, conditions not met'); + logger.debug( + 'vodSeriesSeasonsResource skipped - conditions not met' + ); return []; } @@ -482,18 +530,27 @@ export const StalkerStore = signalStore( } if (!response?.js?.data) { - console.log('[StalkerStore] vodSeriesSeasonsResource - no response data'); + logger.debug( + 'vodSeriesSeasonsResource - no response data' + ); return []; } - console.log('[StalkerStore] vodSeriesSeasonsResource - response.js.data:', response.js.data); + logger.debug( + 'vodSeriesSeasonsResource response data', + response.js.data + ); // Filter for season items (is_season: true) const seasons = response.js.data.filter( - (item: any) => item.is_season === true + (item: StalkerVodSeriesSeason) => + item.is_season === true ); - console.log('[StalkerStore] vodSeriesSeasonsResource - filtered seasons:', seasons); - return sortByNumericValue(seasons); + logger.debug( + 'vodSeriesSeasonsResource filtered seasons', + seasons + ); + return sortVodSeriesSeasonsByNumber(seasons); }, }), }) @@ -616,19 +673,28 @@ export const StalkerStore = signalStore( }); } } catch (error) { - console.error( - 'Error syncing Stalker playlist to SQLite:', + logger.error( + 'Error syncing Stalker playlist to SQLite', error ); } } }, - setSelectedItem(selectedItem: any) { + setSelectedItem(selectedItem: StalkerVodSource | null | undefined) { // TODO: check the item type and proper property either selectedVodId or serialsId etc + const selectedIdRaw = + selectedItem?.id !== undefined && + selectedItem?.id !== null + ? selectedItem.id + : undefined; + const selectedId = + selectedIdRaw !== undefined + ? normalizeStalkerEntityId(selectedIdRaw) + : undefined; patchState(store, { - selectedVodId: selectedItem?.id ?? undefined, - selectedSerialId: selectedItem?.id ?? undefined, - selectedItvId: selectedItem?.id ?? undefined, + selectedVodId: selectedId, + selectedSerialId: selectedId, + selectedItvId: selectedId, selectedItem, }); }, @@ -640,7 +706,10 @@ export const StalkerStore = signalStore( selectedItem: undefined, }); }, - setCategories(type: 'vod' | 'series' | 'itv', categories: any[]) { + setCategories( + type: 'vod' | 'series' | 'itv', + categories: StalkerCategoryItem[] + ) { if (type === 'vod') { patchState(store, { vodCategories: categories }); } else if (type === 'series') { @@ -656,7 +725,7 @@ export const StalkerStore = signalStore( itvCategories: [], }); }, - setItvChannels(channels: any[]) { + setItvChannels(channels: StalkerItvChannel[]) { patchState(store, { itvChannels: channels }); }, setSearchPhrase(phrase: string) { @@ -671,7 +740,7 @@ export const StalkerStore = signalStore( async fetchVodSeriesEpisodes( videoId: string, seasonId: string - ): Promise { + ): Promise { const playlist = store.currentPlaylist() as Playlist; if (!playlist) return []; @@ -704,7 +773,8 @@ export const StalkerStore = signalStore( // 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 + (item: StalkerVodSeriesEpisode) => + item.is_episode === true ) ); @@ -788,7 +858,7 @@ export const StalkerStore = signalStore( // Check for server-side errors if (response.js?.error) { const errorMsg = response.js.error; - console.error('[StalkerStore] Server error:', errorMsg); + logger.error('Server error', errorMsg); throw new Error(errorMsg); } @@ -888,11 +958,11 @@ export const StalkerStore = signalStore( return 'Unknown'; } catch (error) { - console.error('Failed to fetch expire date:', error); + logger.error('Failed to fetch expire date', error); return 'Error fetching data'; } }, - addToFavorites(item: any) { + addToFavorites(item: any, onDone?: () => void) { playlistService .addPortalFavorite(this.currentPlaylist()?._id, { ...item, @@ -908,9 +978,10 @@ export const StalkerStore = signalStore( duration: 1000, } ); + onDone?.(); }); }, - removeFromFavorites(favoriteId: string) { + removeFromFavorites(favoriteId: string, onDone?: () => void) { playlistService .removeFromPortalFavorites( this.currentPlaylist()?._id, @@ -924,6 +995,7 @@ export const StalkerStore = signalStore( duration: 1000, } ); + onDone?.(); }); }, /** @@ -985,6 +1057,10 @@ export const StalkerStore = signalStore( const item = this.selectedItem(); let cmdToUse = cmd ?? item?.cmd; + if (!cmdToUse) { + throw new Error('nothing_to_play'); + } + // For items with has_files and relative path, we need to fetch the file id first if ( item?.has_files !== undefined && @@ -993,7 +1069,9 @@ export const StalkerStore = signalStore( cmdToUse.includes('/media/') && !cmdToUse.includes('/media/file_') ) { - const fileId = await this.fetchMovieFileId(item.id); + const fileId = await this.fetchMovieFileId( + normalizeStalkerEntityId(item.id) + ); if (fileId) { cmdToUse = `/media/file_${fileId}.mpg`; } @@ -1007,19 +1085,24 @@ export const StalkerStore = signalStore( ); this.addToRecentlyViewed({ ...item, - id: item.id, + id: item?.id, cmd: cmd, cover: thumbnail, title, }); const playlist = this.currentPlaylist(); const isEpisode = episodeNum !== undefined || episodeId !== undefined; + const selectedItemId = + normalizeStalkerEntityIdAsNumber(item?.id) ?? 0; const contentInfo = { playlistId: playlist._id, // For episodes, use episodeId if provided, otherwise fall back to item.id - contentXtreamId: isEpisode && episodeId ? episodeId : Number(item.id), + contentXtreamId: + isEpisode && episodeId + ? episodeId + : selectedItemId, contentType: isEpisode ? 'episode' : 'vod', - seriesXtreamId: isEpisode ? Number(item.id) : undefined, + seriesXtreamId: isEpisode ? selectedItemId : undefined, }; playerService.openPlayer( @@ -1035,10 +1118,7 @@ export const StalkerStore = signalStore( startTime ); } catch (error) { - console.error( - '[StalkerStore] Failed to get playback URL:', - error - ); + logger.error('Failed to get playback URL', error); const errorMessage = error?.message === 'nothing_to_play' ? translate.instant('PORTALS.CONTENT_NOT_AVAILABLE') @@ -1055,13 +1135,20 @@ export const StalkerStore = signalStore( }) .subscribe(); }, - removeFromRecentlyViewed(itemId: number) { + removeFromRecentlyViewed(itemId: number, onComplete?: () => void) { playlistService .removeFromPortalRecentlyViewed( this.currentPlaylist()?._id, itemId ) - .subscribe(); + .subscribe({ + next: () => onComplete?.(), + error: (error) => + logger.error( + 'Failed to remove item from recently viewed', + error + ), + }); }, async fetchChannelEpg( channelId: number | string, 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 c3f46a208..0aa387daa 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 @@ -11,6 +11,7 @@ @if (vodDetailsItem()) { void) | null = null; readonly contentType = this.store.selectedContentType; + private readonly playlistService = inject(PlaylistsService); + private readonly favoritesRefresh = createRefreshTrigger(); readonly limit = this.store.limit; readonly pageIndex = this.store.page; @@ -190,6 +202,23 @@ export class CategoryContentViewComponent implements OnInit, OnDestroy { return position?.positionSeconds ?? null; }); + readonly portalFavorites = createPortalFavoritesResource( + this.playlistService, + () => { + if (!this.isStalker) return undefined; + const stalkerStore = this.store as InstanceType; + return stalkerStore.currentPlaylist()?._id; + }, + () => this.favoritesRefresh.refreshVersion() + ); + + readonly isSelectedVodFavorite = computed(() => + isSelectedStalkerVodFavorite( + this.vodDetailsItem(), + this.portalFavorites.value() ?? [] + ) + ); + seasons = []; ngOnInit() { @@ -279,34 +308,11 @@ export class CategoryContentViewComponent implements OnInit, OnDestroy { } onItemClick(item: any) { - 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.o_name || item.name, - o_name: item.o_name, - director: item.director, - releasedate: item.year, - genre: item.genres_str, - actors: item.actors, - rating_imdb: item.rating_imdb, - rating_kinopoisk: item.rating_kinopoisk, - }, - }; + const selectedItem = buildStalkerSelectedVodItem( + item, + this.contentType() === 'vod' && + (item.is_series === '1' || item.is_series === 1) + ); if (this.isStalker) { this.store.setSelectedItem(selectedItem); @@ -333,14 +339,30 @@ export class CategoryContentViewComponent implements OnInit, OnDestroy { await this.store.createLinkToPlayVod(cmd, title, thumbnail); } - addToFavorites(item: any) { - console.debug('Add to favorites', item); + addToFavorites(item: any, onDone?: () => void) { + this.logger.debug('Add to favorites', item); + if (this.isStalker) { + (this.store as InstanceType).addToFavorites( + item, + onDone + ); + return; + } this.store.addToFavorites(item); + onDone?.(); } - removeFromFavorites(favoriteId: string) { - console.debug('Remove from favorites', favoriteId); + removeFromFavorites(favoriteId: string, onDone?: () => void) { + this.logger.debug('Remove from favorites', favoriteId); + if (this.isStalker) { + (this.store as InstanceType).removeFromFavorites( + favoriteId, + onDone + ); + return; + } this.store.removeFromFavorites(favoriteId); + onDone?.(); } /** Handle play from vod-details component */ @@ -374,19 +396,14 @@ export class CategoryContentViewComponent implements OnInit, OnDestroy { item: VodDetailsItem; isFavorite: boolean; }): void { - if (event.item.type === 'stalker') { - if (event.isFavorite) { - this.addToFavorites({ - ...event.item.data, - category_id: 'vod', - title: event.item.data.info?.name, - cover: event.item.data.info?.movie_image, - added_at: new Date().toISOString(), - }); - } else { - this.removeFromFavorites(event.item.data.id); - } - } + toggleStalkerVodFavorite(event, { + addToFavorites: (item, onDone) => this.addToFavorites(item, onDone), + removeFromFavorites: (favoriteId, onDone) => + this.removeFromFavorites(favoriteId, onDone), + onComplete: () => { + this.favoritesRefresh.refresh(); + }, + }); } /** Handle back from vod-details component */ diff --git a/libs/services/src/lib/playlists.service.ts b/libs/services/src/lib/playlists.service.ts index fecbd6e3e..4ed395832 100644 --- a/libs/services/src/lib/playlists.service.ts +++ b/libs/services/src/lib/playlists.service.ts @@ -221,13 +221,27 @@ export class PlaylistsService { this.dbService.update(DbStores.Playlists, { ...portal, favorites: portal.favorites?.filter( - (i) => - (i as Partial).stream_id !== - favoriteId && - (i as Partial).series_id !== - favoriteId && - (i as Partial<{ movie_id: string }>).movie_id !== - favoriteId + (i) => { + const expectedId = String(favoriteId); + const streamId = String( + (i as Partial).stream_id ?? '' + ); + const seriesId = String( + (i as Partial).series_id ?? '' + ); + const movieId = String( + (i as Partial<{ movie_id: string }>).movie_id ?? + '' + ); + const itemId = String((i as any).id ?? ''); + + return ( + streamId !== expectedId && + seriesId !== expectedId && + movieId !== expectedId && + itemId !== expectedId + ); + } ), }) ) From 91aa6c02a0f7dbaffe3686df4800ed8634d7b5f3 Mon Sep 17 00:00:00 2001 From: 4gray Date: Sat, 14 Feb 2026 15:42:46 +0100 Subject: [PATCH 08/12] refactor(stalker): stalker store split to feature-based composition - Implemented `withStalkerEpg` for fetching channel EPG data. - Added `withStalkerFavorites` for managing favorite items. - Created `withStalkerPlayer` for playback functionalities including link generation and error handling. - Developed `withStalkerPortal` for managing portal state and requests. - Introduced `withStalkerRecent` for handling recently viewed items. - Added `withStalkerSelection` for managing selection state and pagination. - Implemented `withStalkerSeries` for managing series and episodes, including fetching and sorting functionalities. - Created utility functions for content mapping and sorting. - Updated index files to export new features and utilities. --- apps/web/src/app/stalker/stalker.store.ts | 1223 +---------------- .../src/app/stalker/stores/features/index.ts | 9 + .../features/with-stalker-content.feature.ts | 326 +++++ .../features/with-stalker-epg.feature.ts | 78 ++ .../with-stalker-favorites.feature.ts | 72 + .../features/with-stalker-player.feature.ts | 390 ++++++ .../features/with-stalker-portal.feature.ts | 105 ++ .../features/with-stalker-recent.feature.ts | 49 + .../with-stalker-selection.feature.ts | 135 ++ .../features/with-stalker-series.feature.ts | 275 ++++ apps/web/src/app/stalker/stores/index.ts | 3 + .../web/src/app/stalker/stores/utils/index.ts | 3 + .../stores/utils/stalker-content-mappers.ts | 77 ++ .../stalker/stores/utils/stalker-sorting.ts | 49 + 14 files changed, 1592 insertions(+), 1202 deletions(-) create mode 100644 apps/web/src/app/stalker/stores/features/index.ts create mode 100644 apps/web/src/app/stalker/stores/features/with-stalker-content.feature.ts create mode 100644 apps/web/src/app/stalker/stores/features/with-stalker-epg.feature.ts create mode 100644 apps/web/src/app/stalker/stores/features/with-stalker-favorites.feature.ts create mode 100644 apps/web/src/app/stalker/stores/features/with-stalker-player.feature.ts create mode 100644 apps/web/src/app/stalker/stores/features/with-stalker-portal.feature.ts create mode 100644 apps/web/src/app/stalker/stores/features/with-stalker-recent.feature.ts create mode 100644 apps/web/src/app/stalker/stores/features/with-stalker-selection.feature.ts create mode 100644 apps/web/src/app/stalker/stores/features/with-stalker-series.feature.ts create mode 100644 apps/web/src/app/stalker/stores/index.ts create mode 100644 apps/web/src/app/stalker/stores/utils/index.ts create mode 100644 apps/web/src/app/stalker/stores/utils/stalker-content-mappers.ts create mode 100644 apps/web/src/app/stalker/stores/utils/stalker-sorting.ts diff --git a/apps/web/src/app/stalker/stalker.store.ts b/apps/web/src/app/stalker/stalker.store.ts index 952595e01..19bb69a6c 100644 --- a/apps/web/src/app/stalker/stalker.store.ts +++ b/apps/web/src/app/stalker/stalker.store.ts @@ -1,1209 +1,28 @@ -import { computed, inject, resource, signal } from '@angular/core'; -import { MatSnackBar } from '@angular/material/snack-bar'; +import { signalStore } from '@ngrx/signals'; import { - patchState, - signalStore, - withComputed, - withMethods, - withProps, - withState, -} from '@ngrx/signals'; -import { TranslateService } from '@ngx-translate/core'; -import { DataService, PlaylistsService, StalkerSessionService } from 'services'; -import { - EpgItem, - Playlist, - PlaylistMeta, - STALKER_REQUEST, - StalkerPortalActions, -} from 'shared-interfaces'; -import { PlayerService } from '../services/player.service'; -import { ContentType } from '../xtream/content-type.enum'; -import { - StalkerCategoryItem, - StalkerContentItem, - StalkerItvChannel, - StalkerSeason, - StalkerVodSeriesEpisode, - StalkerVodSeriesSeason, - StalkerVodSource, -} from './models'; -import { StalkerContentTypes } from './stalker-content-types'; -import { createLogger } from '../shared/utils/logger'; -import { - normalizeStalkerEntityId, - normalizeStalkerEntityIdAsNumber, -} from './stalker-vod.utils'; - -interface StalkerState { - selectedContentType: 'vod' | 'itv' | 'series'; - selectedCategoryId: string | null | undefined; - selectedVodId: string | undefined; - selectedSerialId: string | undefined; - selectedItvId: string | undefined; - limit: number; - page: number; - searchPhrase: string; - currentPlaylist: PlaylistMeta; - totalCount: number; - selectedItem: StalkerVodSource | null | undefined; - vodCategories: StalkerCategoryItem[]; - seriesCategories: StalkerCategoryItem[]; - itvCategories: StalkerCategoryItem[]; - hasMoreChannels: boolean; - itvChannels: StalkerItvChannel[]; - /** For VOD items that are actually series (Ministra plugin is_series=1) */ - vodSeriesSeasons: StalkerVodSeriesSeason[]; - vodSeriesEpisodes: StalkerVodSeriesEpisode[]; - selectedVodSeriesSeasonId: string | undefined; -} - -const initialState: StalkerState = { - selectedContentType: 'vod', - selectedCategoryId: undefined, - selectedVodId: undefined, - selectedSerialId: undefined, - selectedItvId: undefined, - limit: 14, - page: 0, - searchPhrase: '', - currentPlaylist: undefined, - totalCount: 0, - selectedItem: undefined, - vodCategories: [], - seriesCategories: [], - itvCategories: [], - hasMoreChannels: false, - itvChannels: [], - vodSeriesSeasons: [], - vodSeriesEpisodes: [], - selectedVodSeriesSeasonId: undefined, -}; - -const logger = createLogger('StalkerStore'); - -function extractNumericValue(str: string) { - const matches = str.match(/\d+/); - if (matches) { - return parseInt(matches[0], 10); - } - return 0; -} - -function sortByNumericValue(array: StalkerSeason[]): StalkerSeason[] { - if (!array) return []; - const key = 'name'; - return array.sort((a, b) => { - const numericA = extractNumericValue(a[key]); - const numericB = extractNumericValue(b[key]); - return numericA - numericB; - }); -} - -function sortVodSeriesSeasonsByNumber( - array: StalkerVodSeriesSeason[] -): StalkerVodSeriesSeason[] { - if (!array) return []; - return [...array].sort((a, b) => { - const numA = Number(a.season_number ?? 0) || 0; - const numB = Number(b.season_number ?? 0) || 0; - return numA - numB; - }); -} + withStalkerContent, + withStalkerEpg, + withStalkerFavorites, + withStalkerPlayer, + withStalkerPortal, + withStalkerRecent, + withStalkerSelection, + withStalkerSeries, +} from './stores/features'; /** - * Sort episodes by series_number in ascending numeric order (1, 2, 3... not "1", "10", "2") + * StalkerStore facade composed from feature slices. + * Public API compatibility is preserved while implementation is split by concern. */ -function sortEpisodesByNumber( - episodes: StalkerVodSeriesEpisode[] -): StalkerVodSeriesEpisode[] { - if (!episodes) return []; - return episodes.sort((a, b) => { - const numA = Number(a.series_number ?? 0) || 0; - const numB = Number(b.series_number ?? 0) || 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: T, - portalUrl: string -): T { - const processed = { ...item }; - - // Convert screenshot_uri to absolute URL - if (processed.screenshot_uri) { - processed.screenshot_uri = makeAbsoluteUrl( - portalUrl, - processed.screenshot_uri - ); - } - - return processed; -} - -function toStalkerContentItem( - item: StalkerVodSource, - portalUrl: string -): StalkerContentItem { - const processed = processItemUrls(item, portalUrl); - return { - ...processed, - cover: processed.screenshot_uri, - }; -} - -function toStalkerItvChannel(item: StalkerContentItem): StalkerItvChannel { - return { - ...item, - id: item.id ?? item.stream_id ?? '', - cmd: String(item.cmd ?? ''), - name: - typeof item.name === 'string' ? item.name : undefined, - o_name: - typeof item.o_name === 'string' ? item.o_name : undefined, - logo: - typeof item.logo === 'string' ? item.logo : undefined, - }; -} - export const StalkerStore = signalStore( { providedIn: 'root' }, - withState(initialState), - 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) { - logger.error('Failed to get stalker token', error); - } - } - - 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 }): Promise => { - 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; - } - - 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)) { - logger.warn('Invalid categories response', response); - return []; - } - - const categories = response.js - .map((item): StalkerCategoryItem => ({ - category_name: item.title, - category_id: String(item.id), - })) - .sort((a, b) => - a.category_name.localeCompare(b.category_name) - ); - patchState(store, { - [`${params.contentType}Categories`]: categories, - }); - return categories; - }, - }), - getContentResource: resource({ - params: () => ({ - contentType: store.selectedContentType(), - category: store.selectedCategoryId(), - action: StalkerPortalActions.GetOrderedList, - search: store.searchPhrase(), - pageIndex: store.page() + 1, - }), - loader: async ({ - params, - }): Promise => { - if ( - !params.category || - params.category === null || - params.category === '' - ) { - return Promise.resolve(undefined); - } - - const currentPlaylist = store.currentPlaylist; - - // 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 - // Use "*" for categories without an ID (e.g. "All") to fetch all items - 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) { - logger.warn('Invalid response structure', response); - return []; - } - - patchState(store, { - totalCount: response.js.total_items ?? 0, - }); - - const portalUrl = currentPlaylist().portalUrl; - const newItems = response.js.data.map( - (item: StalkerVodSource) => - toStalkerContentItem(item, portalUrl) - ); - - if (store.selectedContentType() === 'itv') { - const channels = newItems.map(toStalkerItvChannel); - // Check if we're loading the first page or loading more - if (params.pageIndex === 1) { - patchState(store, { itvChannels: channels }); - } else { - patchState(store, { - itvChannels: [ - ...store.itvChannels(), - ...channels, - ], - }); - } - - // Update hasMoreItems based on total count and current items - const totalLoaded = store.itvChannels().length; - patchState(store, { - hasMoreChannels: - totalLoaded < (response.js.total_items ?? 0), - }); - } - - return newItems; - }, - }), - serialSeasonsResource: resource({ - params: () => ({ - itemId: store.selectedSerialId(), - }), - loader: async ({ params }) => { - // Guard: ensure currentPlaylist and itemId are available - if (!store.currentPlaylist() || !params.itemId) { - return []; - } - 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) { - logger.warn('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, - }): Promise => { - const item = params.selectedItem; - logger.debug( - 'vodSeriesSeasonsResource loader called', - { - item, - isSeries: item?.is_series, - currentPlaylist: store.currentPlaylist(), - } - ); - - // Only fetch if item is a VOD series (has is_series flag) - if (!store.currentPlaylist() || !item || !item.is_series) { - logger.debug( - 'vodSeriesSeasonsResource skipped - conditions not met' - ); - return []; - } - - const playlist = store.currentPlaylist() as Playlist; - const queryParams = { - action: StalkerPortalActions.GetOrderedList, - type: 'vod', - movie_id: item.id, - p: '1', - }; - - 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, - } - ); - } - - if (!response?.js?.data) { - logger.debug( - 'vodSeriesSeasonsResource - no response data' - ); - return []; - } - - logger.debug( - 'vodSeriesSeasonsResource response data', - response.js.data - ); - - // Filter for season items (is_season: true) - const seasons = response.js.data.filter( - (item: StalkerVodSeriesSeason) => - item.is_season === true - ); - logger.debug( - 'vodSeriesSeasonsResource filtered seasons', - seasons - ); - return sortVodSeriesSeasonsByNumber(seasons); - }, - }), - }) - ), - withComputed((store) => ({ - getTotalPages: computed(() => { - return Math.ceil(store.totalCount() / store.limit()); - }), - /** category content */ - getPaginatedContent: computed(() => store.getContentResource.value()), - isPaginatedContentLoading: computed(() => - store.getContentResource.isLoading() - ), - isPaginatedContentFailed: computed(() => - store.getContentResource.error() - ), - /** serials */ - 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(() => - store.getCategoryResource.isLoading() - ), - isCategoryResourceFailed: computed(() => - store.getCategoryResource.error() - ), - getSelectedCategoryName: computed(() => { - const type = store.selectedContentType(); - const selectedCategoryId = store.selectedCategoryId(); - if (!selectedCategoryId) return ''; - let categories = []; - if (type === 'vod') { - categories = store.vodCategories(); - } else if (type === 'series') { - categories = store.seriesCategories(); - } else if (type === 'itv') { - categories = store.itvCategories(); - } - const category = categories.find( - (cat) => String(cat.category_id) === String(selectedCategoryId) - ); - return category ? category.category_name : ''; - }), - })), - withMethods( - ( - store, - dataService = inject(DataService), - playerService = inject(PlayerService), - playlistService = inject(PlaylistsService), - stalkerSession = inject(StalkerSessionService), - snackBar = inject(MatSnackBar), - translate = inject(TranslateService) - ) => ({ - /** selectors */ - setSelectedContentType(type: 'vod' | 'itv' | 'series') { - patchState(store, { selectedContentType: type }); - }, - setSelectedCategory(id: string | number | null) { - patchState(store, { - selectedCategoryId: - id !== null && id !== undefined - ? String(id) - : null, - page: 0, - }); - }, - setSelectedSerialId(id: string) { - patchState(store, { selectedSerialId: id }); - }, - setSelectedVodId(id: string) { - patchState(store, { selectedVodId: id }); - }, - setSelectedItvId(id: string) { - patchState(store, { selectedItvId: id }); - }, - setLimit(limit: number) { - patchState(store, { limit }); - }, - setPage(page: number) { - patchState(store, { page }); - }, - async setCurrentPlaylist(playlist: PlaylistMeta | undefined) { - patchState(store, { currentPlaylist: playlist }); - - // Ensure Stalker playlist exists in SQLite for playback positions - // Only sync if this is actually a Stalker playlist (has macAddress and portalUrl) - if ( - playlist && - dataService.isElectron && - playlist._id && - playlist.macAddress && - playlist.portalUrl - ) { - try { - const playlistId = String(playlist._id); - // Check if playlist exists in SQLite - const existing = - await window.electron.dbGetPlaylist(playlistId); - if (!existing) { - // Create playlist in SQLite - await window.electron.dbCreatePlaylist({ - id: playlistId, - name: playlist.title || '', - macAddress: playlist.macAddress || '', - url: playlist.portalUrl || '', - type: 'stalker', - }); - } - } catch (error) { - logger.error( - 'Error syncing Stalker playlist to SQLite', - error - ); - } - } - }, - setSelectedItem(selectedItem: StalkerVodSource | null | undefined) { - // TODO: check the item type and proper property either selectedVodId or serialsId etc - const selectedIdRaw = - selectedItem?.id !== undefined && - selectedItem?.id !== null - ? selectedItem.id - : undefined; - const selectedId = - selectedIdRaw !== undefined - ? normalizeStalkerEntityId(selectedIdRaw) - : undefined; - patchState(store, { - selectedVodId: selectedId, - selectedSerialId: selectedId, - selectedItvId: selectedId, - selectedItem, - }); - }, - clearSelectedItem() { - patchState(store, { - selectedVodId: undefined, - selectedSerialId: undefined, - selectedItvId: undefined, - selectedItem: undefined, - }); - }, - setCategories( - type: 'vod' | 'series' | 'itv', - categories: StalkerCategoryItem[] - ) { - if (type === 'vod') { - patchState(store, { vodCategories: categories }); - } else if (type === 'series') { - patchState(store, { seriesCategories: categories }); - } else if (type === 'itv') { - patchState(store, { itvCategories: categories }); - } - }, - resetCategories() { - patchState(store, { - vodCategories: [], - seriesCategories: [], - itvCategories: [], - }); - }, - setItvChannels(channels: StalkerItvChannel[]) { - patchState(store, { itvChannels: channels }); - }, - setSearchPhrase(phrase: string) { - patchState(store, { searchPhrase: phrase, page: 0 }); - }, - /** - * 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', - }; - - 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, - }); - } - - if (!response?.js?.data) { - return []; - } - - // Filter for episode items (is_episode: true) and sort by series_number - const episodes = sortEpisodesByNumber( - response.js.data.filter( - (item: StalkerVodSeriesEpisode) => - item.is_episode === true - ) - ); - - // Store episodes and selected season - patchState(store, { - vodSeriesEpisodes: episodes, - selectedVodSeriesSeasonId: seasonId, - }); - - return episodes; - }, - /** getters */ - getSelectedCategory: computed(() => { - const categoryId = store.selectedCategoryId(); - if (!categoryId) { - return { - id: 0, - name: 'All Items', - type: store.selectedContentType(), - }; - } - - // Get categories based on content type - const contentType = store.selectedContentType(); - let categories: any[] = []; - if (contentType === 'vod') { - categories = store.vodCategories(); - } else if (contentType === 'series') { - categories = store.seriesCategories(); - } else if (contentType === 'itv') { - categories = store.itvCategories(); - } - - return categories.find( - (c: any) => String(c.category_id) === String(categoryId) - ) || { id: categoryId, name: '', type: contentType }; - }), - /** API */ - async fetchLinkToPlay( - portalUrl: string, - macAddress: string, - cmd: string, - series?: number - ) { - 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, - disable_ad: '0', - download: '0', - JsHttpRequest: '1-xml', - ...(series ? { series: String(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; - logger.error('Server error', errorMsg); - throw new Error(errorMsg); - } - - let url = response.js.cmd as string; - - // 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() { - const params = { - type: 'account_info', - action: 'get_main_info', - JsHttpRequest: '1-xml', - }; - - try { - // 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 - const expireDate = response.js.account_info.expire_date; - - // Convert timestamp to readable date if it's a unix timestamp - if (expireDate && !isNaN(expireDate)) { - const date = new Date(expireDate * 1000); // Convert seconds to milliseconds - return date.toLocaleDateString(); - } - - return expireDate || 'Unknown'; - } - - return 'Unknown'; - } catch (error) { - logger.error('Failed to fetch expire date', error); - return 'Error fetching data'; - } - }, - addToFavorites(item: any, onDone?: () => void) { - playlistService - .addPortalFavorite(this.currentPlaylist()?._id, { - ...item, - category_id: store.selectedContentType(), - added_at: Date.now(), - id: item.stream_id ?? item.id, - }) - .subscribe(() => { - snackBar.open( - translate.instant('PORTALS.ADDED_TO_FAVORITES'), - null, - { - duration: 1000, - } - ); - onDone?.(); - }); - }, - removeFromFavorites(favoriteId: string, onDone?: () => void) { - playlistService - .removeFromPortalFavorites( - this.currentPlaylist()?._id, - favoriteId - ) - .subscribe(() => { - snackBar.open( - translate.instant('PORTALS.REMOVED_FROM_FAVORITES'), - null, - { - duration: 1000, - } - ); - onDone?.(); - }); - }, - /** - * 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', - }; - - 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, - }); - } - - // Extract id from the first data item - if (response?.js?.data?.[0]?.id) { - const fileId = response.js.data[0].id; - return String(fileId); - } - - return null; - }, - /** - * Play VOD or episode content - * @param cmd The media command/path - * @param title Display title - * @param thumbnail Thumbnail URL - * @param episodeNum Episode number (for series param in API) - * @param episodeId Optional episode ID for playback tracking (defaults to item.id) - * @param startTime Optional start time in seconds for resume playback - */ - async createLinkToPlayVod( - cmd?: string, - title?: string, - thumbnail?: string, - episodeNum?: number, - episodeId?: number, - startTime?: number - ) { - try { - const item = this.selectedItem(); - let cmdToUse = cmd ?? item?.cmd; - - if (!cmdToUse) { - throw new Error('nothing_to_play'); - } - - // 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_') - ) { - const fileId = await this.fetchMovieFileId( - normalizeStalkerEntityId(item.id) - ); - if (fileId) { - cmdToUse = `/media/file_${fileId}.mpg`; - } - } - - const url = await this.fetchLinkToPlay( - this.currentPlaylist().portalUrl, - this.currentPlaylist().macAddress, - cmdToUse, - episodeNum - ); - this.addToRecentlyViewed({ - ...item, - id: item?.id, - cmd: cmd, - cover: thumbnail, - title, - }); - const playlist = this.currentPlaylist(); - const isEpisode = episodeNum !== undefined || episodeId !== undefined; - const selectedItemId = - normalizeStalkerEntityIdAsNumber(item?.id) ?? 0; - const contentInfo = { - playlistId: playlist._id, - // For episodes, use episodeId if provided, otherwise fall back to item.id - contentXtreamId: - isEpisode && episodeId - ? episodeId - : selectedItemId, - contentType: isEpisode ? 'episode' : 'vod', - seriesXtreamId: isEpisode ? selectedItemId : undefined, - }; - - playerService.openPlayer( - url, - title, - thumbnail, - true, - false, - playlist?.userAgent, - playlist?.referrer, - playlist?.origin, - contentInfo, - startTime - ); - } catch (error) { - logger.error('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) { - playlistService - .addPortalRecentlyViewed(this.currentPlaylist()?._id, { - ...item, - category_id: store.selectedContentType(), - added_at: Date.now(), - }) - .subscribe(); - }, - removeFromRecentlyViewed(itemId: number, onComplete?: () => void) { - playlistService - .removeFromPortalRecentlyViewed( - this.currentPlaylist()?._id, - itemId - ) - .subscribe({ - next: () => onComplete?.(), - error: (error) => - logger.error( - 'Failed to remove item from recently viewed', - error - ), - }); - }, - async fetchChannelEpg( - channelId: number | string, - size = 10 - ): Promise { - const playlist = store.currentPlaylist() as Playlist; - if (!playlist) return []; - - const queryParams: Record = { - action: StalkerPortalActions.GetShortEpg, - type: 'itv', - ch_id: String(channelId), - size: String(size), - }; - - 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, - } - ); - } - - const epgData = - response?.js?.data ?? response?.js ?? []; - const items = Array.isArray(epgData) ? epgData : []; - - return items.map((item) => ({ - id: String(item.id ?? ''), - epg_id: '', - title: item.name ?? '', - description: item.descr ?? '', - lang: '', - start: item.time ?? '', - end: item.time_to ?? '', - stop: item.time_to ?? '', - channel_id: String(item.ch_id ?? channelId), - start_timestamp: String( - item.start_timestamp ?? '' - ), - stop_timestamp: String( - item.stop_timestamp ?? '' - ), - })); - }, - }) - ) + withStalkerPortal(), + withStalkerSelection(), + withStalkerContent(), + withStalkerSeries(), + withStalkerPlayer(), + withStalkerFavorites(), + withStalkerRecent(), + withStalkerEpg() ); + diff --git a/apps/web/src/app/stalker/stores/features/index.ts b/apps/web/src/app/stalker/stores/features/index.ts new file mode 100644 index 000000000..264f02961 --- /dev/null +++ b/apps/web/src/app/stalker/stores/features/index.ts @@ -0,0 +1,9 @@ +export * from './with-stalker-content.feature'; +export * from './with-stalker-epg.feature'; +export * from './with-stalker-favorites.feature'; +export * from './with-stalker-player.feature'; +export * from './with-stalker-portal.feature'; +export * from './with-stalker-recent.feature'; +export * from './with-stalker-selection.feature'; +export * from './with-stalker-series.feature'; + diff --git a/apps/web/src/app/stalker/stores/features/with-stalker-content.feature.ts b/apps/web/src/app/stalker/stores/features/with-stalker-content.feature.ts new file mode 100644 index 000000000..63b5c626b --- /dev/null +++ b/apps/web/src/app/stalker/stores/features/with-stalker-content.feature.ts @@ -0,0 +1,326 @@ +import { computed, inject, resource } from '@angular/core'; +import { + patchState, + signalStoreFeature, + withComputed, + withMethods, + withProps, + withState, +} from '@ngrx/signals'; +import { DataService, StalkerSessionService } from 'services'; +import { + Playlist, + STALKER_REQUEST, + StalkerPortalActions, +} from 'shared-interfaces'; +import { + StalkerCategoryItem, + StalkerContentItem, + StalkerItvChannel, + StalkerVodSource, +} from '../../models'; +import { StalkerContentTypes } from '../../stalker-content-types'; +import { createLogger } from '../../../shared/utils/logger'; +import { toStalkerContentItem, toStalkerItvChannel } from '../utils'; + +/** + * Content/categories/channels feature state. + */ +export interface StalkerContentState { + totalCount: number; + vodCategories: StalkerCategoryItem[]; + seriesCategories: StalkerCategoryItem[]; + itvCategories: StalkerCategoryItem[]; + hasMoreChannels: boolean; + itvChannels: StalkerItvChannel[]; +} + +const initialContentState: StalkerContentState = { + totalCount: 0, + vodCategories: [], + seriesCategories: [], + itvCategories: [], + hasMoreChannels: false, + itvChannels: [], +}; + +export function withStalkerContent() { + const logger = createLogger('withStalkerContent'); + return signalStoreFeature( + withState(initialContentState), + withProps( + ( + store, + dataService = inject(DataService), + stalkerSession = inject(StalkerSessionService) + ) => ({ + getCategoryResource: resource({ + params: () => ({ + contentType: (store as any).selectedContentType(), + action: StalkerPortalActions.GetCategories, + currentPlaylist: (store as any).currentPlaylist(), + }), + loader: async ({ + params, + }): Promise => { + 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; + } + + 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)) { + logger.warn('Invalid categories response', response); + return []; + } + + const categories = response.js + .map((item): StalkerCategoryItem => ({ + category_name: item.title, + category_id: String(item.id), + })) + .sort((a, b) => + a.category_name.localeCompare(b.category_name) + ); + patchState(store, { + [`${params.contentType}Categories`]: categories, + }); + return categories; + }, + }), + getContentResource: resource({ + params: () => ({ + contentType: (store as any).selectedContentType(), + category: (store as any).selectedCategoryId(), + action: StalkerPortalActions.GetOrderedList, + search: (store as any).searchPhrase(), + pageIndex: (store as any).page() + 1, + }), + loader: async ({ + params, + }): Promise => { + if ( + !params.category || + params.category === null || + params.category === '' + ) { + return Promise.resolve(undefined); + } + + const currentPlaylist = (store as any).currentPlaylist; + + // 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 + // Use "*" for categories without an ID (e.g. "All") to fetch all items + 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) { + logger.warn('Invalid response structure', response); + return []; + } + + patchState(store, { + totalCount: response.js.total_items ?? 0, + }); + + const portalUrl = currentPlaylist().portalUrl; + const newItems = response.js.data.map( + (item: StalkerVodSource) => + toStalkerContentItem(item, portalUrl) + ); + + if ((store as any).selectedContentType() === 'itv') { + const channels = newItems.map(toStalkerItvChannel); + // Check if we're loading the first page or loading more + if (params.pageIndex === 1) { + patchState(store, { itvChannels: channels }); + } else { + patchState(store, { + itvChannels: [ + ...store.itvChannels(), + ...channels, + ], + }); + } + + // Update hasMoreItems based on total count and current items + const totalLoaded = store.itvChannels().length; + patchState(store, { + hasMoreChannels: + totalLoaded < (response.js.total_items ?? 0), + }); + } + + return newItems; + }, + }), + }) + ), + withComputed((store) => { + const storeAny = store as any; + return { + getTotalPages: computed(() => { + return Math.ceil(store.totalCount() / storeAny.limit()); + }), + getSelectedCategoryName: computed(() => { + const type = storeAny.selectedContentType(); + const selectedCategoryId = storeAny.selectedCategoryId(); + if (!selectedCategoryId) return ''; + let categories = []; + if (type === 'vod') { + categories = store.vodCategories(); + } else if (type === 'series') { + categories = store.seriesCategories(); + } else if (type === 'itv') { + categories = store.itvCategories(); + } + const category = categories.find( + (cat) => + String(cat.category_id) === + String(selectedCategoryId) + ); + return category ? category.category_name : ''; + }), + /** category content */ + getPaginatedContent: computed(() => + storeAny.getContentResource.value() + ), + isPaginatedContentLoading: computed(() => + storeAny.getContentResource.isLoading() + ), + isPaginatedContentFailed: computed(() => + storeAny.getContentResource.error() + ), + /** category resource */ + getCategoryResource: computed(() => + storeAny.getCategoryResource.value() + ), + isCategoryResourceLoading: computed(() => + storeAny.getCategoryResource.isLoading() + ), + isCategoryResourceFailed: computed(() => + storeAny.getCategoryResource.error() + ), + }; + }), + withMethods((store) => ({ + setCategories( + type: 'vod' | 'series' | 'itv', + categories: StalkerCategoryItem[] + ) { + if (type === 'vod') { + patchState(store, { vodCategories: categories }); + } else if (type === 'series') { + patchState(store, { seriesCategories: categories }); + } else if (type === 'itv') { + patchState(store, { itvCategories: categories }); + } + }, + resetCategories() { + patchState(store, { + vodCategories: [], + seriesCategories: [], + itvCategories: [], + }); + }, + setItvChannels(channels: StalkerItvChannel[]) { + patchState(store, { itvChannels: channels }); + }, + })) + ); +} diff --git a/apps/web/src/app/stalker/stores/features/with-stalker-epg.feature.ts b/apps/web/src/app/stalker/stores/features/with-stalker-epg.feature.ts new file mode 100644 index 000000000..d08f84502 --- /dev/null +++ b/apps/web/src/app/stalker/stores/features/with-stalker-epg.feature.ts @@ -0,0 +1,78 @@ +import { inject } from '@angular/core'; +import { signalStoreFeature, withMethods } from '@ngrx/signals'; +import { DataService, StalkerSessionService } from 'services'; +import { + EpgItem, + Playlist, + STALKER_REQUEST, + StalkerPortalActions, +} from 'shared-interfaces'; + +/** + * EPG concern methods. + */ +export function withStalkerEpg() { + return signalStoreFeature( + withMethods( + ( + store, + dataService = inject(DataService), + stalkerSession = inject(StalkerSessionService) + ) => { + const storeAny = store as any; + + return { + async fetchChannelEpg( + channelId: number | string, + size = 10 + ): Promise { + const playlist = storeAny.currentPlaylist() as Playlist; + if (!playlist) return []; + + const queryParams: Record = { + action: StalkerPortalActions.GetShortEpg, + type: 'itv', + ch_id: String(channelId), + size: String(size), + }; + + 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, + } + ); + } + + const epgData = response?.js?.data ?? response?.js ?? []; + const items = Array.isArray(epgData) ? epgData : []; + + return items.map((item) => ({ + id: String(item.id ?? ''), + epg_id: '', + title: item.name ?? '', + description: item.descr ?? '', + lang: '', + start: item.time ?? '', + end: item.time_to ?? '', + stop: item.time_to ?? '', + channel_id: String(item.ch_id ?? channelId), + start_timestamp: String(item.start_timestamp ?? ''), + stop_timestamp: String(item.stop_timestamp ?? ''), + })); + }, + }; + } + ) + ); +} diff --git a/apps/web/src/app/stalker/stores/features/with-stalker-favorites.feature.ts b/apps/web/src/app/stalker/stores/features/with-stalker-favorites.feature.ts new file mode 100644 index 000000000..9d17ba24b --- /dev/null +++ b/apps/web/src/app/stalker/stores/features/with-stalker-favorites.feature.ts @@ -0,0 +1,72 @@ +import { inject } from '@angular/core'; +import { MatSnackBar } from '@angular/material/snack-bar'; +import { withMethods, signalStoreFeature } from '@ngrx/signals'; +import { TranslateService } from '@ngx-translate/core'; +import { PlaylistsService } from 'services'; + +/** + * Favorites concern methods. + */ +export function withStalkerFavorites() { + return signalStoreFeature( + withMethods( + ( + store, + playlistService = inject(PlaylistsService), + snackBar = inject(MatSnackBar), + translate = inject(TranslateService) + ) => { + const storeAny = store as any; + return { + addToFavorites(item: any, onDone?: () => void) { + playlistService + .addPortalFavorite( + storeAny.currentPlaylist()?._id, + { + ...item, + category_id: + storeAny.selectedContentType(), + added_at: Date.now(), + id: item.stream_id ?? item.id, + } + ) + .subscribe(() => { + snackBar.open( + translate.instant( + 'PORTALS.ADDED_TO_FAVORITES' + ), + null, + { + duration: 1000, + } + ); + onDone?.(); + }); + }, + removeFromFavorites( + favoriteId: string, + onDone?: () => void + ) { + playlistService + .removeFromPortalFavorites( + storeAny.currentPlaylist()?._id, + favoriteId + ) + .subscribe(() => { + snackBar.open( + translate.instant( + 'PORTALS.REMOVED_FROM_FAVORITES' + ), + null, + { + duration: 1000, + } + ); + onDone?.(); + }); + }, + }; + } + ) + ); +} diff --git a/apps/web/src/app/stalker/stores/features/with-stalker-player.feature.ts b/apps/web/src/app/stalker/stores/features/with-stalker-player.feature.ts new file mode 100644 index 000000000..67e0f95d4 --- /dev/null +++ b/apps/web/src/app/stalker/stores/features/with-stalker-player.feature.ts @@ -0,0 +1,390 @@ +import { inject } from '@angular/core'; +import { MatSnackBar } from '@angular/material/snack-bar'; +import { signalStoreFeature, withMethods } from '@ngrx/signals'; +import { TranslateService } from '@ngx-translate/core'; +import { DataService, PlaylistsService, StalkerSessionService } from 'services'; +import { Playlist, STALKER_REQUEST, StalkerPortalActions } from 'shared-interfaces'; +import { PlayerService } from '../../../services/player.service'; +import { ContentType } from '../../../xtream/content-type.enum'; +import { StalkerContentTypes } from '../../stalker-content-types'; +import { + normalizeStalkerEntityId, + normalizeStalkerEntityIdAsNumber, +} from '../../stalker-vod.utils'; +import { createLogger } from '../../../shared/utils/logger'; + +/** + * Playback/link/player concern methods. + */ +export function withStalkerPlayer() { + const logger = createLogger('withStalkerPlayer'); + return signalStoreFeature( + withMethods( + ( + store, + dataService = inject(DataService), + playlistService = inject(PlaylistsService), + playerService = inject(PlayerService), + stalkerSession = inject(StalkerSessionService), + snackBar = inject(MatSnackBar), + translate = inject(TranslateService) + ) => { + const storeAny = store as any; + const fetchLinkToPlayInternal = async ( + portalUrl: string, + macAddress: string, + cmd: string, + series?: number + ) => { + const type = series + ? ContentType.VODS + : storeAny.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[ + storeAny.selectedContentType() + ].getLink, + cmd: cmd, + type, + disable_ad: '0', + download: '0', + JsHttpRequest: '1-xml', + ...(series ? { series: String(series) } : {}), + }; + + // Use makeAuthenticatedRequest for automatic retry on auth failure + const playlist = storeAny.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; + logger.error('Server error', errorMsg); + throw new Error(errorMsg); + } + + let url = response.js.cmd as string; + + // 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; + }; + + const fetchMovieFileIdInternal = async ( + movieId: string + ): Promise => { + const playlist = storeAny.currentPlaylist() as Playlist; + if (!playlist) return null; + + const queryParams = { + action: StalkerPortalActions.GetOrderedList, + type: 'vod', + movie_id: movieId, + p: '1', + }; + + 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, + } + ); + } + + // Extract id from the first data item + if (response?.js?.data?.[0]?.id) { + const fileId = response.js.data[0].id; + return String(fileId); + } + + return null; + }; + + const addToRecentlyViewedInternal = ( + item: any, + cmd?: string, + cover?: string, + title?: string + ) => { + const playlistId = storeAny.currentPlaylist()?._id; + if (!playlistId) return; + playlistService + .addPortalRecentlyViewed(playlistId, { + ...item, + id: item?.id, + cmd, + cover, + title, + category_id: storeAny.selectedContentType(), + added_at: Date.now(), + }) + .subscribe(); + }; + + return { + async fetchLinkToPlay( + portalUrl: string, + macAddress: string, + cmd: string, + series?: number + ) { + return fetchLinkToPlayInternal( + portalUrl, + macAddress, + cmd, + series + ); + }, + async getExpireDate() { + const params = { + type: 'account_info', + action: 'get_main_info', + JsHttpRequest: '1-xml', + }; + + try { + // Use makeAuthenticatedRequest for automatic retry on auth failure + const playlist = storeAny.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 + const expireDate = + response.js.account_info.expire_date; + + // Convert timestamp to readable date if it's a unix timestamp + if (expireDate && !isNaN(expireDate)) { + const date = new Date(expireDate * 1000); // Convert seconds to milliseconds + return date.toLocaleDateString(); + } + + return expireDate || 'Unknown'; + } + + return 'Unknown'; + } catch (error) { + logger.error('Failed to fetch expire date', error); + return 'Error fetching data'; + } + }, + /** + * 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 { + return fetchMovieFileIdInternal(movieId); + }, + /** + * Play VOD or episode content + * @param cmd The media command/path + * @param title Display title + * @param thumbnail Thumbnail URL + * @param episodeNum Episode number (for series param in API) + * @param episodeId Optional episode ID for playback tracking (defaults to item.id) + * @param startTime Optional start time in seconds for resume playback + */ + async createLinkToPlayVod( + cmd?: string, + title?: string, + thumbnail?: string, + episodeNum?: number, + episodeId?: number, + startTime?: number + ) { + try { + const item = storeAny.selectedItem(); + let cmdToUse = cmd ?? item?.cmd; + + if (!cmdToUse) { + throw new Error('nothing_to_play'); + } + + // 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_') + ) { + const fileId = await fetchMovieFileIdInternal( + normalizeStalkerEntityId(item.id) + ); + if (fileId) { + cmdToUse = `/media/file_${fileId}.mpg`; + } + } + + const playlist = storeAny.currentPlaylist(); + const url = await fetchLinkToPlayInternal( + playlist.portalUrl, + playlist.macAddress, + cmdToUse, + episodeNum + ); + if (typeof storeAny.addToRecentlyViewed === 'function') { + storeAny.addToRecentlyViewed({ + ...item, + id: item?.id, + cmd: cmd, + cover: thumbnail, + title, + }); + } else { + addToRecentlyViewedInternal( + item, + cmd, + thumbnail, + title + ); + } + + const isEpisode = + episodeNum !== undefined || + episodeId !== undefined; + const selectedItemId = + normalizeStalkerEntityIdAsNumber(item?.id) ?? 0; + const contentInfo = { + playlistId: playlist._id, + // For episodes, use episodeId if provided, otherwise fall back to item.id + contentXtreamId: + isEpisode && episodeId + ? episodeId + : selectedItemId, + contentType: isEpisode ? 'episode' : 'vod', + seriesXtreamId: isEpisode + ? selectedItemId + : undefined, + }; + + playerService.openPlayer( + url, + title, + thumbnail, + true, + false, + playlist?.userAgent, + playlist?.referrer, + playlist?.origin, + contentInfo, + startTime + ); + } catch (error) { + logger.error('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, + }); + } + }, + }; + } + ) + ); +} diff --git a/apps/web/src/app/stalker/stores/features/with-stalker-portal.feature.ts b/apps/web/src/app/stalker/stores/features/with-stalker-portal.feature.ts new file mode 100644 index 000000000..69be4da51 --- /dev/null +++ b/apps/web/src/app/stalker/stores/features/with-stalker-portal.feature.ts @@ -0,0 +1,105 @@ +import { inject } from '@angular/core'; +import { + patchState, + signalStoreFeature, + withProps, + withMethods, + withState, +} from '@ngrx/signals'; +import { Playlist, PlaylistMeta, STALKER_REQUEST } from 'shared-interfaces'; +import { DataService, StalkerSessionService } from 'services'; +import { createLogger } from '../../../shared/utils/logger'; + +/** + * Portal/session state and methods. + */ +export interface StalkerPortalState { + currentPlaylist: PlaylistMeta; +} + +const initialPortalState: StalkerPortalState = { + currentPlaylist: undefined, +}; + +export function withStalkerPortal() { + const logger = createLogger('withStalkerPortal'); + return signalStoreFeature( + withState(initialPortalState), + 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) { + logger.error('Failed to get stalker token', error); + } + } + + return dataService.sendIpcEvent(STALKER_REQUEST, { + url: playlist.portalUrl, + macAddress: playlist.macAddress, + params, + token, + serialNumber, + }); + }, + }) + ), + withMethods((store, dataService = inject(DataService)) => ({ + async setCurrentPlaylist(playlist: PlaylistMeta | undefined) { + patchState(store, { currentPlaylist: playlist }); + + // Ensure Stalker playlist exists in SQLite for playback positions + // Only sync if this is actually a Stalker playlist (has macAddress and portalUrl) + if ( + playlist && + dataService.isElectron && + playlist._id && + playlist.macAddress && + playlist.portalUrl + ) { + try { + const playlistId = String(playlist._id); + // Check if playlist exists in SQLite + const existing = + await window.electron.dbGetPlaylist(playlistId); + if (!existing) { + // Create playlist in SQLite + await window.electron.dbCreatePlaylist({ + id: playlistId, + name: playlist.title || '', + macAddress: playlist.macAddress || '', + url: playlist.portalUrl || '', + type: 'stalker', + }); + } + } catch (error) { + logger.error( + 'Error syncing Stalker playlist to SQLite', + error + ); + } + } + }, + })) + ); +} diff --git a/apps/web/src/app/stalker/stores/features/with-stalker-recent.feature.ts b/apps/web/src/app/stalker/stores/features/with-stalker-recent.feature.ts new file mode 100644 index 000000000..984c95768 --- /dev/null +++ b/apps/web/src/app/stalker/stores/features/with-stalker-recent.feature.ts @@ -0,0 +1,49 @@ +import { inject } from '@angular/core'; +import { signalStoreFeature, withMethods } from '@ngrx/signals'; +import { PlaylistsService } from 'services'; +import { createLogger } from '../../../shared/utils/logger'; + +/** + * Recently-viewed concern methods. + */ +export function withStalkerRecent() { + const logger = createLogger('withStalkerRecent'); + return signalStoreFeature( + withMethods((store, playlistService = inject(PlaylistsService)) => { + const storeAny = store as any; + + return { + addToRecentlyViewed(item: any) { + playlistService + .addPortalRecentlyViewed( + storeAny.currentPlaylist()?._id, + { + ...item, + category_id: storeAny.selectedContentType(), + added_at: Date.now(), + } + ) + .subscribe(); + }, + removeFromRecentlyViewed( + itemId: string | number, + onComplete?: () => void + ) { + playlistService + .removeFromPortalRecentlyViewed( + storeAny.currentPlaylist()?._id, + itemId + ) + .subscribe({ + next: () => onComplete?.(), + error: (error) => + logger.error( + 'Failed to remove item from recently viewed', + error + ), + }); + }, + }; + }) + ); +} diff --git a/apps/web/src/app/stalker/stores/features/with-stalker-selection.feature.ts b/apps/web/src/app/stalker/stores/features/with-stalker-selection.feature.ts new file mode 100644 index 000000000..c6cb7ff34 --- /dev/null +++ b/apps/web/src/app/stalker/stores/features/with-stalker-selection.feature.ts @@ -0,0 +1,135 @@ +import { + patchState, + signalStoreFeature, + withMethods, + withState, +} from '@ngrx/signals'; +import { computed } from '@angular/core'; +import { StalkerVodSource } from '../../models'; +import { normalizeStalkerEntityId } from '../../stalker-vod.utils'; + +/** + * Selection/pagination/search feature state. + */ +export interface StalkerSelectionState { + selectedContentType: 'vod' | 'itv' | 'series'; + selectedCategoryId: string | null | undefined; + selectedVodId: string | undefined; + selectedSerialId: string | undefined; + selectedItvId: string | undefined; + limit: number; + page: number; + searchPhrase: string; + selectedItem: StalkerVodSource | null | undefined; +} + +const initialSelectionState: StalkerSelectionState = { + selectedContentType: 'vod', + selectedCategoryId: undefined, + selectedVodId: undefined, + selectedSerialId: undefined, + selectedItvId: undefined, + limit: 14, + page: 0, + searchPhrase: '', + selectedItem: undefined, +}; + +export function withStalkerSelection() { + return signalStoreFeature( + withState(initialSelectionState), + withMethods((store) => ({ + setSelectedContentType(type: 'vod' | 'itv' | 'series') { + patchState(store, { selectedContentType: type }); + }, + setSelectedCategory(id: string | number | null) { + patchState(store, { + selectedCategoryId: + id !== null && id !== undefined + ? String(id) + : null, + page: 0, + }); + }, + setSelectedSerialId(id: string) { + patchState(store, { selectedSerialId: id }); + }, + setSelectedVodId(id: string) { + patchState(store, { selectedVodId: id }); + }, + setSelectedItvId(id: string) { + patchState(store, { selectedItvId: id }); + }, + setLimit(limit: number) { + patchState(store, { limit }); + }, + setPage(page: number) { + patchState(store, { page }); + }, + setSearchPhrase(phrase: string) { + patchState(store, { searchPhrase: phrase, page: 0 }); + }, + setSelectedItem(selectedItem: StalkerVodSource | null | undefined) { + const selectedIdRaw = + selectedItem?.id !== undefined && + selectedItem?.id !== null + ? selectedItem.id + : undefined; + const selectedId = + selectedIdRaw !== undefined + ? normalizeStalkerEntityId(selectedIdRaw) + : undefined; + patchState(store, { + selectedVodId: selectedId, + selectedSerialId: selectedId, + selectedItvId: selectedId, + selectedItem, + }); + }, + clearSelectedItem() { + patchState(store, { + selectedVodId: undefined, + selectedSerialId: undefined, + selectedItvId: undefined, + selectedItem: undefined, + }); + }, + /** getters */ + getSelectedCategory: computed(() => { + const storeAny = store as any; + const categoryId = store.selectedCategoryId(); + if (!categoryId) { + return { + id: 0, + name: 'All Items', + type: store.selectedContentType(), + }; + } + + // Get categories based on content type + const contentType = store.selectedContentType(); + let categories: any[] = []; + const readCategories = ( + getterName: 'vodCategories' | 'seriesCategories' | 'itvCategories' + ) => + typeof storeAny[getterName] === 'function' + ? storeAny[getterName]() + : []; + if (contentType === 'vod') { + categories = readCategories('vodCategories'); + } else if (contentType === 'series') { + categories = readCategories('seriesCategories'); + } else if (contentType === 'itv') { + categories = readCategories('itvCategories'); + } + + return ( + categories.find( + (c: any) => + String(c.category_id) === String(categoryId) + ) || { id: categoryId, name: '', type: contentType } + ); + }), + })) + ); +} diff --git a/apps/web/src/app/stalker/stores/features/with-stalker-series.feature.ts b/apps/web/src/app/stalker/stores/features/with-stalker-series.feature.ts new file mode 100644 index 000000000..4a96502b9 --- /dev/null +++ b/apps/web/src/app/stalker/stores/features/with-stalker-series.feature.ts @@ -0,0 +1,275 @@ +import { computed, inject } from '@angular/core'; +import { + patchState, + signalStoreFeature, + withComputed, + withMethods, + withProps, + withState, +} from '@ngrx/signals'; +import { DataService, StalkerSessionService } from 'services'; +import { Playlist, STALKER_REQUEST, StalkerPortalActions } from 'shared-interfaces'; +import { + StalkerVodSeriesEpisode, + StalkerVodSeriesSeason, +} from '../../models'; +import { sortEpisodesByNumber } from '../utils'; +import { resource } from '@angular/core'; +import { StalkerContentTypes } from '../../stalker-content-types'; +import { sortByNumericValue, sortVodSeriesSeasonsByNumber } from '../utils'; +import { createLogger } from '../../../shared/utils/logger'; + +/** + * Regular-series and VOD-series feature state. + */ +export interface StalkerSeriesState { + /** For VOD items that are actually series (Ministra plugin is_series=1) */ + vodSeriesSeasons: StalkerVodSeriesSeason[]; + vodSeriesEpisodes: StalkerVodSeriesEpisode[]; + selectedVodSeriesSeasonId: string | undefined; +} + +const initialSeriesState: StalkerSeriesState = { + vodSeriesSeasons: [], + vodSeriesEpisodes: [], + selectedVodSeriesSeasonId: undefined, +}; + +export function withStalkerSeries() { + const logger = createLogger('withStalkerSeries'); + const toMovieId = (value: unknown): string => { + const raw = String(value ?? '').trim(); + if (!raw) return ''; + return raw.includes(':') ? raw.split(':')[0] : raw; + }; + return signalStoreFeature( + withState(initialSeriesState), + withProps( + ( + store, + dataService = inject(DataService), + stalkerSession = inject(StalkerSessionService) + ) => ({ + serialSeasonsResource: resource({ + params: () => ({ + itemId: (store as any).selectedSerialId(), + }), + loader: async ({ params }) => { + const movieId = toMovieId(params.itemId); + // Guard: ensure currentPlaylist and itemId are available + if (!(store as any).currentPlaylist() || !movieId) { + return []; + } + const playlist = (store as any).currentPlaylist() as Playlist; + const queryParams = { + action: StalkerContentTypes.series.getContentAction, + type: 'series', + movie_id: movieId, + }; + + // 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) { + logger.warn('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 as any).selectedItem(), + selectedContentType: + (store as any).selectedContentType?.() ?? 'vod', + }), + loader: async ({ + params, + }): Promise => { + const item = params.selectedItem; + logger.debug('vodSeriesSeasonsResource loader called', { + item, + isSeries: item?.is_series, + currentPlaylist: (store as any).currentPlaylist(), + }); + + // Only fetch if item is a VOD series (has is_series flag) + if ( + !(store as any).currentPlaylist() || + params.selectedContentType !== 'vod' || + !item || + !item.is_series + ) { + logger.debug( + 'vodSeriesSeasonsResource skipped - conditions not met' + ); + return []; + } + + const playlist = (store as any).currentPlaylist() as Playlist; + const queryParams = { + action: StalkerPortalActions.GetOrderedList, + type: 'vod', + movie_id: item.id, + p: '1', + }; + + 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, + } + ); + } + + if (!response?.js?.data) { + logger.debug( + 'vodSeriesSeasonsResource - no response data' + ); + return []; + } + + logger.debug( + 'vodSeriesSeasonsResource response data', + response.js.data + ); + + // Filter for season items (is_season: true) + const seasons = response.js.data.filter( + (item: StalkerVodSeriesSeason) => + item.is_season === true + ); + logger.debug( + 'vodSeriesSeasonsResource filtered seasons', + seasons + ); + return sortVodSeriesSeasonsByNumber(seasons); + }, + }), + }) + ), + withComputed((store) => { + const storeAny = store as any; + return { + /** serials */ + getSerialSeasonsResource: computed(() => + storeAny.serialSeasonsResource.value() + ), + isSerialSeasonsLoading: computed(() => + storeAny.serialSeasonsResource.isLoading() + ), + /** VOD series (Ministra plugin is_series=1) */ + getVodSeriesSeasonsResource: computed(() => + storeAny.vodSeriesSeasonsResource.value() + ), + isVodSeriesSeasonsLoading: computed(() => + storeAny.vodSeriesSeasonsResource.isLoading() + ), + }; + }), + withMethods( + ( + store, + dataService = inject(DataService), + stalkerSession = inject(StalkerSessionService) + ) => { + const storeAny = store as any; + + return { + /** + * 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 = storeAny.currentPlaylist() as Playlist; + if (!playlist) return []; + + const queryParams = { + action: StalkerPortalActions.GetOrderedList, + type: 'vod', + movie_id: videoId, + season_id: seasonId, + p: '1', + }; + + 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, + } + ); + } + + if (!response?.js?.data) { + return []; + } + + // Filter for episode items (is_episode: true) and sort by series_number + const episodes = sortEpisodesByNumber( + response.js.data.filter( + (item: StalkerVodSeriesEpisode) => + item.is_episode === true + ) + ); + + // Store episodes and selected season + patchState(store, { + vodSeriesEpisodes: episodes, + selectedVodSeriesSeasonId: seasonId, + }); + + return episodes; + }, + }; + } + ) + ); +} diff --git a/apps/web/src/app/stalker/stores/index.ts b/apps/web/src/app/stalker/stores/index.ts new file mode 100644 index 000000000..87e9faf4f --- /dev/null +++ b/apps/web/src/app/stalker/stores/index.ts @@ -0,0 +1,3 @@ +export * from './features'; +export * from './utils'; + diff --git a/apps/web/src/app/stalker/stores/utils/index.ts b/apps/web/src/app/stalker/stores/utils/index.ts new file mode 100644 index 000000000..dde2e91fa --- /dev/null +++ b/apps/web/src/app/stalker/stores/utils/index.ts @@ -0,0 +1,3 @@ +export * from './stalker-content-mappers'; +export * from './stalker-sorting'; + diff --git a/apps/web/src/app/stalker/stores/utils/stalker-content-mappers.ts b/apps/web/src/app/stalker/stores/utils/stalker-content-mappers.ts new file mode 100644 index 000000000..937ce5920 --- /dev/null +++ b/apps/web/src/app/stalker/stores/utils/stalker-content-mappers.ts @@ -0,0 +1,77 @@ +import { + StalkerContentItem, + StalkerItvChannel, + StalkerVodSource, +} from '../../models'; + +/** + * Convert relative URLs to absolute URLs using the portal base URL + * Handles screenshot_uri and cmd paths that come as relative from the server + */ +export 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 + */ +export function processItemUrls( + item: T, + portalUrl: string +): T { + const processed = { ...item }; + + // Convert screenshot_uri to absolute URL + if (processed.screenshot_uri) { + processed.screenshot_uri = makeAbsoluteUrl( + portalUrl, + processed.screenshot_uri + ); + } + + return processed; +} + +export function toStalkerContentItem( + item: StalkerVodSource, + portalUrl: string +): StalkerContentItem { + const processed = processItemUrls(item, portalUrl); + return { + ...processed, + cover: processed.screenshot_uri, + }; +} + +export function toStalkerItvChannel(item: StalkerContentItem): StalkerItvChannel { + return { + ...item, + id: item.id ?? item.stream_id ?? '', + cmd: String(item.cmd ?? ''), + name: + typeof item.name === 'string' ? item.name : undefined, + o_name: + typeof item.o_name === 'string' ? item.o_name : undefined, + logo: + typeof item.logo === 'string' ? item.logo : undefined, + }; +} + diff --git a/apps/web/src/app/stalker/stores/utils/stalker-sorting.ts b/apps/web/src/app/stalker/stores/utils/stalker-sorting.ts new file mode 100644 index 000000000..f0078938b --- /dev/null +++ b/apps/web/src/app/stalker/stores/utils/stalker-sorting.ts @@ -0,0 +1,49 @@ +import { + StalkerSeason, + StalkerVodSeriesEpisode, + StalkerVodSeriesSeason, +} from '../../models'; + +export function extractNumericValue(str: string): number { + const matches = str.match(/\d+/); + if (matches) { + return parseInt(matches[0], 10); + } + return 0; +} + +export function sortByNumericValue(array: StalkerSeason[]): StalkerSeason[] { + if (!array) return []; + const key = 'name'; + return array.sort((a, b) => { + const numericA = extractNumericValue(a[key]); + const numericB = extractNumericValue(b[key]); + return numericA - numericB; + }); +} + +export function sortVodSeriesSeasonsByNumber( + array: StalkerVodSeriesSeason[] +): StalkerVodSeriesSeason[] { + if (!array) return []; + return [...array].sort((a, b) => { + const numA = Number(a.season_number ?? 0) || 0; + const numB = Number(b.season_number ?? 0) || 0; + return numA - numB; + }); +} + +/** + * Sort episodes by series_number in ascending numeric order (1, 2, 3... not "1", "10", "2") + */ +export function sortEpisodesByNumber( + episodes: StalkerVodSeriesEpisode[] +): StalkerVodSeriesEpisode[] { + if (!episodes) return []; + return episodes.sort((a, b) => { + const numA = Number(a.series_number ?? 0) || 0; + const numB = Number(b.series_number ?? 0) || 0; + return numA - numB; + }); +} + From a9eee9e64ffbf99694630429743329191b48635c Mon Sep 17 00:00:00 2001 From: 4gray Date: Sat, 14 Feb 2026 15:44:16 +0100 Subject: [PATCH 09/12] refactor(stalker): adapt store calls, enhance recently viewed, favorites, download manger layout --- .../favorites-layout.component.html | 17 ++++- .../favorites-layout.component.scss | 4 ++ .../favorites-layout.component.ts | 12 ++++ .../recently-viewed.component.html | 7 +++ .../recently-viewed.component.ts | 51 +++++++++++++-- .../stalker-favorites.component.ts | 22 +++++-- .../stalker-series-view.component.ts | 22 +++++-- .../category-content-view.component.html | 1 + .../category-content-view.component.ts | 62 +++++++++++++++++++ libs/services/src/lib/playlists.service.ts | 34 +++++++++- 10 files changed, 213 insertions(+), 19 deletions(-) diff --git a/apps/web/src/app/shared/components/favorites-layout/favorites-layout.component.html b/apps/web/src/app/shared/components/favorites-layout/favorites-layout.component.html index bd1762abe..2a5e384bb 100644 --- a/apps/web/src/app/shared/components/favorites-layout/favorites-layout.component.html +++ b/apps/web/src/app/shared/components/favorites-layout/favorites-layout.component.html @@ -33,6 +33,16 @@ ')' }} + @if (showHeaderAction()) { + + }
@if (favorites().length) { @@ -44,7 +54,10 @@ [type]="item.type" [date]="item.added_at" [showRemoveButton]="true" - [removeTooltip]="'CHANNELS.REMOVE_FAVORITE' | translate" + [removeTooltip]=" + removeTooltip() || + ('CHANNELS.REMOVE_FAVORITE' | translate) + " (cardClick)="openFavorite(item)" (remove)="removeFromFavorites(item)" /> @@ -52,7 +65,7 @@
} @else {
- favorite_border + {{ emptyIcon() }}

No items in the selected category

} diff --git a/apps/web/src/app/shared/components/favorites-layout/favorites-layout.component.scss b/apps/web/src/app/shared/components/favorites-layout/favorites-layout.component.scss index c9fc7c64a..6fccda148 100644 --- a/apps/web/src/app/shared/components/favorites-layout/favorites-layout.component.scss +++ b/apps/web/src/app/shared/components/favorites-layout/favorites-layout.component.scss @@ -20,6 +20,10 @@ text-transform: capitalize; padding: 16px; border-bottom: 1px solid rgba(0, 0, 0, 0.12); + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; h2 { margin: 0; diff --git a/apps/web/src/app/shared/components/favorites-layout/favorites-layout.component.ts b/apps/web/src/app/shared/components/favorites-layout/favorites-layout.component.ts index 7e164f09f..c3446f34b 100644 --- a/apps/web/src/app/shared/components/favorites-layout/favorites-layout.component.ts +++ b/apps/web/src/app/shared/components/favorites-layout/favorites-layout.component.ts @@ -1,6 +1,7 @@ import { Component, input, output } from '@angular/core'; import { MatIconButton } from '@angular/material/button'; import { MatIcon } from '@angular/material/icon'; +import { MatTooltip } from '@angular/material/tooltip'; import { TranslatePipe } from '@ngx-translate/core'; import { PlaylistSwitcherComponent, ResizableDirective } from 'components'; import { CategoryViewComponent } from '../../../xtream-tauri/category-view/category-view.component'; @@ -21,6 +22,7 @@ import { ContentCardComponent } from '../content-card/content-card.component'; PlaylistSwitcherComponent, ResizableDirective, TranslatePipe, + MatTooltip, ], }) export class FavoritesLayoutComponent { @@ -30,10 +32,16 @@ export class FavoritesLayoutComponent { readonly playlistTitle = input('Playlist'); readonly selectedCategoryId = input('movie'); readonly titleTranslationString = input('CHANNELS.FAVORITES'); + readonly removeTooltip = input(''); + readonly emptyIcon = input('favorite_border'); + readonly showHeaderAction = input(false); + readonly headerActionIcon = input('delete_sweep'); + readonly headerActionTooltip = input(''); readonly categoryClicked = output(); readonly removeFavorite = output(); readonly openItem = output(); + readonly headerActionClicked = output(); setCategoryId(categoryId: any) { this.categoryClicked.emit({ category_id: categoryId }); @@ -46,4 +54,8 @@ export class FavoritesLayoutComponent { openFavorite(item: any) { this.openItem.emit(item); } + + onHeaderActionClick() { + this.headerActionClicked.emit(); + } } diff --git a/apps/web/src/app/stalker/recently-viewed/recently-viewed.component.html b/apps/web/src/app/stalker/recently-viewed/recently-viewed.component.html index 59f146302..8e7012bfd 100644 --- a/apps/web/src/app/stalker/recently-viewed/recently-viewed.component.html +++ b/apps/web/src/app/stalker/recently-viewed/recently-viewed.component.html @@ -16,6 +16,7 @@ } diff --git a/apps/web/src/app/stalker/recently-viewed/recently-viewed.component.ts b/apps/web/src/app/stalker/recently-viewed/recently-viewed.component.ts index 51965e356..1221a1a6e 100644 --- a/apps/web/src/app/stalker/recently-viewed/recently-viewed.component.ts +++ b/apps/web/src/app/stalker/recently-viewed/recently-viewed.component.ts @@ -1,4 +1,4 @@ -import { Component, computed, inject, signal } from '@angular/core'; +import { Component, computed, effect, inject, signal } from '@angular/core'; import { TranslateService } from '@ngx-translate/core'; import { PlaylistsService } from 'services'; import { @@ -15,12 +15,13 @@ import { } from '../models'; import { createPortalCollectionResource, - normalizeStalkerEntityIdAsNumber, + createPortalFavoritesResource, toggleStalkerVodFavorite, clearStalkerDetailViewState, createStalkerDetailViewState, createRefreshTrigger, normalizeStalkerVodDetailsItem, + isSelectedStalkerVodFavorite, } from '../stalker-vod.utils'; @Component({ @@ -54,12 +55,14 @@ export class RecentlyViewedComponent { } private readonly playlistService = inject(PlaylistsService); private readonly collectionRefresh = createRefreshTrigger(); + private readonly favoritesRefresh = createRefreshTrigger(); private readonly stalkerStore = inject(StalkerStore); private readonly translate = inject(TranslateService); private readonly logger = createLogger('StalkerRecentlyViewed'); itemDetails: (StalkerVodSource & { category_id?: string }) | null = null; vodDetailsItem: VodDetailsItem | null = null; + readonly isSelectedVodFavorite = signal(false); readonly currentPlaylist = this.stalkerStore.currentPlaylist; @@ -70,6 +73,11 @@ export class RecentlyViewedComponent { (playlistService, portalId) => playlistService.getPortalRecentlyViewed(portalId) ); + readonly portalFavorites = createPortalFavoritesResource( + this.playlistService, + () => this.stalkerStore.currentPlaylist()?._id, + () => this.favoritesRefresh.refreshVersion() + ); readonly categories = computed(() => [ { @@ -131,14 +139,34 @@ export class RecentlyViewedComponent { this.allFavorites.value()?.filter((item) => item.category_id === 'itv') ); + constructor() { + effect(() => { + this.portalFavorites.value(); + this.syncSelectedVodFavorite(); + }); + } + removeFromRecentlyViewed(item: Pick) { - const id = normalizeStalkerEntityIdAsNumber(item.id); - if (id === null) return; + const id = String(item.id ?? '').trim(); + if (!id) return; this.stalkerStore.removeFromRecentlyViewed(id, () => { this.collectionRefresh.refresh(); }); } + clearAllRecentlyViewed() { + const playlistId = this.stalkerStore.currentPlaylist()?._id; + if (!playlistId) return; + this.playlistService.clearPortalRecentlyViewed(playlistId).subscribe({ + next: () => this.collectionRefresh.refresh(), + error: (error) => + this.logger.error( + 'Failed to clear recently viewed items', + error + ), + }); + } + setCategoryId(categoryId: string) { this.selectedCategoryId.set(categoryId); } @@ -166,6 +194,7 @@ export class RecentlyViewedComponent { }; this.vodDetailsItem = detailViewState.vodDetailsItem; this.stalkerStore.setSelectedItem(detailViewState.itemDetails); + this.syncSelectedVodFavorite(); break; case 'series': this.itemDetails = item; @@ -193,6 +222,10 @@ export class RecentlyViewedComponent { addToFavorites: (item, onDone) => this.addToFavorites(item, onDone), removeFromFavorites: (favoriteId, onDone) => this.removeFromFavorites(favoriteId, onDone), + onComplete: () => { + this.favoritesRefresh.refresh(); + this.syncSelectedVodFavorite(); + }, }); } @@ -201,6 +234,7 @@ export class RecentlyViewedComponent { const cleared = clearStalkerDetailViewState(); this.itemDetails = cleared.itemDetails; this.vodDetailsItem = cleared.vodDetailsItem; + this.isSelectedVodFavorite.set(false); } async createLinkToPlayVodItv( @@ -231,4 +265,13 @@ export class RecentlyViewedComponent { private normalizeRecentItem(item: StalkerVodSource): StalkerSelectedVodItem { return normalizeStalkerVodDetailsItem(item); } + + private syncSelectedVodFavorite(): void { + this.isSelectedVodFavorite.set( + isSelectedStalkerVodFavorite( + this.vodDetailsItem, + this.portalFavorites.value() ?? [] + ) + ); + } } diff --git a/apps/web/src/app/stalker/stalker-favorites/stalker-favorites.component.ts b/apps/web/src/app/stalker/stalker-favorites/stalker-favorites.component.ts index 03f283742..75012a701 100644 --- a/apps/web/src/app/stalker/stalker-favorites/stalker-favorites.component.ts +++ b/apps/web/src/app/stalker/stalker-favorites/stalker-favorites.component.ts @@ -21,6 +21,7 @@ import { NormalizedStalkerFavoriteItem, normalizeStalkerFavoriteItem, isSelectedStalkerVodFavorite, + toggleStalkerVodFavorite, } from '../stalker-vod.utils'; @Component({ @@ -143,6 +144,14 @@ export class StalkerFavoritesComponent { ); } + addToFavorites(item: Record, onDone?: () => void) { + this.stalkerStore.addToFavorites(item, () => { + this.favoritesRefresh.refresh(); + this.syncSelectedVodFavorite(); + onDone?.(); + }); + } + setCategoryId(categoryId: string) { this.selectedCategoryId.set(categoryId); } @@ -195,10 +204,15 @@ export class StalkerFavoritesComponent { /** Handle favorite toggle from vod-details component */ onVodFavoriteToggled(event: { item: VodDetailsItem; isFavorite: boolean }): void { - if (!event.isFavorite && event.item.type === 'stalker') { - this.removeFromFavorites({ id: event.item.data.id }); - this.isSelectedVodFavorite.set(false); - } + toggleStalkerVodFavorite(event, { + addToFavorites: (item, onDone) => this.addToFavorites(item, onDone), + removeFromFavorites: (favoriteId, onDone) => + this.removeFromFavorites({ id: favoriteId }, onDone), + onComplete: () => { + this.favoritesRefresh.refresh(); + this.syncSelectedVodFavorite(); + }, + }); } /** Handle back from vod-details component */ 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 faf23478a..dda9fccff 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 @@ -71,7 +71,10 @@ export class StalkerSeriesViewComponent { * Note: is_series can be true, 1, or "1" depending on the source */ readonly isVodSeries = computed(() => { - return isVodSeriesItem(this.displayItem()); + return ( + this.stalkerStore.selectedContentType() === 'vod' && + isVodSeriesItem(this.displayItem()) + ); }); /** @@ -103,17 +106,21 @@ export class StalkerSeriesViewComponent { const playlist = this.stalkerStore.currentPlaylist(); if (item && playlist?._id) { const seriesId = Number(item.id); + const normalizedSeriesId = this.toSeriesId(item.id); this.logger.debug( 'Loading positions for series', { id: item.id, - seriesId, + seriesId: normalizedSeriesId, isSeries: item.is_series, } ); - if (!isNaN(seriesId)) { + if (!isNaN(normalizedSeriesId)) { // Load playback positions from the XtreamStore (works for any playlist type) - this.xtreamStore.loadSeriesPositions(playlist._id, seriesId); + this.xtreamStore.loadSeriesPositions( + playlist._id, + normalizedSeriesId + ); } } }); @@ -331,8 +338,11 @@ export class StalkerSeriesViewComponent { this.stalkerStore.clearSelectedItem(); } - toSeriesId(id: string): number { - const parsed = Number(id); + toSeriesId(id: string | number): number { + const raw = String(id ?? '').trim(); + if (!raw) return 0; + const primary = raw.includes(':') ? raw.split(':')[0] : raw; + const parsed = Number(primary); return Number.isFinite(parsed) ? parsed : 0; } } 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 0aa387daa..0661f10f9 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 @@ -16,6 +16,7 @@ (playClicked)="onVodPlay($event)" (resumeClicked)="onVodResume($event)" (favoriteToggled)="onVodFavoriteToggled($event)" + (downloadRequested)="onVodDownload($event)" (backClicked)="onVodBack()" /> } 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 1545ac21f..e6a5c5ef1 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 @@ -25,6 +25,11 @@ import { toggleStalkerVodFavorite, } from '../../stalker/stalker-vod.utils'; import { createLogger } from '../../shared/utils/logger'; +import { DownloadsService } from '../../services/downloads.service'; +import { + normalizeStalkerEntityId, + normalizeStalkerEntityIdAsNumber, +} from '../../stalker/stalker-vod.utils'; @Component({ selector: 'app-category-content-view', @@ -54,6 +59,7 @@ export class CategoryContentViewComponent implements OnInit, OnDestroy { readonly contentType = this.store.selectedContentType; private readonly playlistService = inject(PlaylistsService); + private readonly downloadsService = inject(DownloadsService); private readonly favoritesRefresh = createRefreshTrigger(); readonly limit = this.store.limit; @@ -411,6 +417,62 @@ export class CategoryContentViewComponent implements OnInit, OnDestroy { this.store.setSelectedItem(null); } + /** Handle download from vod-details component */ + async onVodDownload(item: VodDetailsItem): Promise { + if (item.type !== 'stalker') return; + + const stalkerStore = this.store as InstanceType; + const playlist = stalkerStore.currentPlaylist(); + if (!playlist || !playlist.portalUrl || !playlist.macAddress) return; + + let cmdToUse = item.cmd; + const itemData = item.data as any; + const normalizedItemId = normalizeStalkerEntityId(itemData?.id); + + // Align with playback path: resolve has_files references to /media/file_{id}.mpg + if ( + itemData?.has_files !== undefined && + cmdToUse && + !cmdToUse.includes('://') && + cmdToUse.includes('/media/') && + !cmdToUse.includes('/media/file_') + ) { + const fileId = await stalkerStore.fetchMovieFileId(normalizedItemId); + if (fileId) { + cmdToUse = `/media/file_${fileId}.mpg`; + } + } + + const url = await stalkerStore.fetchLinkToPlay( + playlist.portalUrl, + playlist.macAddress, + cmdToUse + ); + if (!url) return; + + const numericId = + normalizeStalkerEntityIdAsNumber(itemData?.id) ?? 0; + + await this.downloadsService.startDownload({ + playlistId: playlist._id, + xtreamId: numericId, + contentType: 'vod', + title: itemData?.info?.name || itemData?.title || 'Unknown', + url, + posterUrl: itemData?.info?.movie_image, + headers: { + userAgent: playlist.userAgent, + referer: playlist.referrer, + origin: playlist.origin, + }, + // Playlist info for auto-creation (Stalker playlists) + playlistName: playlist.title || 'Stalker Portal', + playlistType: 'stalker', + portalUrl: playlist.portalUrl, + macAddress: playlist.macAddress, + }); + } + ngOnDestroy(): void { this.unsubscribePositionUpdates?.(); } diff --git a/libs/services/src/lib/playlists.service.ts b/libs/services/src/lib/playlists.service.ts index 4ed395832..6343f4579 100644 --- a/libs/services/src/lib/playlists.service.ts +++ b/libs/services/src/lib/playlists.service.ts @@ -411,13 +411,41 @@ export class PlaylistsService { } removeFromPortalRecentlyViewed(portalId: string, id: string | number) { + const normalizePortalItemId = (value: unknown): string => { + const raw = String(value ?? '').trim(); + if (!raw) return ''; + return raw.includes(':') ? raw.split(':')[0] : raw; + }; + + return this.getPlaylistById(portalId).pipe( + switchMap((portal) => { + const expectedId = String(id); + const expectedNormalized = normalizePortalItemId(id); + + return this.dbService.update(DbStores.Playlists, { + ...portal, + recentlyViewed: portal.recentlyViewed?.filter((i: any) => { + const itemId = String(i?.id ?? ''); + const itemNormalized = normalizePortalItemId(itemId); + return ( + itemId !== expectedId && + itemNormalized !== expectedNormalized + ); + }), + }); + }) + ); + } + + clearPortalRecentlyViewed(portalId: string) { + if (!portalId) { + throw new Error('Portal ID is required'); + } return this.getPlaylistById(portalId).pipe( switchMap((portal) => this.dbService.update(DbStores.Playlists, { ...portal, - recentlyViewed: portal.recentlyViewed?.filter( - (i) => i.id !== id - ), + recentlyViewed: [], }) ) ); From 67b32e82fe7323d86b7eeddf8b5500a90fd179f8 Mon Sep 17 00:00:00 2001 From: 4gray Date: Sat, 14 Feb 2026 15:44:44 +0100 Subject: [PATCH 10/12] test(stalker): add compatibility tests for StalkerStore API --- .../app/stalker/stalker.store.compat.spec.ts | 161 ++++++++++++++++++ apps/web/tsconfig.spec.json | 2 +- 2 files changed, 162 insertions(+), 1 deletion(-) create mode 100644 apps/web/src/app/stalker/stalker.store.compat.spec.ts diff --git a/apps/web/src/app/stalker/stalker.store.compat.spec.ts b/apps/web/src/app/stalker/stalker.store.compat.spec.ts new file mode 100644 index 000000000..7f1ad1d2f --- /dev/null +++ b/apps/web/src/app/stalker/stalker.store.compat.spec.ts @@ -0,0 +1,161 @@ +import { TestBed } from '@angular/core/testing'; +import { MatSnackBar } from '@angular/material/snack-bar'; +import { TranslateService } from '@ngx-translate/core'; +import { of } from 'rxjs'; +import { + DataService, + PlaylistsService, + StalkerSessionService, +} from 'services'; +import { PlayerService } from '../services/player.service'; +import { StalkerStore } from './stalker.store'; + +describe('StalkerStore API compatibility smoke', () => { + let store: any; + + beforeEach(() => { + TestBed.configureTestingModule({ + providers: [ + StalkerStore, + { + provide: DataService, + useValue: { + isElectron: false, + sendIpcEvent: jest.fn(), + }, + }, + { + provide: StalkerSessionService, + useValue: { + ensureToken: jest.fn(), + makeAuthenticatedRequest: jest.fn(), + }, + }, + { + provide: PlayerService, + useValue: { + openPlayer: jest.fn(), + }, + }, + { + provide: PlaylistsService, + useValue: { + addPortalFavorite: jest.fn(() => of(undefined)), + removeFromPortalFavorites: jest.fn(() => of(undefined)), + addPortalRecentlyViewed: jest.fn(() => of(undefined)), + removeFromPortalRecentlyViewed: jest.fn(() => + of(undefined) + ), + }, + }, + { + provide: MatSnackBar, + useValue: { + open: jest.fn(), + }, + }, + { + provide: TranslateService, + useValue: { + instant: jest.fn((key: string) => key), + }, + }, + ], + }); + + store = TestBed.inject(StalkerStore); + }); + + it('exposes compatibility state signals', () => { + const expectedSignals = [ + 'selectedContentType', + 'selectedCategoryId', + 'selectedVodId', + 'selectedSerialId', + 'selectedItvId', + 'limit', + 'page', + 'searchPhrase', + 'currentPlaylist', + 'totalCount', + 'selectedItem', + 'vodCategories', + 'seriesCategories', + 'itvCategories', + 'hasMoreChannels', + 'itvChannels', + 'vodSeriesSeasons', + 'vodSeriesEpisodes', + 'selectedVodSeriesSeasonId', + ]; + + for (const signalName of expectedSignals) { + expect(typeof store[signalName]).toBe('function'); + } + }); + + it('exposes compatibility computed selectors', () => { + const expectedComputed = [ + 'getTotalPages', + 'getPaginatedContent', + 'isPaginatedContentLoading', + 'isPaginatedContentFailed', + 'getSerialSeasonsResource', + 'isSerialSeasonsLoading', + 'getVodSeriesSeasonsResource', + 'isVodSeriesSeasonsLoading', + 'getCategoryResource', + 'isCategoryResourceLoading', + 'isCategoryResourceFailed', + 'getSelectedCategoryName', + ]; + + for (const computedName of expectedComputed) { + expect(typeof store[computedName]).toBe('function'); + } + }); + + it('exposes compatibility methods and internal resources', () => { + const expectedMethods = [ + 'setSelectedContentType', + 'setSelectedCategory', + 'setSelectedSerialId', + 'setSelectedVodId', + 'setSelectedItvId', + 'setLimit', + 'setPage', + 'setCurrentPlaylist', + 'setSelectedItem', + 'clearSelectedItem', + 'setCategories', + 'resetCategories', + 'setItvChannels', + 'setSearchPhrase', + 'fetchVodSeriesEpisodes', + 'getSelectedCategory', + 'fetchLinkToPlay', + 'getExpireDate', + 'addToFavorites', + 'removeFromFavorites', + 'fetchMovieFileId', + 'createLinkToPlayVod', + 'addToRecentlyViewed', + 'removeFromRecentlyViewed', + 'fetchChannelEpg', + 'makeStalkerRequest', + ]; + + for (const methodName of expectedMethods) { + expect(typeof store[methodName]).toBe('function'); + } + + expect(store.getContentResource).toBeDefined(); + expect(typeof store.getContentResource.value).toBe('function'); + + expect(store.serialSeasonsResource).toBeDefined(); + expect(typeof store.serialSeasonsResource.value).toBe('function'); + + expect(store.vodSeriesSeasonsResource).toBeDefined(); + expect(typeof store.vodSeriesSeasonsResource.value).toBe('function'); + }); +}); diff --git a/apps/web/tsconfig.spec.json b/apps/web/tsconfig.spec.json index a16a365c0..d40306e4b 100644 --- a/apps/web/tsconfig.spec.json +++ b/apps/web/tsconfig.spec.json @@ -7,7 +7,7 @@ "types": ["jest", "node"], "moduleResolution": "node10" }, - "files": ["src/test-setup.ts"], + "files": ["src/test-setup.ts", "../../global.d.ts"], "include": [ "jest.config.ts", "src/**/*.test.ts", From 935ef3561853159ce0a7be302b43d06f112ca613 Mon Sep 17 00:00:00 2001 From: 4gray Date: Sat, 14 Feb 2026 15:48:52 +0100 Subject: [PATCH 11/12] docs: add architecture documentation for M3U playlist, remote control, EPG, and Stalker portal --- docs/architecture/m3u-playlist-module.md | 321 ++++++++++++++++++ docs/architecture/remote-control.md | 245 +++++++++++++ docs/architecture/stalker-epg.md | 212 ++++++++++++ docs/architecture/stalker-portal.md | 156 +++++++++ .../stalker-store-api-baseline.md | 126 +++++++ 5 files changed, 1060 insertions(+) create mode 100644 docs/architecture/m3u-playlist-module.md create mode 100644 docs/architecture/remote-control.md create mode 100644 docs/architecture/stalker-epg.md create mode 100644 docs/architecture/stalker-portal.md create mode 100644 docs/architecture/stalker-store-api-baseline.md diff --git a/docs/architecture/m3u-playlist-module.md b/docs/architecture/m3u-playlist-module.md new file mode 100644 index 000000000..d677f704d --- /dev/null +++ b/docs/architecture/m3u-playlist-module.md @@ -0,0 +1,321 @@ +# M3U Playlist Module Architecture + +This document describes the M3U playlist module architecture, which handles traditional M3U/M3U8 playlists (as opposed to Xtream Codes or Stalker Portal). + +## Overview + +The M3U playlist module provides: +- Channel list display with virtual scrolling (90,000+ channels support) +- EPG (Electronic Program Guide) integration +- Favorites management with drag-and-drop reordering +- Channel grouping and search +- Video playback with multiple player backends + +## Module Structure + +``` +┌─────────────────────────────────────────────────────────────────────┐ +│ VIDEO PLAYER PAGE │ +│ apps/web/src/app/home/video-player/ │ +├─────────────────────────────────────────────────────────────────────┤ +│ ┌─────────────┐ ┌──────────────────────┐ ┌────────────────────┐ │ +│ │ Sidebar │ │ Video Player │ │ EPG List │ │ +│ │ │ │ (ArtPlayer/Video.js)│ │ (Right drawer) │ │ +│ │ ┌─────────┐ │ │ │ │ │ │ +│ │ │Channel │ │ │ │ │ │ │ +│ │ │List │ │ │ │ │ │ │ +│ │ │Container│ │ │ │ │ │ │ +│ │ └─────────┘ │ │ │ │ │ │ +│ └─────────────┘ └──────────────────────┘ └────────────────────┘ │ +└─────────────────────────────────────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────────────────┐ +│ NgRx STORE (m3u-state) │ +│ libs/m3u-state/ │ +│ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ │ +│ │ Playlist │ │ Channel │ │ EPG │ │Favorites │ │ Filter │ │ +│ │ Reducer │ │ Reducer │ │ Reducer │ │ Reducer │ │ Reducer │ │ +│ └──────────┘ └──────────┘ └──────────┘ └──────────┘ └──────────┘ │ +└─────────────────────────────────────────────────────────────────────┘ +``` + +## State Management (libs/m3u-state/) + +### State Structure + +```typescript +interface PlaylistState { + // Active channel being played + active: Channel | undefined; + + // All channels from current playlist + channels: Channel[]; + + // EPG state + epg: { + epgAvailable: boolean; + activeEpgProgram: EpgProgram | undefined; + currentEpgProgram: EpgProgram | undefined; + }; + + // Playlist metadata (entity adapter) + playlistsMeta: { + ids: string[]; + entities: Record; + selectedId: string | undefined; + allPlaylistsLoaded: boolean; + selectedFilters: PlaylistSourceFilter[]; + }; +} +``` + +### Actions + +| Action Group | Actions | Purpose | +|--------------|---------|---------| +| **PlaylistActions** | `loadPlaylists`, `addPlaylist`, `removePlaylist`, `parsePlaylist`, `setActivePlaylist` | Playlist CRUD | +| **ChannelActions** | `setChannels`, `setActiveChannel`, `setAdjacentChannelAsActive` | Channel selection & navigation | +| **EpgActions** | `setActiveEpgProgram`, `setCurrentEpgProgram`, `setEpgAvailableFlag` | EPG state | +| **FavoritesActions** | `updateFavorites`, `setFavorites` | Favorites management | +| **FilterActions** | `setSelectedFilters` | Playlist type filtering | + +### Key Selectors + +```typescript +// Channel selectors +selectActive // Current playing channel +selectChannels // All channels array +selectFavorites // Favorite channel URLs + +// Playlist selectors +selectAllPlaylistsMeta // All playlists +selectActivePlaylistId // Selected playlist ID +selectCurrentPlaylist // Active playlist object +selectPlaylistTitle // Title with "Global favorites" fallback + +// EPG selectors +selectIsEpgAvailable // EPG data available flag +selectCurrentEpgProgram // Current playing program +``` + +## Channel List Container + +**Location**: `libs/ui/components/src/lib/channel-list-container/` + +### Component Architecture + +``` +channel-list-container/ +├── channel-list-container.component.ts # Parent - shared state coordinator +├── channel-list-container.component.html +├── channel-list-container.component.scss +│ +├── all-channels-tab/ # Virtual scroll + search +│ ├── all-channels-tab.component.ts +│ ├── all-channels-tab.component.html +│ └── all-channels-tab.component.scss +│ +├── groups-tab/ # Expansion panels + infinite scroll +│ ├── groups-tab.component.ts +│ ├── groups-tab.component.html +│ └── groups-tab.component.scss +│ +├── favorites-tab/ # Drag-drop reordering +│ ├── favorites-tab.component.ts +│ ├── favorites-tab.component.html +│ └── favorites-tab.component.scss +│ +└── channel-list-item/ # Individual channel display + ├── channel-list-item.component.ts + ├── channel-list-item.component.html + └── channel-list-item.component.scss +``` + +### Data Flow + +``` +┌──────────────────────────────────────────────────────────────┐ +│ ChannelListContainerComponent │ +│ (Parent) │ +│ ┌────────────────────────────────────────────────────────┐ │ +│ │ Shared State (Signals): │ │ +│ │ - channelEpgMap: Map │ │ +│ │ - progressTick: number (30s interval) │ │ +│ │ - shouldShowEpg: boolean │ │ +│ │ - favoriteIds: Set │ │ +│ └────────────────────────────────────────────────────────┘ │ +│ │ │ +│ ┌─────────────────────┼─────────────────────┐ │ +│ ▼ ▼ ▼ │ +│ ┌─────────┐ ┌──────────┐ ┌───────────┐ │ +│ │ All │ │ Groups │ │ Favorites │ │ +│ │Channels │ │ Tab │ │ Tab │ │ +│ │ Tab │ │ │ │ │ │ +│ └────┬────┘ └────┬─────┘ └─────┬─────┘ │ +│ │ │ │ │ +│ └───────────────────┴─────────────────────┘ │ +│ │ │ +│ ▼ │ +│ (channelSelected) output │ +│ │ │ +└──────────────────────────┼───────────────────────────────────┘ + ▼ + Store Dispatch + ChannelActions.setActiveChannel +``` + +### EnrichedChannel Pattern + +For performance optimization, channels are pre-enriched with EPG data: + +```typescript +interface EnrichedChannel extends Channel { + epgProgram: EpgProgram | null | undefined; + progressPercentage: number; // Pre-computed by parent +} +``` + +### Performance Optimizations + +| Optimization | Implementation | +|--------------|----------------| +| **Virtual Scroll** | CDK virtual scroll for 90,000+ channels | +| **Computed Signals** | `enrichedChannels` computed signal replaces template pipe | +| **Debounced Search** | 300ms debounce on search input | +| **Global Progress Tick** | Single 30s interval instead of per-item intervals | +| **OnPush Change Detection** | All components use OnPush | +| **Infinite Scroll in Groups** | IntersectionObserver loads 50 channels at a time | +| **Memoized Group Enrichment** | `enrichedGroupChannelsMap` computed signal | + +### Tab Components + +#### AllChannelsTabComponent +- **Inputs**: `channels`, `channelEpgMap`, `progressTick`, `shouldShowEpg`, `itemSize`, `activeChannelUrl`, `favoriteIds` +- **Outputs**: `channelSelected`, `favoriteToggled` +- **Features**: Search with 300ms debounce, virtual scrolling, no-results placeholder + +#### GroupsTabComponent +- **Inputs**: Same as AllChannelsTab + `groupedChannels` +- **Outputs**: `channelSelected`, `favoriteToggled` +- **Features**: Expansion panels, infinite scroll with IntersectionObserver, lazy loading + +#### FavoritesTabComponent +- **Inputs**: `favorites`, `channelEpgMap`, `progressTick`, `shouldShowEpg`, `activeChannelUrl` +- **Outputs**: `channelSelected`, `favoriteToggled`, `favoritesReordered` +- **Features**: Drag-and-drop reordering with CDK DragDrop + +## EPG Integration + +### EpgService (libs/services/) + +```typescript +class EpgService { + // Fetch EPG for multiple URLs + fetchEpg(urls: string[]): void; + + // Get programs for a channel + getChannelPrograms(channelId: string): void; + + // Batch fetch current programs + getCurrentProgramsForChannels(channelIds: string[]): Observable>; + + // Observables + epgAvailable$: Observable; + currentEpgPrograms$: Observable; +} +``` + +### EPG Components + +| Component | Purpose | +|-----------|---------| +| `EpgListComponent` | Timeline view for single channel | +| `EpgListItemComponent` | Individual program in timeline | +| `EpgItemDescriptionComponent` | Program details dialog | +| `MultiEpgContainerComponent` | Grid view of all channels' schedules | + +## Video Player + +**Location**: `apps/web/src/app/home/video-player/` + +### Supported Players +- **ArtPlayer** (default) - Modern player with plugins +- **Video.js** - Fallback with HLS support +- **HTML5** - Basic video element +- **Audio** - For radio streams + +### Player Features +- Channel navigation (prev/next) +- Favorites toggle +- EPG sidebar +- Multi-EPG modal view +- Channel info overlay +- External player support (MPV, VLC) in Electron + +## Interfaces + +### Channel Interface +```typescript +interface Channel { + id: string; + url: string; + name: string; + group: { title: string }; + tvg: { + id: string; // For EPG matching + name: string; + url: string; + logo: string; + rec: string; + }; + epgParams?: string; + timeshift?: string; + catchup?: { type?: string; source?: string; days?: string }; + http: { + referrer: string; + 'user-agent': string; + origin: string; + }; +} +``` + +### EpgProgram Interface +```typescript +interface EpgProgram { + start: string; // ISO string + stop: string; // ISO string + channel: string; // TVG ID + title: string; + desc: string | null; + category: string | null; + episodeNum?: string | null; + iconUrl?: string | null; + rating?: string | null; +} +``` + +## Routes + +``` +/playlists/:id # Video player with playlist +/iptv # Default IPTV route +``` + +## Adding New Features + +### To add a new tab to channel list: +1. Create component in `channel-list-container/new-tab/` +2. Accept inputs: `channels`, `channelEpgMap`, `progressTick`, `shouldShowEpg`, `activeChannelUrl` +3. Emit `channelSelected` output +4. Add to parent template and imports + +### To add EPG-related features: +1. Use `EpgService` for data fetching +2. Subscribe to `channelEpgMap` signal for current programs +3. Dispatch `EpgActions` for state updates + +### To modify favorites behavior: +1. Dispatch `FavoritesActions.updateFavorites` for toggle +2. Dispatch `FavoritesActions.setFavorites` for reordering +3. Effects automatically persist to database diff --git a/docs/architecture/remote-control.md b/docs/architecture/remote-control.md new file mode 100644 index 000000000..49b6ac0d5 --- /dev/null +++ b/docs/architecture/remote-control.md @@ -0,0 +1,245 @@ +# Remote Control Architecture + +This document describes the current remote control implementation in IPTVnator, including: + +- HTTP API exposed by Electron main process +- IPC bridge between Electron main and Angular renderer +- Feature support and integration points for M3U, Xtream, and Stalker +- Remote web UI structure and behavior + +Related architecture docs: + +- [Stalker Portal Architecture](./stalker-portal.md) +- [Stalker Portal EPG Architecture](./stalker-epg.md) + +## Scope + +Remote control is a desktop-only feature that serves a mobile-friendly web app from the Electron backend and routes remote actions into the running renderer. + +Current capabilities: + +- Channel up / down +- Channel select by number +- Volume commands (implemented in command layer; active support currently in M3U flow) +- Playback status polling (portal, live-state, channel name/number, EPG now, volume capability) + +## High-Level Flow + +1. User opens remote web UI (`http://:`). +2. Remote web app calls `/api/remote-control/*`. +3. Electron main handles API request and sends IPC to renderer: + - `CHANNEL_CHANGE` for up/down + - `REMOTE_CONTROL_COMMAND` for numeric/volume commands +4. Renderer-specific feature module (M3U/Xtream/Stalker) applies action. +5. Renderer pushes status snapshots back to main via: + - `REMOTE_CONTROL_STATUS_UPDATE` +6. Remote web app polls `/api/remote-control/status` and updates UI. + +## Backend (Electron Main) + +### HTTP server and static app hosting + +- File: `apps/electron-backend/src/app/server/http-server.ts` +- Responsibilities: + - Serves static remote app from: + - dev: `dist/apps/remote-control-web/browser` + - prod: `/remote-control-web/browser` + - Routes `/api/remote-control/*` to registered handlers. + - Starts/stops/restarts on settings updates. + +### Remote control event module + +- File: `apps/electron-backend/src/app/events/remote-control.events.ts` +- Bootstrapped in: `apps/electron-backend/src/main.ts` via `RemoteControlEvents.bootstrapRemoteControlEvents()` + +Registered endpoints: + +- `POST /api/remote-control/channel/up` +- `POST /api/remote-control/channel/down` +- `POST /api/remote-control/channel/select-number` with `{ number: }` +- `POST /api/remote-control/volume/up` +- `POST /api/remote-control/volume/down` +- `POST /api/remote-control/volume/toggle-mute` +- `GET /api/remote-control/status` + +IPC emitted to renderer: + +- `CHANNEL_CHANGE` payload: `{ direction: 'up' | 'down' }` +- `REMOTE_CONTROL_COMMAND` payload: + - `{ type: 'channel-select-number', number }` + - `{ type: 'volume-up' | 'volume-down' | 'volume-toggle-mute' }` + +Status ingestion from renderer: + +- Listens on `REMOTE_CONTROL_STATUS_UPDATE` +- Maintains in-memory `RemoteControlStatus` object returned by `/status` + +### Settings integration + +- Main handler: `apps/electron-backend/src/app/events/settings.events.ts` +- On `SETTINGS_UPDATE`, reads `remoteControl` and `remoteControlPort`, persists to store, and calls: + - `httpServer.updateSettings(enabled, port)` + +## Preload Bridge + +- File: `apps/electron-backend/src/app/api/main.preload.ts` + +Exposed APIs relevant to remote control: + +- `onChannelChange(callback) => unsubscribe` +- `onRemoteControlCommand(callback) => unsubscribe` +- `updateRemoteControlStatus(status) => void` + +Type definitions: + +- `apps/web/src/typings.d.ts` +- `global.d.ts` + +## Renderer Integrations + +## Shared helpers + +- File: `apps/web/src/app/shared/services/remote-channel-navigation.util.ts` + +Functions: + +- `getAdjacentChannelItem(...)`: wraps around on boundaries for up/down +- `getChannelItemByNumber(...)`: 1-based number to list item mapping + +Used by M3U, Xtream, and Stalker live integrations. + +## M3U integration + +- File: `apps/web/src/app/home/video-player/video-player.component.ts` + +Implemented behavior: + +- Subscribes to: + - `onChannelChange` (up/down) + - `onRemoteControlCommand` (number + volume) +- Applies channel up/down by active channel URL over `channels$` +- Applies number select through existing `switchToChannelByNumber(...)` +- Applies volume commands: + - up/down in 0.1 increments + - toggle mute with last non-zero volume restore + - persists to `localStorage` +- Publishes status snapshots via `updateRemoteControlStatus(...)`: + - `portal: 'm3u'` + - `isLiveView: true` + - channel name/number + - EPG now fields + - `supportsVolume: true`, `volume`, `muted` +- Cleans listeners/subscriptions in `ngOnDestroy`. + +## Xtream integration (live view) + +- File: `apps/web/src/app/xtream-tauri/live-stream-layout/live-stream-layout.component.ts` + +Implemented behavior: + +- Subscribes to: + - `onChannelChange` for up/down + - `onRemoteControlCommand` for number select +- Up/down: + - Uses selected live item `selectedItem().xtream_id` + - Navigates inside `selectItemsFromSelectedCategory()` + - Calls `playLive(nextItem)` +- Number select: + - Maps number to item in current category list + - Calls `playLive(channel)` +- Publishes status via effect: + - `portal: 'xtream'` + - `isLiveView` only when selected content type is `live` and item is selected + - channel name/number + current EPG item + - `supportsVolume: false` +- Cleans listeners in `ngOnDestroy`. + +## Stalker integration (ITV live view) + +- File: `apps/web/src/app/stalker/stalker-live-stream-layout/stalker-live-stream-layout.component.ts` + +Implemented behavior: + +- Subscribes to: + - `onChannelChange` for up/down + - `onRemoteControlCommand` for number select +- Up/down: + - Uses `selectedItem().id` + - Navigates inside `itvChannels()` + - Calls `playChannel(nextItem)` +- Number select: + - Maps number into `itvChannels()` + - Calls `playChannel(channel)` +- Publishes status via effect: + - `portal: 'stalker'` + - `isLiveView` only for selected content type `itv` with active item + - channel name/number + current EPG item + - `supportsVolume: false` +- Cleans listeners in `ngOnDestroy`. + +## Remote Web App + +### App shell + +- App: `apps/remote-control-web/src/app/app.ts` +- Template: `apps/remote-control-web/src/app/app.html` +- Style: `apps/remote-control-web/src/app/app.scss` +- Renders shared library component: `` + +### Shared remote UI library + +- Component: + - `libs/ui/remote-control/src/lib/remote-control/remote-control.component.ts` + - `libs/ui/remote-control/src/lib/remote-control/remote-control.component.html` + - `libs/ui/remote-control/src/lib/remote-control/remote-control.component.scss` +- Service: + - `libs/ui/remote-control/src/lib/remote-control/remote-control.service.ts` + +Implemented UI behavior: + +- Channel pad (`CH+`, `CH-`) +- Numeric keypad (`0-9`, `DEL`, `CLR`, `OK`) +- Volume controls (`VOL-`, `MUTE/UNMUTE`, `VOL+`) +- Status card (portal, channel name/number, current program) +- Polls `/status` every 2s +- Uses action wrapper to refresh status after command execution + +## Settings UI and discoverability + +- Files: + - `apps/web/src/app/settings/settings.component.ts` + - `apps/web/src/app/settings/settings.component.html` +- Features: + - Toggle `remoteControl` + - Configure `remoteControlPort` + - Display local URLs and QR codes for remote access + - Local IP list loaded via `getLocalIpAddresses()` + +## Feature Matrix (Current) + +| Capability | M3U | Xtream Live | Stalker ITV | +|---|---|---|---| +| Channel up/down | Yes | Yes | Yes | +| Number select | Yes | Yes | Yes | +| Status publish | Yes | Yes | Yes | +| Volume command handling | Yes | No | No | +| `supportsVolume` in status | true | false | false | + +## Known limitations + +- Volume commands are currently no-op in Xtream and Stalker integrations. +- Remote status uses polling from web UI (2s), not push/WebSocket. +- Number-based selection is list-position based (1-based index in active list scope), not global EPG number mapping. +- Remote API currently has no auth/TLS; intended for trusted local networks. + +## Operational notes + +- UI updates in remote web app require rebuilding `remote-control-web` so Electron serves fresh `dist` assets. +- If stale UI appears, clear browser cache/hard-refresh mobile browser. + +## Future extension points + +- Add optional auth token for `/api/remote-control/*` endpoints. +- Add WebSocket/SSE status push for lower latency and reduced polling. +- Add cross-portal volume abstraction and capability negotiation. +- Add last-channel, favorites navigation, and search/select commands. diff --git a/docs/architecture/stalker-epg.md b/docs/architecture/stalker-epg.md new file mode 100644 index 000000000..bf25532a5 --- /dev/null +++ b/docs/architecture/stalker-epg.md @@ -0,0 +1,212 @@ +# Stalker Portal EPG Architecture + +This document describes the EPG (Electronic Program Guide) implementation for Stalker/Ministra portal live TV (ITV) streams. + +Related architecture docs: + +- [Stalker Portal Architecture](./stalker-portal.md) +- [Remote Control Architecture](./remote-control.md) + +## Overview + +The Stalker ITV live stream layout displays EPG data in the right panel when a live channel is playing. EPG is fetched per-channel using the Stalker `get_short_epg` API action, which returns the current program plus the next ~5 upcoming programs. The response is mapped to the shared `EpgItem` interface and rendered by the reusable `EpgViewComponent`. + +## Architecture + +``` +┌───────────────────────────────────────────────────────────────────────────┐ +│ StalkerLiveStreamLayoutComponent │ +│ apps/web/src/app/stalker/stalker-live-stream-layout/ │ +│ │ +│ ┌──────────────┐ ┌──────────────────────┐ ┌─────────────────────┐ │ +│ │ Sidebar │ │ Video Player │ │ EPG Panel │ │ +│ │ (channels) │ │ (WebPlayerView) │ │ (EpgViewComponent) │ │ +│ │ │ │ │ │ │ │ +│ │ click ──────┼────┼──► playChannel() ────┼────┼──► loadEpgFor...() │ │ +│ └──────────────┘ └──────────────────────┘ └─────────────────────┘ │ +│ │ │ │ +└──────────────────────────────┼────────────────────────────┼───────────────┘ + │ │ + ▼ ▼ + ┌──────────────────────┐ ┌────────────────────────┐ + │ StalkerStore │ │ StalkerStore │ + │ fetchLinkToPlay() │ │ fetchChannelEpg() │ + └──────────┬───────────┘ └────────────┬───────────┘ + │ │ + ▼ ▼ + ┌──────────────────────────────────────────────────────┐ + │ Stalker Portal API │ + │ action=create_link action=get_short_epg │ + └──────────────────────────────────────────────────────┘ +``` + +## Stalker EPG API + +### `get_short_epg` (per-channel) + +**Request:** +``` +GET load.php?type=itv&action=get_short_epg&ch_id={channel_id}&JsHttpRequest=1-xml +``` +Uses standard Stalker auth headers (Bearer token + MAC cookie). + +**Response:** +```json +{ + "js": { + "data": [ + { + "id": "123", + "ch_id": "45", + "name": "Program Title", + "descr": "Program description", + "time": "2025-01-15 14:00:00", + "time_to": "2025-01-15 14:30:00", + "duration": "1800", + "start_timestamp": "1736949600", + "stop_timestamp": "1736951400", + "t_time": "14:00", + "t_time_to": "14:30" + } + ] + } +} +``` + +**Notes:** +- If the channel has no `xmltv_id` set on the server, returns an empty array +- Response normalization handles both `{ js: { data: [...] } }` and `{ js: [...] }` formats +- No backend (Electron IPC) changes needed — the generic `stalker.events.ts` handler forwards any `params` to the portal URL + +### `get_epg_info` (bulk — reserved for future use) + +``` +GET load.php?type=itv&action=get_epg_info&period={hours}&JsHttpRequest=1-xml +``` + +Returns EPG for all channels for a given time period. Currently unused but the enum value `StalkerPortalActions.GetEpgInfo` is defined for future bulk EPG features. + +## Data Mapping + +### Stalker EPG → `EpgItem` Interface + +| Stalker field | `EpgItem` field | Notes | +|--------------------|--------------------|-------| +| `id` | `id` | Converted to string | +| `ch_id` | `channel_id` | Falls back to passed `channelId` | +| `name` | `title` | | +| `descr` | `description` | | +| `time` | `start` | Full datetime string | +| `time_to` | `end`, `stop` | Both set to same value | +| `start_timestamp` | `start_timestamp` | Unix timestamp as string | +| `stop_timestamp` | `stop_timestamp` | Unix timestamp as string | +| _(n/a)_ | `epg_id` | Empty string | +| _(n/a)_ | `lang` | Empty string | + +### `EpgItem` Interface + +```typescript +// libs/shared/interfaces/src/lib/epg-item.interface.ts +interface EpgItem { + id: string; + epg_id: string; + title: string; + lang: string; + start: string; + end: string; + stop: string; + description: string; + channel_id: string; + start_timestamp: string; + stop_timestamp: string; +} +``` + +## Implementation Details + +### Key Files + +| File | Purpose | +|------|---------| +| `libs/shared/interfaces/src/lib/stalker-portal-actions.enum.ts` | `GetShortEpg`, `GetEpgInfo` enum values | +| `apps/web/src/app/stalker/stalker.store.ts` | `fetchChannelEpg()` method | +| `apps/web/src/app/stalker/stalker-live-stream-layout/stalker-live-stream-layout.component.ts` | EPG signals + `loadEpgForChannel()` | +| `apps/web/src/app/stalker/stalker-live-stream-layout/stalker-live-stream-layout.component.html` | `` integration | +| `libs/ui/shared-portals/src/lib/epg-view/epg-view.component.ts` | Shared EPG display component | + +### StalkerStore.fetchChannelEpg() + +Location: `apps/web/src/app/stalker/stalker.store.ts` (in `withMethods`) + +```typescript +async fetchChannelEpg(channelId: number | string): Promise +``` + +- Sends `get_short_epg` request with `ch_id` param +- Supports both full Stalker portals (authenticated via `StalkerSessionService`) and simple portals (direct IPC) +- Returns mapped `EpgItem[]` or empty array on failure +- No store state mutation — returns data directly to the component + +### StalkerLiveStreamLayoutComponent EPG Integration + +The component manages EPG state locally with signals: + +```typescript +readonly epgItems = signal([]); +readonly isLoadingEpg = signal(false); +``` + +**Flow:** +1. User clicks a channel in the sidebar → `playChannel(item)` +2. `fetchLinkToPlay()` gets the stream URL +3. If using embedded player, `streamUrl` is set and `loadEpgForChannel(item.id)` is called +4. `loadEpgForChannel()` sets loading state, calls `stalkerStore.fetchChannelEpg()`, updates `epgItems` +5. Template renders `` or a loading spinner + +### EpgViewComponent (shared) + +Location: `libs/ui/shared-portals/src/lib/epg-view/` + +Reusable component shared between Stalker and Xtream live stream layouts: +- **Input:** `epgItems: EpgItem[]` +- Displays program list with time, title, and info button +- Highlights current program with green progress bar +- Handles empty state (shows "EPG not available" message) +- Info button opens `EpgItemDescriptionComponent` dialog with title and description + +**Current program detection:** +```typescript +isCurrentProgram(item: EpgItem): boolean { + const now = new Date().getTime(); + const start = new Date(item.start).getTime(); + const stop = new Date(item.stop ?? item.end).getTime(); + return now >= start && now <= stop; +} +``` + +This works with Stalker's datetime format (`"2025-01-15 14:00:00"`) because `new Date()` parses it correctly. + +## Authentication + +EPG requests follow the same authentication pattern as all Stalker API calls: + +| Portal Type | Auth Method | +|-------------|-------------| +| **Full Stalker** (`isFullStalkerPortal: true`) | `StalkerSessionService.makeAuthenticatedRequest()` — handles token refresh and retry on 401 | +| **Simple Stalker** | Direct IPC via `DataService.sendIpcEvent(STALKER_REQUEST, ...)` — no auth headers | + +## Future Enhancements + +### Bulk EPG in Channel List Sidebar + +Use `get_epg_info` with `period=3` to pre-fetch current program titles for all channels when a category is selected: +- Call `get_epg_info` after category selection +- Build a `Map` +- Show current program name below channel title in the sidebar +- Display progress bar per channel item + +This is a separate task due to Stalker's lazy-loaded channel pagination model. + +### EPG Auto-Refresh + +Currently EPG is fetched once per channel selection. A future enhancement could add a timer to refresh EPG data periodically (e.g., every 5 minutes) to keep the "current program" indicator accurate during long viewing sessions. diff --git a/docs/architecture/stalker-portal.md b/docs/architecture/stalker-portal.md new file mode 100644 index 000000000..748f9eeb6 --- /dev/null +++ b/docs/architecture/stalker-portal.md @@ -0,0 +1,156 @@ +# Stalker Portal Architecture + +This document describes the Stalker portal implementation in IPTVnator and where each feature is integrated. + +## Related Docs + +- [Stalker Portal EPG Architecture](./stalker-epg.md) +- [Remote Control Architecture](./remote-control.md) +- [Download Manager](./download-manager.md) +- [Category Management](./category-management.md) +- [Stalker Store API Baseline](./stalker-store-api-baseline.md) + +## Scope + +Stalker support covers: + +- Live TV (`itv`) +- VOD (`vod`) +- Series (`series`) +- VOD-as-series flows (`is_series=1` and embedded `series[]`) +- Favorites and recently viewed collections +- Search +- External player playback (shared Xtream player infrastructure) +- Remote control for live ITV navigation + +## Routing Structure + +Primary route tree lives in `/Users/4gray/Code/iptvnator/apps/web/src/app/stalker/stalker.routes.ts`. + +- `/stalker/:id/vod` +- `/stalker/:id/series` +- `/stalker/:id/itv` +- `/stalker/:id/favorites` +- `/stalker/:id/recent` +- `/stalker/:id/search` +- `/stalker/:id/downloads` (shared downloads module from Xtream UI) + +## Runtime Architecture + +1. Angular Stalker screens call methods/resources in `StalkerStore`. +2. `StalkerStore` builds request params based on selected content type and current view state. +3. Requests go through `DataService.sendIpcEvent(STALKER_REQUEST, ...)` or `StalkerSessionService` (full portal auth). +4. Electron main process handles `STALKER_REQUEST` in `/Users/4gray/Code/iptvnator/apps/electron-backend/src/app/events/stalker.events.ts`. +5. Axios calls Stalker `load.php` API with required headers/cookies and returns normalized payloads to renderer. + +## Main UI Components + +- `/Users/4gray/Code/iptvnator/apps/web/src/app/stalker/stalker-main-container.component.ts` + - Category + content layout for `vod` and `series` +- `/Users/4gray/Code/iptvnator/apps/web/src/app/stalker/stalker-live-stream-layout/stalker-live-stream-layout.component.ts` + - ITV live playback, channel navigation, EPG panel integration +- `/Users/4gray/Code/iptvnator/apps/web/src/app/stalker/stalker-series-view/stalker-series-view.component.ts` + - Season/episode UI for all Stalker series modes +- `/Users/4gray/Code/iptvnator/apps/web/src/app/stalker/stalker-favorites/stalker-favorites.component.ts` +- `/Users/4gray/Code/iptvnator/apps/web/src/app/stalker/recently-viewed/recently-viewed.component.ts` +- `/Users/4gray/Code/iptvnator/apps/web/src/app/stalker/stalker-search/stalker-search.component.ts` + +## Store and Data Flow + +Stalker store is now feature-composed: + +- Facade: `/Users/4gray/Code/iptvnator/apps/web/src/app/stalker/stalker.store.ts` +- Feature slices: `/Users/4gray/Code/iptvnator/apps/web/src/app/stalker/stores/features/*` +- Shared store utils: `/Users/4gray/Code/iptvnator/apps/web/src/app/stalker/stores/utils/*` + +Important store responsibilities: + +- Selected content/category/item state +- Category and paginated content resources +- ITV channel list + pagination +- Regular series seasons resource +- VOD-series (`is_series=1`) seasons + episodes resources +- Playback link creation (`create_link` flow) +- Favorites and recently viewed persistence helpers + +## VOD/Series Modes + +Stalker has multiple real-world data shapes. The current implementation supports all three: + +1. Regular Series (`/series`): +- Seasons come from API resource (`serialSeasonsResource`). +- Episodes are derived from season payload. + +2. VOD with Embedded `series[]`: +- Item is opened under VOD, but already contains episodes. +- `StalkerSeriesViewComponent` creates a pseudo-season and renders episodes directly. + +3. VOD with `is_series=1` (Ministra plugin behavior): +- Treated as series flow from VOD context. +- Seasons are fetched lazily. +- Episodes are fetched on season select. +- Uses unique generated tracking IDs for episode playback position compatibility. + +Core decision logic and normalization are centralized in: + +- `/Users/4gray/Code/iptvnator/apps/web/src/app/stalker/stalker-vod.utils.ts` +- `/Users/4gray/Code/iptvnator/apps/web/src/app/stalker/models/*.ts` + +## Favorites and Recently Viewed + +Current implementation is shared via Stalker-specific helpers: + +- `createPortalCollectionResource(...)` generic collection loader +- `createPortalFavoritesResource(...)` favorites wrapper +- `createStalkerDetailViewState(...)` unified "open detail" decision +- `toggleStalkerVodFavorite(...)` shared add/remove behavior +- `normalizeStalkerEntityId(...)` and `normalizeStalkerEntityIdAsNumber(...)` for stable ID matching +- `matchesFavoriteById(...)` for cross-shape favorite matching + +Where this is used: + +- `/Users/4gray/Code/iptvnator/apps/web/src/app/stalker/stalker-favorites/stalker-favorites.component.ts` +- `/Users/4gray/Code/iptvnator/apps/web/src/app/stalker/recently-viewed/recently-viewed.component.ts` +- `/Users/4gray/Code/iptvnator/apps/web/src/app/stalker/stalker-search/stalker-search.component.ts` +- `/Users/4gray/Code/iptvnator/apps/web/src/app/stalker/favorites-button/favorites-button.component.ts` + +## Remote Control Integration + +Stalker live remote control is implemented in: + +- `/Users/4gray/Code/iptvnator/apps/web/src/app/stalker/stalker-live-stream-layout/stalker-live-stream-layout.component.ts` + +Supported today: + +- Channel up/down +- Numeric channel selection (list-position based) +- Status publish for remote UI (portal/channel/current program) + +See full backend and web-remote flow in [Remote Control Architecture](./remote-control.md). + +## EPG Integration + +EPG in Stalker ITV uses `get_short_epg` and shared `EpgViewComponent`. Full details are documented in [Stalker Portal EPG Architecture](./stalker-epg.md). + +## Shared/Reusable Infrastructure + +Stalker reuses some Xtream UI infrastructure deliberately: + +- Category content rendering route uses Xtream category content component +- Season container for episodes uses shared Xtream season UI component +- Playback position handling for series episodes reuses Xtream store position mechanisms +- Downloads route reuses shared downloads feature + +This reduces duplicate UI logic across portal types and keeps compatibility behavior aligned. + +## Regression Coverage + +Focused regression tests for Stalker VOD mode branching live in: + +- `/Users/4gray/Code/iptvnator/apps/web/src/app/stalker/stalker-vod.utils.spec.ts` + +Covered scenarios include: + +- Embedded `series[]` opens series view state +- `is_series=1` opens lazy series state +- Favorite toggle helper path invokes the expected add/remove flow diff --git a/docs/architecture/stalker-store-api-baseline.md b/docs/architecture/stalker-store-api-baseline.md new file mode 100644 index 000000000..361b57bbc --- /dev/null +++ b/docs/architecture/stalker-store-api-baseline.md @@ -0,0 +1,126 @@ +# Stalker Store API Baseline + +This is the compatibility baseline for refactoring `apps/web/src/app/stalker/stalker.store.ts`. + +Goal: keep this public surface stable while splitting to feature stores. + +## Source of Truth + +- Store implementation: `apps/web/src/app/stalker/stalker.store.ts` +- Baseline created on current branch state before feature-store extraction. + +## Public State Signals + +Direct signal properties currently exposed by `signalStore`: + +- `selectedContentType: 'vod' | 'itv' | 'series'` +- `selectedCategoryId: string | null | undefined` +- `selectedVodId: string | undefined` +- `selectedSerialId: string | undefined` +- `selectedItvId: string | undefined` +- `limit: number` +- `page: number` +- `searchPhrase: string` +- `currentPlaylist: PlaylistMeta` +- `totalCount: number` +- `selectedItem: StalkerVodSource | null | undefined` +- `vodCategories: StalkerCategoryItem[]` +- `seriesCategories: StalkerCategoryItem[]` +- `itvCategories: StalkerCategoryItem[]` +- `hasMoreChannels: boolean` +- `itvChannels: StalkerItvChannel[]` +- `vodSeriesSeasons: StalkerVodSeriesSeason[]` +- `vodSeriesEpisodes: StalkerVodSeriesEpisode[]` +- `selectedVodSeriesSeasonId: string | undefined` + +## Public Computed Selectors + +- `getTotalPages: number` +- `getPaginatedContent: StalkerContentItem[] | undefined` +- `isPaginatedContentLoading: boolean` +- `isPaginatedContentFailed: unknown` +- `getSerialSeasonsResource: StalkerSeason[]` +- `isSerialSeasonsLoading: boolean` +- `getVodSeriesSeasonsResource: StalkerVodSeriesSeason[]` +- `isVodSeriesSeasonsLoading: boolean` +- `getCategoryResource: StalkerCategoryItem[]` +- `isCategoryResourceLoading: boolean` +- `isCategoryResourceFailed: unknown` +- `getSelectedCategoryName: string` + +## Exposed Resources/Props + +These are currently reachable on the store object and used internally by computed selectors: + +- `getCategoryResource` (resource) +- `getContentResource` (resource) +- `serialSeasonsResource` (resource) +- `vodSeriesSeasonsResource` (resource) +- `makeStalkerRequest(...)` + +During refactor: +- Keep compatibility for external callers that may read these directly. +- If moved/renamed internally, provide facade aliases. + +## Public Methods (Compatibility Contract) + +- `setSelectedContentType(type: 'vod' | 'itv' | 'series'): void` +- `setSelectedCategory(id: string | number | null): void` +- `setSelectedSerialId(id: string): void` +- `setSelectedVodId(id: string): void` +- `setSelectedItvId(id: string): void` +- `setLimit(limit: number): void` +- `setPage(page: number): void` +- `setCurrentPlaylist(playlist: PlaylistMeta | undefined): Promise` +- `setSelectedItem(selectedItem: StalkerVodSource | null | undefined): void` +- `clearSelectedItem(): void` +- `setCategories(type: 'vod' | 'series' | 'itv', categories: StalkerCategoryItem[]): void` +- `resetCategories(): void` +- `setItvChannels(channels: StalkerItvChannel[]): void` +- `setSearchPhrase(phrase: string): void` +- `fetchVodSeriesEpisodes(videoId: string, seasonId: string): Promise` +- `getSelectedCategory(): { id: string | number; name: string; type: 'vod' | 'itv' | 'series' }` +- `fetchLinkToPlay(portalUrl: string, macAddress: string, cmd: string, series?: number): Promise` +- `getExpireDate(): Promise` +- `addToFavorites(item: any, onDone?: () => void): void` +- `removeFromFavorites(favoriteId: string, onDone?: () => void): void` +- `fetchMovieFileId(movieId: string): Promise` +- `createLinkToPlayVod(cmd?: string, title?: string, thumbnail?: string, episodeNum?: number, episodeId?: number, startTime?: number): Promise` +- `addToRecentlyViewed(item: any): void` +- `removeFromRecentlyViewed(itemId: number, onComplete?: () => void): void` +- `fetchChannelEpg(channelId: number | string, size?: number): Promise` + +## Current Consumers (Observed) + +Top observed store API usage in app code: + +- `currentPlaylist` (16 references) +- `setSelectedContentType` (9) +- `selectedItem` (9) +- `setSelectedItem` (7) +- `setSelectedCategory` (6) +- `createLinkToPlayVod` (6) +- `removeFromFavorites` (5) +- `setPage` (4) +- `addToFavorites` (4) +- `fetchChannelEpg` (3) +- plus lower-frequency calls for paging/resources/series/recent. + +Consumer directories sampled: + +- `apps/web/src/app/stalker/**` +- `apps/web/src/app/xtream-tauri/**` +- `apps/web/src/app/shared/**` + +## Invariants to Preserve During Refactor + +- Selection IDs (`selectedVodId`, `selectedSerialId`, `selectedItvId`) are synchronized in `setSelectedItem`. +- `setSelectedCategory(...)` resets `page` to `0`. +- `createLinkToPlayVod(...)` continues to: + - support episode playback metadata + - append recently viewed + - preserve external player payload shape +- Full-portal auth path continues through `StalkerSessionService`. +- Non-auth/simple path continues through `DataService.sendIpcEvent(STALKER_REQUEST, ...)`. +- Resource-driven loading signals preserve existing names. + From 4049109ffdce7632323618285d58903446c1f7ed Mon Sep 17 00:00:00 2001 From: 4gray Date: Sat, 14 Feb 2026 16:22:08 +0100 Subject: [PATCH 12/12] refactor(stalker): optimize imports and sorting functions for better performance --- .../stores/features/with-stalker-series.feature.ts | 10 ++++++---- .../src/app/stalker/stores/utils/stalker-sorting.ts | 5 ++--- .../category-content-view.component.ts | 6 ++---- 3 files changed, 10 insertions(+), 11 deletions(-) diff --git a/apps/web/src/app/stalker/stores/features/with-stalker-series.feature.ts b/apps/web/src/app/stalker/stores/features/with-stalker-series.feature.ts index 4a96502b9..7e9a5c4e5 100644 --- a/apps/web/src/app/stalker/stores/features/with-stalker-series.feature.ts +++ b/apps/web/src/app/stalker/stores/features/with-stalker-series.feature.ts @@ -1,4 +1,4 @@ -import { computed, inject } from '@angular/core'; +import { computed, inject, resource } from '@angular/core'; import { patchState, signalStoreFeature, @@ -13,10 +13,12 @@ import { StalkerVodSeriesEpisode, StalkerVodSeriesSeason, } from '../../models'; -import { sortEpisodesByNumber } from '../utils'; -import { resource } from '@angular/core'; import { StalkerContentTypes } from '../../stalker-content-types'; -import { sortByNumericValue, sortVodSeriesSeasonsByNumber } from '../utils'; +import { + sortByNumericValue, + sortEpisodesByNumber, + sortVodSeriesSeasonsByNumber, +} from '../utils'; import { createLogger } from '../../../shared/utils/logger'; /** diff --git a/apps/web/src/app/stalker/stores/utils/stalker-sorting.ts b/apps/web/src/app/stalker/stores/utils/stalker-sorting.ts index f0078938b..75e693fa7 100644 --- a/apps/web/src/app/stalker/stores/utils/stalker-sorting.ts +++ b/apps/web/src/app/stalker/stores/utils/stalker-sorting.ts @@ -15,7 +15,7 @@ export function extractNumericValue(str: string): number { export function sortByNumericValue(array: StalkerSeason[]): StalkerSeason[] { if (!array) return []; const key = 'name'; - return array.sort((a, b) => { + return [...array].sort((a, b) => { const numericA = extractNumericValue(a[key]); const numericB = extractNumericValue(b[key]); return numericA - numericB; @@ -40,10 +40,9 @@ export function sortEpisodesByNumber( episodes: StalkerVodSeriesEpisode[] ): StalkerVodSeriesEpisode[] { if (!episodes) return []; - return episodes.sort((a, b) => { + return [...episodes].sort((a, b) => { const numA = Number(a.series_number ?? 0) || 0; const numB = Number(b.series_number ?? 0) || 0; return numA - numB; }); } - 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 e6a5c5ef1..d82add385 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 @@ -22,14 +22,12 @@ import { createPortalFavoritesResource, createRefreshTrigger, isSelectedStalkerVodFavorite, + normalizeStalkerEntityId, + normalizeStalkerEntityIdAsNumber, toggleStalkerVodFavorite, } from '../../stalker/stalker-vod.utils'; import { createLogger } from '../../shared/utils/logger'; import { DownloadsService } from '../../services/downloads.service'; -import { - normalizeStalkerEntityId, - normalizeStalkerEntityIdAsNumber, -} from '../../stalker/stalker-vod.utils'; @Component({ selector: 'app-category-content-view',