From 91aa6c02a0f7dbaffe3686df4800ed8634d7b5f3 Mon Sep 17 00:00:00 2001 From: 4gray Date: Sat, 14 Feb 2026 15:42:46 +0100 Subject: [PATCH] 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; + }); +} +