From d0f36a1afeb9f433495bc7894b39f09483753d32 Mon Sep 17 00:00:00 2001 From: 4gray Date: Thu, 16 Apr 2026 21:58:42 +0200 Subject: [PATCH] refactor: refactor Stalker portal store, features and utilities - Updated `with-stalker-portal.feature.ts` to handle undefined currentPlaylist and improved error handling for token retrieval. - Enhanced `with-stalker-recent.feature.ts` by introducing utility functions for recently viewed items and dispatching playlist meta updates. - Added unit tests for `with-stalker-selection.feature.ts` to ensure correct state management on category and item selection changes. - Simplified `with-stalker-selection.feature.ts` by removing unnecessary computed properties and streamlining state management. - Improved `with-stalker-series.feature.ts` by refactoring resource fetching logic and integrating new utility functions for request execution. - Created `stalker-store.contracts.ts` to define interfaces for various store contracts, enhancing type safety and clarity. - Added utility functions in `stalker-collection-persistence.utils.ts` for building recently viewed payloads and dispatching updates. - Implemented player request utilities in `stalker-player-request.utils.ts` for handling playback links and movie file IDs. - Introduced request execution utilities in `stalker-request.utils.ts` to streamline authenticated and non-authenticated requests. Entire-Checkpoint: c6e522b4276c --- .../src/lib/stalker-live-playback.utils.ts | 8 +- .../with-stalker-content.feature.spec.ts | 197 +++++ .../features/with-stalker-content.feature.ts | 720 ++++++++++-------- .../features/with-stalker-epg.feature.ts | 80 +- .../with-stalker-favorites.feature.ts | 63 +- .../with-stalker-player.feature.spec.ts | 178 +++++ .../features/with-stalker-player.feature.ts | 544 ++++--------- .../features/with-stalker-portal.feature.ts | 41 +- .../features/with-stalker-recent.feature.ts | 82 +- .../with-stalker-selection.feature.spec.ts | 36 + .../with-stalker-selection.feature.ts | 52 +- .../features/with-stalker-series.feature.ts | 304 ++++---- .../src/lib/stores/stalker-store.contracts.ts | 81 ++ .../data-access/src/lib/stores/utils/index.ts | 4 +- .../stalker-collection-persistence.utils.ts | 76 ++ .../stalker-player-request.utils.spec.ts | 136 ++++ .../utils/stalker-player-request.utils.ts | 204 +++++ .../lib/stores/utils/stalker-request.utils.ts | 34 + 18 files changed, 1780 insertions(+), 1060 deletions(-) create mode 100644 libs/portal/stalker/data-access/src/lib/stores/features/with-stalker-content.feature.spec.ts create mode 100644 libs/portal/stalker/data-access/src/lib/stores/features/with-stalker-player.feature.spec.ts create mode 100644 libs/portal/stalker/data-access/src/lib/stores/features/with-stalker-selection.feature.spec.ts create mode 100644 libs/portal/stalker/data-access/src/lib/stores/stalker-store.contracts.ts create mode 100644 libs/portal/stalker/data-access/src/lib/stores/utils/stalker-collection-persistence.utils.ts create mode 100644 libs/portal/stalker/data-access/src/lib/stores/utils/stalker-player-request.utils.spec.ts create mode 100644 libs/portal/stalker/data-access/src/lib/stores/utils/stalker-player-request.utils.ts create mode 100644 libs/portal/stalker/data-access/src/lib/stores/utils/stalker-request.utils.ts diff --git a/libs/portal/stalker/data-access/src/lib/stalker-live-playback.utils.ts b/libs/portal/stalker/data-access/src/lib/stalker-live-playback.utils.ts index 88f23a907..734b7e0aa 100644 --- a/libs/portal/stalker/data-access/src/lib/stalker-live-playback.utils.ts +++ b/libs/portal/stalker/data-access/src/lib/stalker-live-playback.utils.ts @@ -1,11 +1,11 @@ -import { Playlist } from 'shared-interfaces'; +import { PlaylistMeta } from 'shared-interfaces'; export const STALKER_MAG_USER_AGENT = 'Mozilla/5.0 (QtEmbedded; U; Linux; C) AppleWebKit/533.3 (KHTML, like Gecko) MAG250'; export const STALKER_STREAM_USER_AGENT = 'KSPlayer'; export function getStalkerPortalOrigin( - playlist: Playlist | undefined | null + playlist: PlaylistMeta | undefined | null ): string | undefined { const portalUrl = playlist?.portalUrl; if (!portalUrl) { @@ -20,7 +20,7 @@ export function getStalkerPortalOrigin( } export function isCrossOriginStalkerStream( - playlist: Playlist | undefined | null, + playlist: PlaylistMeta | undefined | null, streamUrl?: string ): boolean { const portalOrigin = getStalkerPortalOrigin(playlist); @@ -36,7 +36,7 @@ export function isCrossOriginStalkerStream( } export function buildStalkerExternalPlaybackHeaders( - playlist: Playlist | undefined | null, + playlist: PlaylistMeta | undefined | null, token?: string | null, streamUrl?: string ): Record { diff --git a/libs/portal/stalker/data-access/src/lib/stores/features/with-stalker-content.feature.spec.ts b/libs/portal/stalker/data-access/src/lib/stores/features/with-stalker-content.feature.spec.ts new file mode 100644 index 000000000..8e9616916 --- /dev/null +++ b/libs/portal/stalker/data-access/src/lib/stores/features/with-stalker-content.feature.spec.ts @@ -0,0 +1,197 @@ +import { TestBed } from '@angular/core/testing'; +import { patchState, signalStore, withMethods, withState } from '@ngrx/signals'; +import { TranslateService } from '@ngx-translate/core'; +import { DataService } from 'services'; +import { PlaylistMeta, StalkerPortalActions } from 'shared-interfaces'; +import { StalkerSessionService } from '../../stalker-session.service'; +import { withStalkerContent } from './with-stalker-content.feature'; + +jest.mock('@iptvnator/portal/shared/util', () => ({ + createLogger: () => ({ + debug: jest.fn(), + info: jest.fn(), + warn: jest.fn(), + error: jest.fn(), + }), +})); + +const PLAYLIST = { + _id: 'playlist-1', + title: 'Demo Stalker', + count: 0, + autoRefresh: false, + importDate: '2026-04-14T00:00:00.000Z', + portalUrl: 'http://demo.example/stalker_portal/server/load.php', + macAddress: '00:1A:79:00:00:01', + isFullStalkerPortal: false, +} as PlaylistMeta; + +const TestContentStore = signalStore( + withState({ + currentPlaylist: undefined as PlaylistMeta | undefined, + selectedContentType: 'vod' as 'vod' | 'series' | 'itv', + selectedCategoryId: undefined as string | null | undefined, + searchPhrase: '', + page: 0, + limit: 14, + }), + withMethods((store) => ({ + setCurrentPlaylist(playlist: PlaylistMeta | undefined) { + patchState(store, { currentPlaylist: playlist }); + }, + setSelectedContentType(type: 'vod' | 'series' | 'itv') { + patchState(store, { selectedContentType: type }); + }, + setSelectedCategory(id: string | null | undefined) { + patchState(store, { selectedCategoryId: id }); + }, + })), + withStalkerContent() +); + +async function flushResources(): Promise { + TestBed.flushEffects(); + await Promise.resolve(); + await Promise.resolve(); + TestBed.flushEffects(); + await Promise.resolve(); +} + +async function waitForCondition( + predicate: () => boolean, + attempts = 20 +): Promise { + for (let index = 0; index < attempts; index += 1) { + if (predicate()) { + return; + } + + await flushResources(); + } + + throw new Error('Timed out waiting for resource activity'); +} + +describe('withStalkerContent failure states', () => { + let store: InstanceType; + let dataService: { + sendIpcEvent: jest.Mock, unknown[]>; + }; + + beforeEach(() => { + dataService = { + sendIpcEvent: jest.fn(), + }; + + TestBed.configureTestingModule({ + providers: [ + TestContentStore, + { provide: DataService, useValue: dataService }, + { + provide: StalkerSessionService, + useValue: { + makeAuthenticatedRequest: jest.fn(), + }, + }, + { + provide: TranslateService, + useValue: { + instant: jest.fn((key: string) => key), + }, + }, + ], + }); + + store = TestBed.inject(TestContentStore); + }); + + it('normalizes category failures into empty arrays and explicit error state', async () => { + dataService.sendIpcEvent.mockRejectedValue( + new Error('get_genres failed') + ); + + store.setSelectedContentType('itv'); + store.setCurrentPlaylist(PLAYLIST); + void store.isCategoryResourceLoading(); + + await waitForCondition( + () => dataService.sendIpcEvent.mock.calls.length > 0 + ); + await flushResources(); + + expect(dataService.sendIpcEvent).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + url: PLAYLIST.portalUrl, + macAddress: PLAYLIST.macAddress, + params: expect.objectContaining({ + action: StalkerPortalActions.GetGenres, + type: 'itv', + }), + }) + ); + expect(store.getCategoryResource()).toEqual([]); + expect(store.itvCategories()).toEqual([]); + expect(store.isCategoryResourceFailed()).toBeInstanceOf(Error); + expect((store.isCategoryResourceFailed() as Error).message).toBe( + 'get_genres failed' + ); + }); + + it('derives the selected category from the normalized category collections', () => { + store.setSelectedContentType('series'); + store.setCategories('series', [ + { + category_id: '7', + category_name: 'Drama', + }, + ]); + store.setSelectedCategory('7'); + + expect(store.getSelectedCategory()).toEqual({ + category_id: '7', + category_name: 'Drama', + }); + }); + + it('normalizes content failures into empty collections instead of undefined state', async () => { + dataService.sendIpcEvent.mockRejectedValue( + new Error('get_ordered_list failed') + ); + + store.setSelectedContentType('itv'); + store.setCategories('itv', [ + { + category_id: '5', + category_name: 'News', + }, + ]); + store.setSelectedCategory('5'); + store.setCurrentPlaylist(PLAYLIST); + void store.isPaginatedContentLoading(); + + await waitForCondition( + () => dataService.sendIpcEvent.mock.calls.length > 0 + ); + await flushResources(); + + expect(dataService.sendIpcEvent).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + params: expect.objectContaining({ + action: StalkerPortalActions.GetOrderedList, + type: 'itv', + category: '5', + genre: '5', + }), + }) + ); + expect(store.getPaginatedContent()).toEqual([]); + expect(store.itvChannels()).toEqual([]); + expect(store.hasMoreChannels()).toBe(false); + expect(store.isPaginatedContentFailed()).toBeInstanceOf(Error); + expect((store.isPaginatedContentFailed() as Error).message).toBe( + 'get_ordered_list failed' + ); + }); +}); diff --git a/libs/portal/stalker/data-access/src/lib/stores/features/with-stalker-content.feature.ts b/libs/portal/stalker/data-access/src/lib/stores/features/with-stalker-content.feature.ts index 3fb99edb9..a2c75cd36 100644 --- a/libs/portal/stalker/data-access/src/lib/stores/features/with-stalker-content.feature.ts +++ b/libs/portal/stalker/data-access/src/lib/stores/features/with-stalker-content.feature.ts @@ -8,22 +8,27 @@ import { withState, } from '@ngrx/signals'; import { TranslateService } from '@ngx-translate/core'; -import { DataService } from 'services'; -import { - Playlist, - STALKER_REQUEST, - StalkerPortalActions, -} from 'shared-interfaces'; import { createLogger } from '@iptvnator/portal/shared/util'; +import { DataService } from 'services'; import { StalkerCategoryItem, StalkerContentItem, StalkerItvChannel, StalkerVodSource, } from '../../models'; -import { StalkerSessionService } from '../../stalker-session.service'; import { StalkerContentTypes } from '../../stalker-content-types'; -import { toStalkerContentItem, toStalkerItvChannel } from '../utils'; +import { StalkerSessionService } from '../../stalker-session.service'; +import { + ResourceState, + StalkerCategorySliceContract, + StalkerContentFeatureStoreContract, + StalkerContentType, +} from '../stalker-store.contracts'; +import { + executeStalkerRequest, + toStalkerContentItem, + toStalkerItvChannel, +} from '../utils'; /** * Content/categories/channels feature state. @@ -35,6 +40,9 @@ export interface StalkerContentState { itvCategories: StalkerCategoryItem[]; hasMoreChannels: boolean; itvChannels: StalkerItvChannel[]; + paginatedContent: StalkerContentItem[]; + categoryError: unknown; + contentError: unknown; } const initialContentState: StalkerContentState = { @@ -44,14 +52,11 @@ const initialContentState: StalkerContentState = { itvCategories: [], hasMoreChannels: false, itvChannels: [], + paginatedContent: [], + categoryError: null, + contentError: null, }; -interface ResourceState { - value(): T; - isLoading(): boolean; - error(): unknown; -} - interface StalkerCategoryResponseItem { id?: string | number; title?: string; @@ -68,19 +73,87 @@ interface StalkerOrderedListResponse { }; } -interface StalkerContentStoreContext { - selectedContentType(): 'vod' | 'series' | 'itv'; - currentPlaylist(): Playlist | undefined; - selectedCategoryId(): string | null | undefined; - searchPhrase(): string; - page(): number; - limit(): number; - getContentResource: ResourceState; - getCategoryResource: ResourceState; +interface StalkerContentResourceStoreContract extends StalkerContentFeatureStoreContract { + categoryResource: ResourceState; + getContentResource: ResourceState; +} + +function getCategoriesByType( + store: StalkerCategorySliceContract, + contentType: StalkerContentType +): StalkerCategoryItem[] { + switch (contentType) { + case 'vod': + return store.vodCategories(); + case 'series': + return store.seriesCategories(); + case 'itv': + return store.itvCategories(); + } +} + +function buildCategoryPatch( + contentType: StalkerContentType, + categories: StalkerCategoryItem[] +): Partial { + switch (contentType) { + case 'vod': + return { vodCategories: categories }; + case 'series': + return { seriesCategories: categories }; + case 'itv': + return { itvCategories: categories }; + } +} + +function prependAllCategory( + categories: StalkerCategoryItem[], + translateService: TranslateService +): StalkerCategoryItem[] { + const allIndex = categories.findIndex( + (category) => category.category_name.trim().toLowerCase() === 'all' + ); + + if (allIndex > 0) { + categories.unshift(categories.splice(allIndex, 1)[0]); + return categories; + } + + if ( + allIndex === -1 && + categories.length > 0 && + !categories.some((category) => String(category.category_id) === '*') + ) { + categories.unshift({ + category_name: translateService.instant('PORTALS.ALL_CATEGORIES'), + category_id: '*', + }); + } + + return categories; +} + +function buildEmptyContentPatch( + contentType: StalkerContentType, + error: unknown +): Partial { + const patch: Partial = { + totalCount: 0, + paginatedContent: [], + contentError: error, + }; + + if (contentType === 'itv') { + patch.hasMoreChannels = false; + patch.itvChannels = []; + } + + return patch; } export function withStalkerContent() { const logger = createLogger('withStalkerContent'); + return signalStoreFeature( withState(initialContentState), withProps( @@ -90,331 +163,354 @@ export function withStalkerContent() { stalkerSession = inject(StalkerSessionService), translateService = inject(TranslateService) ) => { - const storeContext = - store as unknown as StalkerContentStoreContext; + const storeContext = store as typeof store & + StalkerContentResourceStoreContract; + const requestDeps = { + dataService, + stalkerSession, + }; return { - getCategoryResource: resource({ - params: () => ({ - contentType: storeContext.selectedContentType(), - action: StalkerPortalActions.GetCategories, - currentPlaylist: storeContext.currentPlaylist(), - }), - loader: async ({ - params, - }): Promise => { - if (!params.currentPlaylist) 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: StalkerCategoryResponse; - 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) - ); - - // If the portal provides its own "All" category entry, - // promote it to position 0. Otherwise add a synthetic one. - const allIdx = categories.findIndex( - (c) => - c.category_name.trim().toLowerCase() === 'all' - ); - if (allIdx > 0) { - // Move the existing 'All' entry to the front - categories.unshift(categories.splice(allIdx, 1)[0]); - } else if ( - allIdx === -1 && - categories.length > 0 && - !categories.some( - (category) => - String(category.category_id) === '*' - ) - ) { - // No 'All' entry found — prepend synthetic one - categories.unshift({ - category_name: translateService.instant( - 'PORTALS.ALL_CATEGORIES' - ), - category_id: '*', - }); - } - patchState(store, { - [`${params.contentType}Categories`]: categories, - }); - return categories; - }, - }), - getContentResource: resource({ - params: () => ({ - contentType: storeContext.selectedContentType(), - category: storeContext.selectedCategoryId(), - action: StalkerPortalActions.GetOrderedList, - search: storeContext.searchPhrase(), - pageIndex: storeContext.page() + 1, - availableCategoryCount: (() => { - const contentType = storeContext.selectedContentType(); - const categories = - contentType === 'vod' - ? store.vodCategories() - : contentType === 'series' - ? store.seriesCategories() - : store.itvCategories(); - return categories.filter( - (category) => - String(category.category_id) !== '*' - ).length; - })(), - }), - loader: async ({ - params, - }): Promise => { - if ( - !params.category || - params.category === null || - params.category === '' - ) { - patchState(store, { totalCount: 0 }); - return Promise.resolve(undefined); - } - if ( - params.category === '*' && - (params.contentType === 'vod' || - params.contentType === 'series') && - params.availableCategoryCount === 0 - ) { - patchState(store, { totalCount: 0 }); - return Promise.resolve(undefined); - } - - const currentPlaylist = storeContext.currentPlaylist; - - // Guard: ensure currentPlaylist is available (may not be during deep link init) - if ( - !currentPlaylist() || - !currentPlaylist().portalUrl - ) { - patchState(store, { totalCount: 0 }); - 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: StalkerOrderedListResponse; - 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 (storeContext.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, - ], - }); + categoryResource: resource({ + params: () => ({ + contentType: storeContext.selectedContentType(), + currentPlaylist: storeContext.currentPlaylist(), + }), + loader: async ({ + params, + }): Promise => { + if (!params.currentPlaylist) { + patchState(store, { categoryError: null }); + return []; } - // Update hasMoreItems based on total count and current items - const totalLoaded = store.itvChannels().length; - patchState(store, { - hasMoreChannels: - totalLoaded < - (response.js.total_items ?? 0), - }); - } + const cachedCategories = getCategoriesByType( + store, + params.contentType + ); + if (cachedCategories.length > 0) { + patchState(store, { categoryError: null }); + return cachedCategories; + } - return newItems; - }, + try { + const response = + await executeStalkerRequest( + requestDeps, + params.currentPlaylist, + { + action: StalkerContentTypes[ + params.contentType + ].getCategoryAction, + type: params.contentType, + } + ); + + if (!Array.isArray(response?.js)) { + const invalidResponseError = new Error( + 'Invalid categories response' + ); + logger.warn( + 'Invalid categories response', + response + ); + patchState(store, { + ...buildCategoryPatch( + params.contentType, + [] + ), + categoryError: invalidResponseError, + }); + return []; + } + + const categories = prependAllCategory( + response.js + .map( + (item): StalkerCategoryItem => ({ + category_name: item.title ?? '', + category_id: String(item.id), + }) + ) + .sort((left, right) => + left.category_name.localeCompare( + right.category_name + ) + ), + translateService + ); + + patchState(store, { + ...buildCategoryPatch( + params.contentType, + categories + ), + categoryError: null, + }); + + return categories; + } catch (error) { + logger.warn('Error loading categories', { + contentType: params.contentType, + error, + }); + patchState(store, { + ...buildCategoryPatch( + params.contentType, + [] + ), + categoryError: error, + }); + return []; + } + }, + }), + getContentResource: resource({ + params: () => ({ + contentType: storeContext.selectedContentType(), + category: storeContext.selectedCategoryId(), + search: storeContext.searchPhrase(), + pageIndex: storeContext.page() + 1, + currentPlaylist: storeContext.currentPlaylist(), + availableCategoryCount: getCategoriesByType( + store, + storeContext.selectedContentType() + ).filter( + (category) => + String(category.category_id) !== '*' + ).length, + }), + loader: async ({ + params, + }): Promise => { + if (!params.category || params.category === '') { + patchState( + store, + buildEmptyContentPatch( + params.contentType, + null + ) + ); + return []; + } + + if ( + params.category === '*' && + (params.contentType === 'vod' || + params.contentType === 'series') && + params.availableCategoryCount === 0 + ) { + patchState( + store, + buildEmptyContentPatch( + params.contentType, + null + ) + ); + return []; + } + + const playlist = params.currentPlaylist; + if (!playlist?.portalUrl) { + patchState( + store, + buildEmptyContentPatch( + params.contentType, + null + ) + ); + return []; + } + + 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, + }; + + if (params.contentType === 'vod') { + queryParams['genre'] = '0'; + queryParams['category'] = categoryParam; + } else if (params.contentType === 'series') { + queryParams['category'] = categoryParam; + } else { + queryParams['category'] = categoryParam; + queryParams['genre'] = categoryParam; + } + + try { + const response = + await executeStalkerRequest( + requestDeps, + playlist, + queryParams + ); + + if (!Array.isArray(response?.js?.data)) { + const invalidResponseError = new Error( + 'Invalid response structure' + ); + logger.warn( + 'Invalid response structure', + response + ); + patchState(store, { + ...buildEmptyContentPatch( + params.contentType, + invalidResponseError + ), + }); + return []; + } + + const newItems = response.js.data.map((item) => + toStalkerContentItem( + item, + playlist.portalUrl ?? '' + ) + ); + + if (params.contentType === 'itv') { + const channels = + newItems.map(toStalkerItvChannel); + const nextChannels = + params.pageIndex === 1 + ? channels + : [ + ...store.itvChannels(), + ...channels, + ]; + + patchState(store, { + totalCount: + response.js.total_items ?? 0, + paginatedContent: newItems, + contentError: null, + itvChannels: nextChannels, + hasMoreChannels: + nextChannels.length < + (response.js.total_items ?? 0), + }); + } else { + patchState(store, { + totalCount: + response.js.total_items ?? 0, + paginatedContent: newItems, + contentError: null, + hasMoreChannels: false, + }); + } + + return newItems; + } catch (error) { + logger.warn('Error loading content', { + contentType: params.contentType, + category: params.category, + error, + }); + patchState( + store, + buildEmptyContentPatch( + params.contentType, + error + ) + ); + return []; + } + }, }), }; } ), withComputed((store) => { - const storeContext = store as unknown as StalkerContentStoreContext; + const storeContext = store as typeof store & + StalkerContentResourceStoreContract; + return { - getTotalPages: computed(() => { - return Math.ceil(store.totalCount() / storeContext.limit()); + getTotalPages: computed(() => + Math.ceil(store.totalCount() / storeContext.limit()) + ), + getSelectedCategory: computed(() => { + const categoryId = storeContext.selectedCategoryId(); + if (!categoryId) { + return { + id: 0, + category_name: 'All Items', + type: storeContext.selectedContentType(), + }; + } + + const contentType = storeContext.selectedContentType(); + const categories = getCategoriesByType(store, contentType); + + return ( + categories.find( + (category) => + String(category.category_id) === + String(categoryId) + ) || { + category_id: categoryId, + category_name: '', + type: contentType, + } + ); }), getSelectedCategoryName: computed(() => { - const type = storeContext.selectedContentType(); - const selectedCategoryId = storeContext.selectedCategoryId(); - if (!selectedCategoryId) return ''; - let categories: StalkerCategoryItem[] = []; - if (type === 'vod') { - categories = store.vodCategories(); - } else if (type === 'series') { - categories = store.seriesCategories(); - } else if (type === 'itv') { - categories = store.itvCategories(); + const selectedCategoryId = + storeContext.selectedCategoryId(); + if (!selectedCategoryId) { + return ''; } - const category = categories.find( - (cat) => - String(cat.category_id) === + + const category = getCategoriesByType( + store, + storeContext.selectedContentType() + ).find( + (item) => + String(item.category_id) === String(selectedCategoryId) ); - return category ? category.category_name : ''; + + return category?.category_name ?? ''; }), - /** category content */ - getPaginatedContent: computed(() => - storeContext.getContentResource.value() - ), + getPaginatedContent: computed(() => store.paginatedContent()), isPaginatedContentLoading: computed(() => storeContext.getContentResource.isLoading() ), - isPaginatedContentFailed: computed(() => - storeContext.getContentResource.error() - ), - /** category resource */ + isPaginatedContentFailed: computed(() => store.contentError()), getCategoryResource: computed(() => - storeContext.getCategoryResource.value() + getCategoriesByType( + store, + storeContext.selectedContentType() + ) ), isCategoryResourceLoading: computed(() => - storeContext.getCategoryResource.isLoading() - ), - isCategoryResourceFailed: computed(() => - storeContext.getCategoryResource.error() + storeContext.categoryResource.isLoading() ), + isCategoryResourceFailed: computed(() => store.categoryError()), }; }), withMethods((store) => ({ setCategories( - type: 'vod' | 'series' | 'itv', + type: StalkerContentType, 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 }); - } + patchState(store, buildCategoryPatch(type, categories)); }, resetCategories() { patchState(store, { vodCategories: [], seriesCategories: [], itvCategories: [], + categoryError: null, }); }, setItvChannels(channels: StalkerItvChannel[]) { diff --git a/libs/portal/stalker/data-access/src/lib/stores/features/with-stalker-epg.feature.ts b/libs/portal/stalker/data-access/src/lib/stores/features/with-stalker-epg.feature.ts index f0cba5cf8..b005e7f29 100644 --- a/libs/portal/stalker/data-access/src/lib/stores/features/with-stalker-epg.feature.ts +++ b/libs/portal/stalker/data-access/src/lib/stores/features/with-stalker-epg.feature.ts @@ -8,20 +8,11 @@ import { } from '@ngrx/signals'; import { createLogger } from '@iptvnator/portal/shared/util'; import { DataService } from 'services'; -import { - EpgItem, - EpgProgram, - Playlist, - STALKER_REQUEST, - StalkerPortalActions, -} from 'shared-interfaces'; +import { EpgItem, EpgProgram, StalkerPortalActions } from 'shared-interfaces'; import { normalizeStalkerEntityId } from '../../stalker-vod.utils'; import { StalkerSessionService } from '../../stalker-session.service'; - -interface EpgStoreContext { - currentPlaylist(): Playlist | undefined; - selectedItvId(): string | undefined; -} +import { StalkerEpgFeatureStoreContract } from '../stalker-store.contracts'; +import { executeStalkerRequest } from '../utils'; interface StalkerEpgEntry { id?: string | number; @@ -49,9 +40,7 @@ interface StalkerEpgResponse { js?: | StalkerEpgEntry[] | { - data?: - | StalkerEpgEntry[] - | Record; + data?: StalkerEpgEntry[] | Record; } | Record; } @@ -83,11 +72,12 @@ export function withStalkerEpg() { return signalStoreFeature( withState(initialEpgState), withComputed((store) => { - const storeContext = store as unknown as EpgStoreContext; + const storeContext = store as typeof store & + StalkerEpgFeatureStoreContract; return { selectedItvEpgPrograms: computed(() => { - const selectedId = storeContext.selectedItvId?.(); + const selectedId = storeContext.selectedItvId(); if (!selectedId) { return []; } @@ -106,26 +96,25 @@ export function withStalkerEpg() { dataService = inject(DataService), stalkerSession = inject(StalkerSessionService) ) => { - const storeContext = store as unknown as EpgStoreContext; + const storeContext = store as typeof store & + StalkerEpgFeatureStoreContract; + const requestDeps = { + dataService, + stalkerSession, + }; const requestEpg = async ( - playlist: Playlist, + playlist: NonNullable< + ReturnType< + StalkerEpgFeatureStoreContract['currentPlaylist'] + > + >, queryParams: Record ): Promise => { - if (playlist.isFullStalkerPortal) { - return stalkerSession.makeAuthenticatedRequest( - playlist, - queryParams - ); - } - - return dataService.sendIpcEvent( - STALKER_REQUEST, - { - url: playlist.portalUrl, - macAddress: playlist.macAddress, - params: queryParams, - } + return executeStalkerRequest( + requestDeps, + playlist, + queryParams ); }; @@ -212,7 +201,7 @@ export function withStalkerEpg() { period: String(periodHours), }); const selectedChannelId = - storeContext.selectedItvId?.() ?? null; + storeContext.selectedItvId() ?? null; const bulkPrograms = extractBulkEpgByChannel( response, selectedChannelId @@ -242,7 +231,9 @@ export function withStalkerEpg() { ); } -function extractShortEpgEntries(response: StalkerEpgResponse): StalkerEpgEntry[] { +function extractShortEpgEntries( + response: StalkerEpgResponse +): StalkerEpgEntry[] { if (Array.isArray(response?.js)) { return response.js; } @@ -272,11 +263,9 @@ function extractBulkEpgByChannel( const groupArrayEntries = (entries: StalkerEpgEntry[]) => { for (const entry of entries) { - const entryChannelId = - entry.ch_id ?? selectedChannelId ?? null; - const normalizedChannelId = normalizeOptionalEntityId( - entryChannelId - ); + const entryChannelId = entry.ch_id ?? selectedChannelId ?? null; + const normalizedChannelId = + normalizeOptionalEntityId(entryChannelId); if (!normalizedChannelId) { continue; } @@ -290,9 +279,7 @@ function extractBulkEpgByChannel( groupArrayEntries(raw); } else { const rawData = - raw && typeof raw === 'object' && 'data' in raw - ? raw.data - : raw; + raw && typeof raw === 'object' && 'data' in raw ? raw.data : raw; if (Array.isArray(rawData)) { groupArrayEntries(rawData); @@ -386,8 +373,7 @@ function toEpgItem( end: stop, stop, channel_id: String(item.ch_id ?? fallbackChannelId), - start_timestamp: - startTimestamp !== null ? String(startTimestamp) : '', + start_timestamp: startTimestamp !== null ? String(startTimestamp) : '', stop_timestamp: stopTimestamp !== null ? String(stopTimestamp) : '', }; } @@ -452,9 +438,7 @@ function getProgramTimestampSeconds( : normalized.replace(' ', 'T'); const parsedDate = Date.parse(candidate); - return Number.isFinite(parsedDate) - ? Math.floor(parsedDate / 1000) - : null; + return Number.isFinite(parsedDate) ? Math.floor(parsedDate / 1000) : null; } function getProgramTimestampMs( diff --git a/libs/portal/stalker/data-access/src/lib/stores/features/with-stalker-favorites.feature.ts b/libs/portal/stalker/data-access/src/lib/stores/features/with-stalker-favorites.feature.ts index 41a4d7d48..ccc8c30e0 100644 --- a/libs/portal/stalker/data-access/src/lib/stores/features/with-stalker-favorites.feature.ts +++ b/libs/portal/stalker/data-access/src/lib/stores/features/with-stalker-favorites.feature.ts @@ -3,13 +3,19 @@ import { MatSnackBar } from '@angular/material/snack-bar'; import { signalStoreFeature, withMethods } from '@ngrx/signals'; import { Store } from '@ngrx/store'; import { TranslateService } from '@ngx-translate/core'; -import { PlaylistActions } from 'm3u-state'; import { PlaylistsService } from 'services'; import { PlaylistMeta, StalkerPortalItem } from 'shared-interfaces'; +import { StalkerSelectionStoreContract } from '../stalker-store.contracts'; +import { + dispatchStalkerPlaylistMetaUpdate, + resolveStalkerCategoryId, +} from '../utils'; interface FavoritesStoreContext { currentPlaylist(): PlaylistMeta | undefined; - selectedContentType(): string; + selectedContentType(): StalkerSelectionStoreContract['selectedContentType'] extends () => infer T + ? T + : string; } type FavoritePayload = StalkerPortalItem & { @@ -17,14 +23,6 @@ type FavoritePayload = StalkerPortalItem & { id?: string | number; }; -function resolveCategoryId( - value: unknown, - fallback: string -): string { - const normalized = String(value ?? '').trim(); - return normalized || fallback; -} - /** * Favorites concern methods. */ @@ -38,14 +36,19 @@ export function withStalkerFavorites() { translate = inject(TranslateService), ngrxStore = inject(Store) ) => { - const storeContext = store as unknown as FavoritesStoreContext; + const storeContext = store as typeof store & + FavoritesStoreContext; return { addToFavorites(item: FavoritePayload, onDone?: () => void) { const portalId = storeContext.currentPlaylist()?._id; + if (!portalId) { + return; + } + playlistService .addPortalFavorite(portalId, { ...item, - category_id: resolveCategoryId( + category_id: resolveStalkerCategoryId( item.category_id, storeContext.selectedContentType() ), @@ -53,20 +56,18 @@ export function withStalkerFavorites() { id: item.stream_id ?? item.id, }) .subscribe((updatedPlaylist) => { - ngrxStore.dispatch( - PlaylistActions.updatePlaylistMeta({ - playlist: { - _id: portalId, - favorites: - updatedPlaylist?.favorites, - } as PlaylistMeta, - }) + dispatchStalkerPlaylistMetaUpdate( + ngrxStore, + portalId, + { + favorites: updatedPlaylist?.favorites, + } ); snackBar.open( translate.instant( 'PORTALS.ADDED_TO_FAVORITES' ), - null, + undefined, { duration: 1000, } @@ -79,23 +80,25 @@ export function withStalkerFavorites() { onDone?: () => void ) { const portalId = storeContext.currentPlaylist()?._id; + if (!portalId) { + return; + } + playlistService .removeFromPortalFavorites(portalId, favoriteId) .subscribe((updatedPlaylist) => { - ngrxStore.dispatch( - PlaylistActions.updatePlaylistMeta({ - playlist: { - _id: portalId, - favorites: - updatedPlaylist?.favorites, - } as PlaylistMeta, - }) + dispatchStalkerPlaylistMetaUpdate( + ngrxStore, + portalId, + { + favorites: updatedPlaylist?.favorites, + } ); snackBar.open( translate.instant( 'PORTALS.REMOVED_FROM_FAVORITES' ), - null, + undefined, { duration: 1000, } diff --git a/libs/portal/stalker/data-access/src/lib/stores/features/with-stalker-player.feature.spec.ts b/libs/portal/stalker/data-access/src/lib/stores/features/with-stalker-player.feature.spec.ts new file mode 100644 index 000000000..9ff6f1776 --- /dev/null +++ b/libs/portal/stalker/data-access/src/lib/stores/features/with-stalker-player.feature.spec.ts @@ -0,0 +1,178 @@ +import { TestBed } from '@angular/core/testing'; +import { MatSnackBar } from '@angular/material/snack-bar'; +import { signalStore, withState } from '@ngrx/signals'; +import { Store } from '@ngrx/store'; +import { TranslateService } from '@ngx-translate/core'; +import { PORTAL_PLAYER } from '@iptvnator/portal/shared/util'; +import { DataService, PlaylistsService } from 'services'; +import { of } from 'rxjs'; +import { PlaylistMeta, StalkerPortalActions } from 'shared-interfaces'; +import { StalkerSessionService } from '../../stalker-session.service'; +import { withStalkerPlayer } from './with-stalker-player.feature'; + +jest.mock('@iptvnator/portal/shared/util', () => ({ + ...jest.requireActual('@iptvnator/portal/shared/util'), + createLogger: () => ({ + debug: jest.fn(), + info: jest.fn(), + warn: jest.fn(), + error: jest.fn(), + }), +})); + +const PLAYLIST = { + _id: 'playlist-1', + title: 'Demo Stalker', + count: 0, + autoRefresh: false, + importDate: '2026-04-14T00:00:00.000Z', + portalUrl: 'http://demo.example/stalker_portal/server/load.php', + macAddress: '00:1A:79:00:00:01', + isFullStalkerPortal: false, +} as PlaylistMeta; + +const TestPlayerStore = signalStore( + withState({ + currentPlaylist: PLAYLIST, + selectedContentType: 'vod' as 'vod' | 'series' | 'itv', + selectedItem: { + id: '22', + cmd: '/media/source_22.mpg', + has_files: true, + title: 'Original Title', + category_id: 'vod', + }, + }), + withStalkerPlayer() +); + +describe('withStalkerPlayer', () => { + let store: InstanceType; + let dataService: { + sendIpcEvent: jest.Mock, unknown[]>; + }; + let playlistService: { + addPortalRecentlyViewed: jest.Mock; + }; + let ngrxStore: { + dispatch: jest.Mock; + }; + + beforeEach(() => { + dataService = { + sendIpcEvent: jest.fn(), + }; + playlistService = { + addPortalRecentlyViewed: jest.fn(() => + of({ recentlyViewed: [{ id: '22', title: 'Movie Title' }] }) + ), + }; + ngrxStore = { + dispatch: jest.fn(), + }; + + TestBed.configureTestingModule({ + providers: [ + TestPlayerStore, + { provide: DataService, useValue: dataService }, + { + provide: PlaylistsService, + useValue: playlistService, + }, + { + provide: PORTAL_PLAYER, + useValue: { + openResolvedPlayback: jest.fn(), + }, + }, + { + provide: StalkerSessionService, + useValue: { + getCachedToken: jest.fn(), + makeAuthenticatedRequest: jest.fn(), + }, + }, + { + provide: MatSnackBar, + useValue: { + open: jest.fn(), + }, + }, + { + provide: TranslateService, + useValue: { + instant: jest.fn((key: string) => key), + }, + }, + { + provide: Store, + useValue: ngrxStore, + }, + ], + }); + + store = TestBed.inject(TestPlayerStore); + }); + + it('falls back to file ids for VOD playback and persists recently viewed metadata', async () => { + dataService.sendIpcEvent + .mockResolvedValueOnce({ + js: { + data: [{ id: 77 }], + }, + }) + .mockResolvedValueOnce({ + js: { + cmd: 'ffmpeg http://cdn.example/video_77.mpg', + }, + }); + + const playback = await store.resolveVodPlayback( + undefined, + 'Movie Title', + 'thumb.jpg' + ); + + expect(dataService.sendIpcEvent).toHaveBeenNthCalledWith( + 1, + expect.anything(), + expect.objectContaining({ + params: expect.objectContaining({ + action: StalkerPortalActions.GetOrderedList, + type: 'vod', + movie_id: '22', + p: '1', + }), + }) + ); + expect(dataService.sendIpcEvent).toHaveBeenNthCalledWith( + 2, + expect.anything(), + expect.objectContaining({ + params: expect.objectContaining({ + action: StalkerPortalActions.CreateLink, + cmd: '/media/file_77.mpg', + type: 'vod', + }), + }) + ); + expect(playlistService.addPortalRecentlyViewed).toHaveBeenCalledWith( + PLAYLIST._id, + expect.objectContaining({ + id: '22', + title: 'Movie Title', + category_id: 'vod', + cover: 'thumb.jpg', + added_at: expect.any(Number), + }) + ); + expect(ngrxStore.dispatch).toHaveBeenCalled(); + expect(playback.streamUrl).toBe('http://cdn.example/video_77.mpg'); + expect(playback.contentInfo).toEqual({ + playlistId: PLAYLIST._id, + contentXtreamId: 22, + contentType: 'vod', + seriesXtreamId: undefined, + }); + }); +}); diff --git a/libs/portal/stalker/data-access/src/lib/stores/features/with-stalker-player.feature.ts b/libs/portal/stalker/data-access/src/lib/stores/features/with-stalker-player.feature.ts index 6d243d33f..b84401841 100644 --- a/libs/portal/stalker/data-access/src/lib/stores/features/with-stalker-player.feature.ts +++ b/libs/portal/stalker/data-access/src/lib/stores/features/with-stalker-player.feature.ts @@ -3,19 +3,13 @@ import { MatSnackBar } from '@angular/material/snack-bar'; import { signalStoreFeature, withMethods } from '@ngrx/signals'; import { Store } from '@ngrx/store'; import { TranslateService } from '@ngx-translate/core'; -import { PlaylistActions } from 'm3u-state'; -import { PORTAL_PLAYER } from '@iptvnator/portal/shared/util'; +import { PORTAL_PLAYER, createLogger } from '@iptvnator/portal/shared/util'; import { DataService, PlaylistsService } from 'services'; import { - Playlist, PlaylistMeta, ResolvedPortalPlayback, - STALKER_REQUEST, - StalkerPortalActions, StalkerPortalItem, } from 'shared-interfaces'; -import { createLogger } from '@iptvnator/portal/shared/util'; -import { StalkerContentTypes } from '../../stalker-content-types'; import { buildStalkerExternalPlaybackHeaders, getStalkerPortalOrigin, @@ -27,56 +21,30 @@ import { normalizeStalkerEntityId, normalizeStalkerEntityIdAsNumber, } from '../../stalker-vod.utils'; +import { + StalkerPlayerFeatureStoreContract, + StalkerRecentlyViewedItem, +} from '../stalker-store.contracts'; +import { + buildStalkerRecentlyViewedPayload, + dispatchStalkerPlaylistMetaUpdate, + fetchStalkerExpireDate, + fetchStalkerMovieFileId, + fetchStalkerPlaybackLink, + shouldResolveMovieFileId, +} from '../utils'; -type StalkerContentType = 'itv' | 'vod' | 'series'; - -interface StalkerPlayableItem extends StalkerPortalItem { +type StalkerPlayableItem = StalkerPortalItem & { cmd?: string; has_files?: unknown; -} - -interface StalkerPlayerStoreLike { - selectedContentType: () => StalkerContentType; - currentPlaylist: () => Playlist | null; - selectedItem: () => StalkerPlayableItem | null; - addToRecentlyViewed?: (item: Record) => void; -} - -interface StalkerResponse { - js?: { - error?: string; - cmd?: string; - data?: Array<{ id?: string | number }>; - account_info?: { - expire_date?: string | number; - }; - }; -} +}; /** * Playback/link/player concern methods. */ export function withStalkerPlayer() { const logger = createLogger('withStalkerPlayer'); - const resolveCategoryId = (value: unknown, fallback: string): string => { - const normalized = String(value ?? '').trim(); - return normalized || fallback; - }; - const getSeriesRecentMetadata = ( - selectedContentType: StalkerContentType - ): { - category_id?: 'series'; - is_series?: true; - } => { - if (selectedContentType !== 'series') { - return {}; - } - return { - category_id: 'series', - is_series: true, - }; - }; return signalStoreFeature( withMethods( ( @@ -89,179 +57,89 @@ export function withStalkerPlayer() { translate = inject(TranslateService), ngrxStore = inject(Store) ) => { - const storeState = store as unknown as StalkerPlayerStoreLike; - const fetchLinkToPlayInternal = async ( - portalUrl: string, - macAddress: string, - cmd: string, - series?: number, - forcedContentType?: StalkerContentType - ) => { - const normalizeCmdValue = (value: string): string => { - const trimmed = String(value ?? '').trim(); - if (!trimmed) return ''; - - // Some portals prepend transport wrappers like: - // "ffmpeg http://...", "ffrt http://...", etc. - const splitAt = trimmed.indexOf(' '); - if (splitAt > 0) { - const candidate = trimmed.slice(splitAt + 1).trim(); - if ( - candidate.startsWith('http://') || - candidate.startsWith('https://') || - candidate.startsWith('/') || - candidate.startsWith('?') - ) { - return candidate; - } - } - - return trimmed; - }; - - const selectedContentType = - forcedContentType ?? storeState.selectedContentType(); - const type = series ? 'vod' : 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[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 = storeState.currentPlaylist(); - let response: StalkerResponse; - 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 = normalizeCmdValue(response.js.cmd as string); - - // If cmd is empty, the content is not available - if (!url) { - throw new Error('nothing_to_play'); - } - - // 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('?')) { - const normalizedCmd = normalizeCmdValue(cmd); - // 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 - if ( - normalizedCmd.startsWith('http://') || - normalizedCmd.startsWith('https://') - ) { - url = `${normalizedCmd}${url}`; - } else { - url = `${portalUrlObj.origin}${basePath}${normalizedCmd}${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 storeState = store as typeof store & + StalkerPlayerFeatureStoreContract; + const requestDeps = { + dataService, + stalkerSession, }; - const fetchMovieFileIdInternal = async ( - movieId: string - ): Promise => { + const createRequestPlaylist = ( + portalUrl: string, + macAddress: string + ): PlaylistMeta => { const playlist = storeState.currentPlaylist(); - if (!playlist) return null; - const queryParams = { - action: StalkerPortalActions.GetOrderedList, - type: 'vod', - movie_id: movieId, - p: '1', + return { + ...playlist, + _id: playlist?._id ?? 'stalker-player', + title: playlist?.title ?? 'Stalker Portal', + count: playlist?.count ?? 0, + autoRefresh: playlist?.autoRefresh ?? false, + importDate: playlist?.importDate ?? '', + portalUrl, + macAddress, }; + }; - let response: StalkerResponse; - if (playlist.isFullStalkerPortal) { - response = - await stalkerSession.makeAuthenticatedRequest( - playlist, - queryParams + const persistRecentlyViewed = ( + playlistId: string, + recentItem: StalkerRecentlyViewedItem + ): void => { + playlistService + .addPortalRecentlyViewed(playlistId, recentItem) + .subscribe((updatedPlaylist) => { + dispatchStalkerPlaylistMetaUpdate( + ngrxStore, + playlistId, + { + recentlyViewed: + updatedPlaylist?.recentlyViewed, + } ); - } else { - response = await dataService.sendIpcEvent( - STALKER_REQUEST, - { - url: playlist.portalUrl, - macAddress: playlist.macAddress, - params: queryParams, - } - ); + }); + }; + + const buildRecentlyViewedItem = ( + item: StalkerPlayableItem, + cmd?: string, + cover?: string, + title?: string + ): StalkerRecentlyViewedItem => + buildStalkerRecentlyViewedPayload( + { + ...item, + cmd, + cover, + title, + }, + storeState.selectedContentType() + ); + + const recordRecentlyViewed = ( + item: StalkerPlayableItem | null | undefined, + cmd?: string, + cover?: string, + title?: string + ): void => { + const playlistId = storeState.currentPlaylist()?._id; + if (!item || !playlistId) { + return; } - // Extract id from the first data item - if (response?.js?.data?.[0]?.id) { - const fileId = response.js.data[0].id; - return String(fileId); + const recentItem = buildRecentlyViewedItem( + item, + cmd, + cover, + title + ); + + if (typeof storeState.addToRecentlyViewed === 'function') { + storeState.addToRecentlyViewed(recentItem); + return; } - return null; + persistRecentlyViewed(playlistId, recentItem); }; const resolveVodPlaybackInternal = async ( @@ -272,57 +150,45 @@ export function withStalkerPlayer() { episodeId?: number, startTime?: number ): Promise => { - const item = storeState.selectedItem(); + const item = storeState.selectedItem() as + | StalkerPlayableItem + | null + | undefined; 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) + const playlist = storeState.currentPlaylist(); + if (!playlist?.portalUrl || !playlist.macAddress) { + throw new Error('nothing_to_play'); + } + + if (shouldResolveMovieFileId(item, cmdToUse)) { + const itemId = normalizeStalkerEntityId(item?.id); + const fileId = await fetchStalkerMovieFileId( + requestDeps, + playlist, + itemId ); if (fileId) { cmdToUse = `/media/file_${fileId}.mpg`; } } - const playlist = storeState.currentPlaylist(); - if (!playlist) { - throw new Error('nothing_to_play'); - } - - const streamUrl = await fetchLinkToPlayInternal( - playlist.portalUrl, - playlist.macAddress, - cmdToUse, - episodeNum + const streamUrl = await fetchStalkerPlaybackLink( + requestDeps, + { + playlist, + selectedContentType: + storeState.selectedContentType(), + cmd: cmdToUse, + series: episodeNum, + } ); - if (typeof storeState.addToRecentlyViewed === 'function') { - storeState.addToRecentlyViewed({ - ...item, - id: item?.id, - cmd: cmd, - cover: thumbnail, - title, - }); - } else { - addToRecentlyViewedInternal( - item, - cmd, - thumbnail, - title - ); - } + recordRecentlyViewed(item, cmd, thumbnail, title); const isEpisode = episodeNum !== undefined || episodeId !== undefined; @@ -339,7 +205,6 @@ export function withStalkerPlayer() { origin: playlist.origin, contentInfo: { playlistId: playlist._id, - // For episodes, use episodeId if provided, otherwise fall back to item.id contentXtreamId: isEpisode && episodeId ? episodeId @@ -356,17 +221,25 @@ export function withStalkerPlayer() { item: StalkerPlayableItem ): Promise => { const playlist = storeState.currentPlaylist(); - if (!playlist || !item?.cmd) { + if ( + !playlist?.portalUrl || + !playlist.macAddress || + !item.cmd + ) { throw new Error('nothing_to_play'); } - const streamUrl = await fetchLinkToPlayInternal( - playlist.portalUrl, - playlist.macAddress, - item.cmd, - undefined, - 'itv' + const streamUrl = await fetchStalkerPlaybackLink( + requestDeps, + { + playlist, + selectedContentType: + storeState.selectedContentType(), + cmd: item.cmd, + forcedContentType: 'itv', + } ); + const token = stalkerSession.getCachedToken(playlist._id); const headers = buildStalkerExternalPlaybackHeaders( playlist, @@ -379,14 +252,12 @@ export function withStalkerPlayer() { ); const portalOrigin = getStalkerPortalOrigin(playlist); - if (typeof storeState.addToRecentlyViewed === 'function') { - storeState.addToRecentlyViewed({ - ...item, - id: item.id, - cover: item.logo ?? item.cover, - title: item.o_name || item.name || item.title, - }); - } + recordRecentlyViewed( + item, + item.cmd, + item.logo ?? item.cover, + item.o_name || item.name || item.title + ); return { streamUrl, @@ -406,47 +277,6 @@ export function withStalkerPlayer() { }; }; - const addToRecentlyViewedInternal = ( - item: StalkerPlayableItem, - cmd?: string, - cover?: string, - title?: string - ) => { - const playlistId = storeState.currentPlaylist()?._id; - if (!playlistId) return; - const selectedContentType = - storeState.selectedContentType(); - const recentlyViewedItem: { - id: string; - title: string; - } & Record = { - ...item, - id: normalizeStalkerEntityId(item?.id), - cmd, - cover, - title, - category_id: resolveCategoryId( - item.category_id, - selectedContentType - ), - ...getSeriesRecentMetadata(selectedContentType), - added_at: Date.now(), - }; - playlistService - .addPortalRecentlyViewed(playlistId, recentlyViewedItem) - .subscribe((updatedPlaylist) => { - ngrxStore.dispatch( - PlaylistActions.updatePlaylistMeta({ - playlist: { - _id: playlistId, - recentlyViewed: - updatedPlaylist?.recentlyViewed, - } as PlaylistMeta, - }) - ); - }); - }; - return { async fetchLinkToPlay( portalUrl: string, @@ -454,85 +284,46 @@ export function withStalkerPlayer() { cmd: string, series?: number ) { - return fetchLinkToPlayInternal( - portalUrl, - macAddress, + return fetchStalkerPlaybackLink(requestDeps, { + playlist: createRequestPlaylist( + portalUrl, + macAddress + ), + selectedContentType: + storeState.selectedContentType(), cmd, - series - ); + series, + }); }, async getExpireDate() { - const params = { - type: 'account_info', - action: 'get_main_info', - JsHttpRequest: '1-xml', - }; + const playlist = storeState.currentPlaylist(); + if (!playlist) { + return 'Unknown'; + } try { - // Use makeAuthenticatedRequest for automatic retry on auth failure - const playlist = storeState.currentPlaylist(); - let response: StalkerResponse; - if (!playlist) { - return 'Unknown'; - } - 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; - const numericExpireDate = Number(expireDate); - - // Convert timestamp to readable date if it's a unix timestamp - if ( - expireDate && - !Number.isNaN(numericExpireDate) - ) { - const date = new Date( - numericExpireDate * 1000 - ); // Convert seconds to milliseconds - return date.toLocaleDateString(); - } - - return expireDate || 'Unknown'; - } - - return 'Unknown'; + return await fetchStalkerExpireDate( + requestDeps, + playlist + ); } 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); + const playlist = storeState.currentPlaylist(); + if (!playlist) { + return null; + } + + return fetchStalkerMovieFileId( + requestDeps, + playlist, + movieId + ); }, async resolveVodPlayback( cmd?: string, @@ -556,15 +347,6 @@ export function withStalkerPlayer() { ): Promise { return resolveItvPlaybackInternal(item); }, - /** - * 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, @@ -590,14 +372,16 @@ export function withStalkerPlayer() { } catch (error) { logger.error('Failed to get playback URL', error); const errorMessage = - error?.message === 'nothing_to_play' + error instanceof Error && + error.message === 'nothing_to_play' ? translate.instant( 'PORTALS.CONTENT_NOT_AVAILABLE' ) : translate.instant( 'PORTALS.PLAYBACK_ERROR' ); - snackBar.open(errorMessage, null, { + + snackBar.open(errorMessage, undefined, { duration: 3000, }); } diff --git a/libs/portal/stalker/data-access/src/lib/stores/features/with-stalker-portal.feature.ts b/libs/portal/stalker/data-access/src/lib/stores/features/with-stalker-portal.feature.ts index f8af913c1..37dbeda20 100644 --- a/libs/portal/stalker/data-access/src/lib/stores/features/with-stalker-portal.feature.ts +++ b/libs/portal/stalker/data-access/src/lib/stores/features/with-stalker-portal.feature.ts @@ -2,20 +2,34 @@ import { inject } from '@angular/core'; import { patchState, signalStoreFeature, - withProps, withMethods, + withProps, withState, } from '@ngrx/signals'; -import { Playlist, PlaylistMeta, STALKER_REQUEST } from 'shared-interfaces'; +import { PlaylistMeta, STALKER_REQUEST } from 'shared-interfaces'; import { createLogger } from '@iptvnator/portal/shared/util'; import { DataService } from 'services'; import { StalkerSessionService } from '../../stalker-session.service'; +import { toStalkerSessionPlaylist } from '../utils'; + +type StalkerPortalWindow = Window & { + electron?: { + dbCreatePlaylist: (playlist: { + id: string; + name: string; + macAddress: string; + url: string; + type: 'stalker'; + }) => Promise; + dbGetPlaylist: (playlistId: string) => Promise; + }; +}; /** * Portal/session state and methods. */ export interface StalkerPortalState { - currentPlaylist: PlaylistMeta; + currentPlaylist: PlaylistMeta | undefined; } const initialPortalState: StalkerPortalState = { @@ -42,14 +56,13 @@ export function withStalkerPortal() { // Get token if it's a full stalker portal let token: string | undefined; let serialNumber: string | undefined; - if ((playlist as Playlist).isFullStalkerPortal) { + if (playlist.isFullStalkerPortal) { try { const result = await stalkerSession.ensureToken( - playlist as Playlist + toStalkerSessionPlaylist(playlist) ); token = result.token ?? undefined; - serialNumber = (playlist as Playlist) - .stalkerSerialNumber; + serialNumber = playlist.stalkerSerialNumber; } catch (error) { logger.error('Failed to get stalker token', error); } @@ -73,7 +86,9 @@ export function withStalkerPortal() { ) => ({ async setCurrentPlaylist(playlist: PlaylistMeta | undefined) { stalkerSession.setActiveWatchdogPlaylist( - (playlist as Playlist) || undefined + playlist + ? toStalkerSessionPlaylist(playlist) + : undefined ); patchState(store, { currentPlaylist: playlist }); @@ -87,13 +102,19 @@ export function withStalkerPortal() { playlist.portalUrl ) { try { + const electronApi = (window as StalkerPortalWindow) + .electron; + if (!electronApi) { + return; + } + const playlistId = String(playlist._id); // Check if playlist exists in SQLite const existing = - await window.electron.dbGetPlaylist(playlistId); + await electronApi.dbGetPlaylist(playlistId); if (!existing) { // Create playlist in SQLite - await window.electron.dbCreatePlaylist({ + await electronApi.dbCreatePlaylist({ id: playlistId, name: playlist.title || '', macAddress: playlist.macAddress || '', diff --git a/libs/portal/stalker/data-access/src/lib/stores/features/with-stalker-recent.feature.ts b/libs/portal/stalker/data-access/src/lib/stores/features/with-stalker-recent.feature.ts index eb775c054..777c7ee9c 100644 --- a/libs/portal/stalker/data-access/src/lib/stores/features/with-stalker-recent.feature.ts +++ b/libs/portal/stalker/data-access/src/lib/stores/features/with-stalker-recent.feature.ts @@ -1,14 +1,18 @@ import { inject } from '@angular/core'; import { signalStoreFeature, withMethods } from '@ngrx/signals'; import { Store } from '@ngrx/store'; -import { PlaylistActions } from 'm3u-state'; import { PlaylistsService } from 'services'; import { PlaylistMeta, StalkerPortalItem } from 'shared-interfaces'; import { createLogger } from '@iptvnator/portal/shared/util'; +import { StalkerContentType } from '../stalker-store.contracts'; +import { + buildStalkerRecentlyViewedPayload, + dispatchStalkerPlaylistMetaUpdate, +} from '../utils'; interface RecentStoreContext { currentPlaylist(): PlaylistMeta | undefined; - selectedContentType(): string; + selectedContentType(): StalkerContentType; } type RecentlyViewedPayload = StalkerPortalItem & { @@ -16,25 +20,6 @@ type RecentlyViewedPayload = StalkerPortalItem & { title?: string; }; -function resolveCategoryId(value: unknown, fallback: string): string { - const normalized = String(value ?? '').trim(); - return normalized || fallback; -} - -function getSeriesRecentMetadata(selectedContentType: string): { - category_id?: 'series'; - is_series?: true; -} { - if (selectedContentType !== 'series') { - return {}; - } - - return { - category_id: 'series', - is_series: true, - }; -} - /** * Recently-viewed concern methods. */ @@ -47,35 +32,31 @@ export function withStalkerRecent() { playlistService = inject(PlaylistsService), ngrxStore = inject(Store) ) => { - const storeContext = store as unknown as RecentStoreContext; + const storeContext = store as typeof store & RecentStoreContext; return { addToRecentlyViewed(item: RecentlyViewedPayload) { const portalId = storeContext.currentPlaylist()?._id; + if (!portalId) { + return; + } + const selectedContentType = storeContext.selectedContentType(); - const recentItem = { - ...item, - category_id: resolveCategoryId( - item.category_id, - selectedContentType - ), - ...getSeriesRecentMetadata(selectedContentType), - added_at: Date.now(), - id: item.id ?? item.stream_id ?? '', - title: item.title ?? item.name ?? item.o_name ?? '', - }; + const recentItem = buildStalkerRecentlyViewedPayload( + item, + selectedContentType + ); playlistService .addPortalRecentlyViewed(portalId, recentItem) .subscribe((updatedPlaylist) => { - ngrxStore.dispatch( - PlaylistActions.updatePlaylistMeta({ - playlist: { - _id: portalId, - recentlyViewed: - updatedPlaylist?.recentlyViewed, - } as PlaylistMeta, - }) + dispatchStalkerPlaylistMetaUpdate( + ngrxStore, + portalId, + { + recentlyViewed: + updatedPlaylist?.recentlyViewed, + } ); }); }, @@ -84,18 +65,21 @@ export function withStalkerRecent() { onComplete?: () => void ) { const portalId = storeContext.currentPlaylist()?._id; + if (!portalId) { + return; + } + playlistService .removeFromPortalRecentlyViewed(portalId, itemId) .subscribe({ next: (updatedPlaylist) => { - ngrxStore.dispatch( - PlaylistActions.updatePlaylistMeta({ - playlist: { - _id: portalId, - recentlyViewed: - updatedPlaylist?.recentlyViewed, - } as PlaylistMeta, - }) + dispatchStalkerPlaylistMetaUpdate( + ngrxStore, + portalId, + { + recentlyViewed: + updatedPlaylist?.recentlyViewed, + } ); onComplete?.(); }, diff --git a/libs/portal/stalker/data-access/src/lib/stores/features/with-stalker-selection.feature.spec.ts b/libs/portal/stalker/data-access/src/lib/stores/features/with-stalker-selection.feature.spec.ts new file mode 100644 index 000000000..48ac3e9ad --- /dev/null +++ b/libs/portal/stalker/data-access/src/lib/stores/features/with-stalker-selection.feature.spec.ts @@ -0,0 +1,36 @@ +import { TestBed } from '@angular/core/testing'; +import { signalStore } from '@ngrx/signals'; +import { withStalkerSelection } from './with-stalker-selection.feature'; + +const TestSelectionStore = signalStore(withStalkerSelection()); + +describe('withStalkerSelection', () => { + let store: InstanceType; + + beforeEach(() => { + TestBed.configureTestingModule({ + providers: [TestSelectionStore], + }); + + store = TestBed.inject(TestSelectionStore); + }); + + it('resets paging when the selected category changes', () => { + store.setPage(3); + store.setSelectedCategory('series-7'); + + expect(store.selectedCategoryId()).toBe('series-7'); + expect(store.page()).toBe(0); + }); + + it('synchronizes entity ids when the selected item changes', () => { + store.setSelectedItem({ + id: '55', + name: 'Example', + }); + + expect(store.selectedVodId()).toBe('55'); + expect(store.selectedSerialId()).toBe('55'); + expect(store.selectedItvId()).toBe('55'); + }); +}); diff --git a/libs/portal/stalker/data-access/src/lib/stores/features/with-stalker-selection.feature.ts b/libs/portal/stalker/data-access/src/lib/stores/features/with-stalker-selection.feature.ts index 5272ef698..21857cea2 100644 --- a/libs/portal/stalker/data-access/src/lib/stores/features/with-stalker-selection.feature.ts +++ b/libs/portal/stalker/data-access/src/lib/stores/features/with-stalker-selection.feature.ts @@ -1,19 +1,12 @@ -import { computed } from '@angular/core'; import { patchState, signalStoreFeature, withMethods, withState, } from '@ngrx/signals'; -import { StalkerCategoryItem, StalkerVodSource } from '../../models'; +import { StalkerVodSource } from '../../models'; import { normalizeStalkerEntityId } from '../../stalker-vod.utils'; -interface SelectionStoreContext { - vodCategories(): StalkerCategoryItem[]; - seriesCategories(): StalkerCategoryItem[]; - itvCategories(): StalkerCategoryItem[]; -} - /** * Selection/pagination/search feature state. */ @@ -101,49 +94,6 @@ export function withStalkerSelection() { selectedItem: undefined, }); }, - /** getters */ - getSelectedCategory: computed(() => { - const storeContext = store as unknown as SelectionStoreContext; - const categoryId = store.selectedCategoryId(); - if (!categoryId) { - return { - id: 0, - category_name: 'All Items', - type: store.selectedContentType(), - }; - } - - // Get categories based on content type - const contentType = store.selectedContentType(); - let categories: StalkerCategoryItem[] = []; - const readCategories = ( - getterName: - | 'vodCategories' - | 'seriesCategories' - | 'itvCategories' - ) => - typeof storeContext[getterName] === 'function' - ? storeContext[getterName]() - : []; - if (contentType === 'vod') { - categories = readCategories('vodCategories'); - } else if (contentType === 'series') { - categories = readCategories('seriesCategories'); - } else if (contentType === 'itv') { - categories = readCategories('itvCategories'); - } - - return ( - categories.find( - (category) => - String(category.category_id) === String(categoryId) - ) || { - category_id: categoryId, - category_name: '', - type: contentType, - } - ); - }), })) ); } diff --git a/libs/portal/stalker/data-access/src/lib/stores/features/with-stalker-series.feature.ts b/libs/portal/stalker/data-access/src/lib/stores/features/with-stalker-series.feature.ts index 5a2cb8bd6..1d9989a60 100644 --- a/libs/portal/stalker/data-access/src/lib/stores/features/with-stalker-series.feature.ts +++ b/libs/portal/stalker/data-access/src/lib/stores/features/with-stalker-series.feature.ts @@ -7,22 +7,23 @@ import { withProps, withState, } from '@ngrx/signals'; +import { createLogger } from '@iptvnator/portal/shared/util'; import { DataService } from 'services'; -import { Playlist, STALKER_REQUEST, StalkerPortalActions } from 'shared-interfaces'; +import { StalkerPortalActions } from 'shared-interfaces'; import { StalkerSeason, - StalkerVodSource, StalkerVodSeriesEpisode, StalkerVodSeriesSeason, } from '../../models'; -import { StalkerSessionService } from '../../stalker-session.service'; import { StalkerContentTypes } from '../../stalker-content-types'; +import { StalkerSessionService } from '../../stalker-session.service'; +import { StalkerSeriesFeatureStoreContract } from '../stalker-store.contracts'; import { + executeStalkerRequest, sortByNumericValue, sortEpisodesByNumber, sortVodSeriesSeasonsByNumber, } from '../utils'; -import { createLogger } from '@iptvnator/portal/shared/util'; /** * Regular-series and VOD-series feature state. @@ -40,11 +41,6 @@ const initialSeriesState: StalkerSeriesState = { selectedVodSeriesSeasonId: undefined, }; -interface ResourceState { - value(): T; - isLoading(): boolean; -} - interface StalkerSeriesResponse { js?: | { @@ -53,14 +49,7 @@ interface StalkerSeriesResponse { | T[]; } -interface StalkerSeriesStoreContext { - selectedSerialId(): string | undefined; - currentPlaylist(): Playlist | undefined; - selectedItem(): StalkerVodSource | null | undefined; - selectedContentType(): 'vod' | 'series' | 'itv'; - serialSeasonsResource: ResourceState; - vodSeriesSeasonsResource: ResourceState; -} +type StalkerSeriesStoreContext = StalkerSeriesFeatureStoreContract; function extractSeriesItems(response: StalkerSeriesResponse): T[] { if (Array.isArray(response?.js)) { @@ -74,13 +63,18 @@ function extractSeriesItems(response: StalkerSeriesResponse): T[] { return []; } +function toMovieId(value: unknown): string { + const raw = String(value ?? '').trim(); + if (!raw) { + return ''; + } + + return raw.includes(':') ? raw.split(':')[0] : raw; +} + 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( @@ -89,151 +83,133 @@ export function withStalkerSeries() { dataService = inject(DataService), stalkerSession = inject(StalkerSessionService) ) => { - const storeContext = - store as unknown as StalkerSeriesStoreContext; + const storeContext = store as typeof store & + StalkerSeriesStoreContext; + const requestDeps = { + dataService, + stalkerSession, + }; return { serialSeasonsResource: resource({ - params: () => ({ - itemId: storeContext.selectedSerialId(), - }), - loader: async ({ params }): Promise => { - const movieId = toMovieId(params.itemId); - // Guard: ensure currentPlaylist and itemId are available - if (!storeContext.currentPlaylist() || !movieId) { - return []; - } - const playlist = storeContext.currentPlaylist() as Playlist; - const queryParams = { - action: StalkerContentTypes.series.getContentAction, - type: 'series', - movie_id: movieId, - }; - - // Use makeAuthenticatedRequest for automatic retry on auth failure - let response: StalkerSeriesResponse; - 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, - } - ); - } - - const seasons = extractSeriesItems(response); - - if (seasons.length === 0) { - logger.warn('Invalid seasons response', response); - return []; - } - - return sortByNumericValue(seasons); - }, - }), - /** - * 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: storeContext.selectedItem(), - selectedContentType: storeContext.selectedContentType(), - }), - loader: async ({ - params, - }): Promise => { - const item = params.selectedItem; - logger.debug('vodSeriesSeasonsResource loader called', { - item, - isSeries: item?.is_series, + params: () => ({ + itemId: storeContext.selectedSerialId(), currentPlaylist: storeContext.currentPlaylist(), - }); + }), + loader: async ({ + params, + }): Promise => { + const playlist = params.currentPlaylist; + const movieId = toMovieId(params.itemId); - // Only fetch if item is a VOD series (has is_series flag) - if ( - !storeContext.currentPlaylist() || - params.selectedContentType !== 'vod' || - !item || - !item.is_series - ) { - logger.debug( - 'vodSeriesSeasonsResource skipped - conditions not met' - ); - return []; - } + if (!playlist || !movieId) { + return []; + } - const playlist = storeContext.currentPlaylist() as Playlist; - const queryParams = { - action: StalkerPortalActions.GetOrderedList, - type: 'vod', - movie_id: item.id, - p: '1', - }; + const response = await executeStalkerRequest< + StalkerSeriesResponse + >(requestDeps, playlist, { + action: StalkerContentTypes.series + .getContentAction, + type: 'series', + movie_id: movieId, + }); - let response: StalkerSeriesResponse; - if (playlist.isFullStalkerPortal) { - response = await stalkerSession.makeAuthenticatedRequest>( - playlist, - queryParams + const seasons = extractSeriesItems(response); + + if (seasons.length === 0) { + logger.warn( + 'Invalid seasons response', + response ); - } else { - response = await dataService.sendIpcEvent>( - STALKER_REQUEST, + return []; + } + + return sortByNumericValue(seasons); + }, + }), + vodSeriesSeasonsResource: resource({ + params: () => ({ + currentPlaylist: storeContext.currentPlaylist(), + selectedItem: storeContext.selectedItem(), + selectedContentType: + storeContext.selectedContentType(), + }), + loader: async ({ + params, + }): Promise => { + const { currentPlaylist, selectedItem } = params; + + logger.debug( + 'vodSeriesSeasonsResource loader called', { - url: playlist.portalUrl, - macAddress: playlist.macAddress, - params: queryParams, + item: selectedItem, + isSeries: selectedItem?.is_series, + currentPlaylist, } ); - } - const seasonItems = extractSeriesItems(response); + if ( + !currentPlaylist || + params.selectedContentType !== 'vod' || + !selectedItem || + selectedItem.id === undefined || + selectedItem.id === null || + !selectedItem.is_series + ) { + logger.debug( + 'vodSeriesSeasonsResource skipped - conditions not met' + ); + return []; + } + + const response = await executeStalkerRequest< + StalkerSeriesResponse + >(requestDeps, currentPlaylist, { + action: StalkerPortalActions.GetOrderedList, + type: 'vod', + movie_id: selectedItem.id, + p: '1', + }); + + const seasonItems = extractSeriesItems(response); + if (seasonItems.length === 0) { + logger.debug( + 'vodSeriesSeasonsResource - no response data' + ); + return []; + } - if (seasonItems.length === 0) { logger.debug( - 'vodSeriesSeasonsResource - no response data' + 'vodSeriesSeasonsResource response data', + seasonItems ); - return []; - } - logger.debug('vodSeriesSeasonsResource response data', seasonItems); + const seasons = seasonItems.filter( + (item) => item.is_season === true + ); + logger.debug( + 'vodSeriesSeasonsResource filtered seasons', + seasons + ); - // Filter for season items (is_season: true) - const seasons = seasonItems.filter( - (item: StalkerVodSeriesSeason) => - item.is_season === true - ); - logger.debug( - 'vodSeriesSeasonsResource filtered seasons', - seasons - ); - return sortVodSeriesSeasonsByNumber(seasons); - }, + return sortVodSeriesSeasonsByNumber(seasons); + }, }), }; } ), withComputed((store) => { - const storeContext = store as unknown as StalkerSeriesStoreContext; + const storeContext = store as typeof store & + StalkerSeriesStoreContext; + return { - /** serials */ getSerialSeasonsResource: computed(() => storeContext.serialSeasonsResource.value() ), isSerialSeasonsLoading: computed(() => storeContext.serialSeasonsResource.isLoading() ), - /** VOD series (Ministra plugin is_series=1) */ getVodSeriesSeasonsResource: computed(() => storeContext.vodSeriesSeasonsResource.value() ), @@ -248,66 +224,44 @@ export function withStalkerSeries() { dataService = inject(DataService), stalkerSession = inject(StalkerSessionService) ) => { - const storeContext = - store as unknown as Pick< - StalkerSeriesStoreContext, - 'currentPlaylist' - >; + const storeContext = store as typeof store & + Pick; + const requestDeps = { + dataService, + stalkerSession, + }; 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 = storeContext.currentPlaylist() as Playlist; - if (!playlist) return []; + const playlist = storeContext.currentPlaylist(); + if (!playlist) { + return []; + } - const queryParams = { + const response = await executeStalkerRequest< + StalkerSeriesResponse + >(requestDeps, playlist, { action: StalkerPortalActions.GetOrderedList, type: 'vod', movie_id: videoId, season_id: seasonId, p: '1', - }; - - let response: StalkerSeriesResponse; - 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 episodeItems = extractSeriesItems(response); - if (episodeItems.length === 0) { return []; } - // Filter for episode items (is_episode: true) and sort by series_number const episodes = sortEpisodesByNumber( episodeItems.filter( - (item: StalkerVodSeriesEpisode) => - item.is_episode === true + (item) => item.is_episode === true ) ); - // Store episodes and selected season patchState(store, { vodSeriesEpisodes: episodes, selectedVodSeriesSeasonId: seasonId, diff --git a/libs/portal/stalker/data-access/src/lib/stores/stalker-store.contracts.ts b/libs/portal/stalker/data-access/src/lib/stores/stalker-store.contracts.ts new file mode 100644 index 000000000..5db70d433 --- /dev/null +++ b/libs/portal/stalker/data-access/src/lib/stores/stalker-store.contracts.ts @@ -0,0 +1,81 @@ +import { PlaylistMeta, StalkerPortalItem } from 'shared-interfaces'; +import { + StalkerCategoryItem, + StalkerContentItem, + StalkerSeason, + StalkerVodSeriesSeason, + StalkerVodSource, +} from '../models'; + +export type StalkerContentType = 'vod' | 'series' | 'itv'; + +export interface ResourceState { + value(): T; + isLoading(): boolean; + error(): unknown; +} + +export interface StalkerPortalStoreContract { + currentPlaylist(): PlaylistMeta | undefined; +} + +export interface StalkerSelectionStoreContract { + selectedContentType(): StalkerContentType; + selectedCategoryId(): string | null | undefined; + selectedVodId(): string | undefined; + selectedSerialId(): string | undefined; + selectedItvId(): string | undefined; + limit(): number; + page(): number; + searchPhrase(): string; + selectedItem(): StalkerVodSource | null | undefined; +} + +export interface StalkerCategorySliceContract { + vodCategories(): StalkerCategoryItem[]; + seriesCategories(): StalkerCategoryItem[]; + itvCategories(): StalkerCategoryItem[]; +} + +export interface StalkerContentFeatureStoreContract + extends + StalkerPortalStoreContract, + StalkerSelectionStoreContract, + StalkerCategorySliceContract { + getContentResource: ResourceState; + categoryResource: ResourceState; +} + +export interface StalkerSeriesFeatureStoreContract + extends + StalkerPortalStoreContract, + Pick< + StalkerSelectionStoreContract, + 'selectedContentType' | 'selectedItem' | 'selectedSerialId' + > { + serialSeasonsResource: ResourceState; + vodSeriesSeasonsResource: ResourceState; +} + +export interface StalkerRecentCallbackStoreContract { + addToRecentlyViewed?: (item: StalkerRecentlyViewedItem) => void; +} + +export type StalkerRecentlyViewedItem = StalkerPortalItem & { + id: string | number; + title: string; +}; + +export interface StalkerPlayerFeatureStoreContract + extends + StalkerPortalStoreContract, + Pick< + StalkerSelectionStoreContract, + 'selectedContentType' | 'selectedItem' + >, + StalkerRecentCallbackStoreContract {} + +export interface StalkerEpgFeatureStoreContract + extends + StalkerPortalStoreContract, + Pick {} diff --git a/libs/portal/stalker/data-access/src/lib/stores/utils/index.ts b/libs/portal/stalker/data-access/src/lib/stores/utils/index.ts index dde2e91fa..e6dd2d051 100644 --- a/libs/portal/stalker/data-access/src/lib/stores/utils/index.ts +++ b/libs/portal/stalker/data-access/src/lib/stores/utils/index.ts @@ -1,3 +1,5 @@ +export * from './stalker-collection-persistence.utils'; +export * from './stalker-player-request.utils'; +export * from './stalker-request.utils'; export * from './stalker-content-mappers'; export * from './stalker-sorting'; - diff --git a/libs/portal/stalker/data-access/src/lib/stores/utils/stalker-collection-persistence.utils.ts b/libs/portal/stalker/data-access/src/lib/stores/utils/stalker-collection-persistence.utils.ts new file mode 100644 index 000000000..959a77d0e --- /dev/null +++ b/libs/portal/stalker/data-access/src/lib/stores/utils/stalker-collection-persistence.utils.ts @@ -0,0 +1,76 @@ +import { Store } from '@ngrx/store'; +import { PlaylistActions } from 'm3u-state'; +import { PlaylistMeta, StalkerPortalItem } from 'shared-interfaces'; +import { normalizeStalkerEntityId } from '../../stalker-vod.utils'; +import { + StalkerContentType, + StalkerRecentlyViewedItem, +} from '../stalker-store.contracts'; + +export type StalkerCollectionPayload = StalkerPortalItem & { + cmd?: string; + cover?: string; + id?: string | number; + stream_id?: string | number; + title?: string; +}; + +export function resolveStalkerCategoryId( + value: unknown, + fallback: string +): string { + const normalized = String(value ?? '').trim(); + return normalized || fallback; +} + +export function getStalkerSeriesRecentMetadata( + selectedContentType: StalkerContentType +): { + category_id?: 'series'; + is_series?: true; +} { + if (selectedContentType !== 'series') { + return {}; + } + + return { + category_id: 'series', + is_series: true, + }; +} + +export function buildStalkerRecentlyViewedPayload( + item: StalkerCollectionPayload, + selectedContentType: StalkerContentType +): StalkerRecentlyViewedItem { + return { + ...item, + category_id: resolveStalkerCategoryId( + item.category_id, + selectedContentType + ), + ...getStalkerSeriesRecentMetadata(selectedContentType), + added_at: Date.now(), + id: normalizeStalkerEntityId(item.id ?? item.stream_id ?? ''), + title: item.title ?? item.name ?? item.o_name ?? '', + }; +} + +export function dispatchStalkerPlaylistMetaUpdate( + ngrxStore: Store, + playlistId: string | undefined, + changes: Pick +): void { + if (!playlistId) { + return; + } + + ngrxStore.dispatch( + PlaylistActions.updatePlaylistMeta({ + playlist: { + _id: playlistId, + ...changes, + } as PlaylistMeta, + }) + ); +} diff --git a/libs/portal/stalker/data-access/src/lib/stores/utils/stalker-player-request.utils.spec.ts b/libs/portal/stalker/data-access/src/lib/stores/utils/stalker-player-request.utils.spec.ts new file mode 100644 index 000000000..138ac6c5a --- /dev/null +++ b/libs/portal/stalker/data-access/src/lib/stores/utils/stalker-player-request.utils.spec.ts @@ -0,0 +1,136 @@ +import { PlaylistMeta, StalkerPortalActions } from 'shared-interfaces'; +import { StalkerSessionService } from '../../stalker-session.service'; +import { + fetchStalkerExpireDate, + fetchStalkerMovieFileId, + fetchStalkerPlaybackLink, + shouldResolveMovieFileId, +} from './stalker-player-request.utils'; + +const PLAYLIST = { + _id: 'playlist-1', + title: 'Demo Stalker', + count: 0, + autoRefresh: false, + importDate: '2026-04-14T00:00:00.000Z', + portalUrl: 'http://demo.example/stalker_portal/server/load.php', + macAddress: '00:1A:79:00:00:01', + isFullStalkerPortal: false, +} as PlaylistMeta; + +describe('stalker-player-request.utils', () => { + let dataService: { + sendIpcEvent: jest.Mock, unknown[]>; + }; + let stalkerSession: Pick; + + beforeEach(() => { + dataService = { + sendIpcEvent: jest.fn(), + }; + stalkerSession = { + makeAuthenticatedRequest: jest.fn(), + }; + }); + + it('builds create_link requests and normalizes relative portal URLs', async () => { + dataService.sendIpcEvent.mockResolvedValue({ + js: { cmd: '/media/video_77.mpg' }, + }); + + const streamUrl = await fetchStalkerPlaybackLink( + { + dataService: dataService as never, + stalkerSession: stalkerSession as StalkerSessionService, + }, + { + playlist: PLAYLIST, + selectedContentType: 'series', + cmd: '/media/source.mpg', + series: 3, + } + ); + + expect(dataService.sendIpcEvent).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + url: PLAYLIST.portalUrl, + macAddress: PLAYLIST.macAddress, + params: expect.objectContaining({ + action: StalkerPortalActions.CreateLink, + cmd: '/media/source.mpg', + type: 'vod', + series: '3', + download: '0', + disable_ad: '0', + JsHttpRequest: '1-xml', + }), + }) + ); + expect(streamUrl).toBe( + 'http://demo.example/stalker_portal/media/video_77.mpg' + ); + }); + + it('detects file-id fallback candidates and reads the movie file id', async () => { + expect( + shouldResolveMovieFileId( + { has_files: true }, + '/media/source_42.mpg' + ) + ).toBe(true); + expect( + shouldResolveMovieFileId({ has_files: true }, '/media/file_42.mpg') + ).toBe(false); + + dataService.sendIpcEvent.mockResolvedValue({ + js: { + data: [{ id: 77 }], + }, + }); + + await expect( + fetchStalkerMovieFileId( + { + dataService: dataService as never, + stalkerSession: stalkerSession as StalkerSessionService, + }, + PLAYLIST, + '22' + ) + ).resolves.toBe('77'); + + expect(dataService.sendIpcEvent).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + params: expect.objectContaining({ + action: StalkerPortalActions.GetOrderedList, + type: 'vod', + movie_id: '22', + p: '1', + }), + }) + ); + }); + + it('returns a localized expire date string from account info', async () => { + const expireDate = 1_713_139_200; + dataService.sendIpcEvent.mockResolvedValue({ + js: { + account_info: { + expire_date: expireDate, + }, + }, + }); + + await expect( + fetchStalkerExpireDate( + { + dataService: dataService as never, + stalkerSession: stalkerSession as StalkerSessionService, + }, + PLAYLIST + ) + ).resolves.toBe(new Date(expireDate * 1000).toLocaleDateString()); + }); +}); diff --git a/libs/portal/stalker/data-access/src/lib/stores/utils/stalker-player-request.utils.ts b/libs/portal/stalker/data-access/src/lib/stores/utils/stalker-player-request.utils.ts new file mode 100644 index 000000000..cc2c0f110 --- /dev/null +++ b/libs/portal/stalker/data-access/src/lib/stores/utils/stalker-player-request.utils.ts @@ -0,0 +1,204 @@ +import { DataService } from 'services'; +import { + PlaylistMeta, + StalkerPortalActions, + StalkerPortalItem, +} from 'shared-interfaces'; +import { StalkerSessionService } from '../../stalker-session.service'; +import { StalkerContentTypes } from '../../stalker-content-types'; +import { StalkerContentType } from '../stalker-store.contracts'; +import { executeStalkerRequest } from './stalker-request.utils'; + +export interface StalkerPlayerResponse { + js?: { + error?: string; + cmd?: string; + data?: Array<{ id?: string | number }>; + account_info?: { + expire_date?: string | number; + }; + }; +} + +export interface StalkerPlayerRequestDeps { + dataService: DataService; + stalkerSession: StalkerSessionService; +} + +export interface StalkerPlayableItemLike extends StalkerPortalItem { + cmd?: string; + has_files?: unknown; +} + +export function normalizeStalkerPlaybackCommand(value: string): string { + const trimmed = String(value ?? '').trim(); + if (!trimmed) { + return ''; + } + + const splitAt = trimmed.indexOf(' '); + if (splitAt > 0) { + const candidate = trimmed.slice(splitAt + 1).trim(); + if ( + candidate.startsWith('http://') || + candidate.startsWith('https://') || + candidate.startsWith('/') || + candidate.startsWith('?') + ) { + return candidate; + } + } + + return trimmed; +} + +export function resolveStalkerPlaybackUrl( + portalUrl: string, + originalCmd: string, + responseCmd: string +): string { + let url = normalizeStalkerPlaybackCommand(responseCmd); + if (!url) { + return ''; + } + + if (url.startsWith('http://') || url.startsWith('https://')) { + return url; + } + + try { + const portalUrlObj = new URL(portalUrl); + const pathParts = portalUrlObj.pathname.split('/'); + let basePath = ''; + + for (let index = 0; index < pathParts.length; index += 1) { + if ( + pathParts[index] === 'stalker_portal' || + pathParts[index] === 'c' || + pathParts[index] === 'portal' + ) { + basePath = '/' + pathParts.slice(1, index + 1).join('/'); + break; + } + } + + if (url.startsWith('?')) { + const normalizedCmd = normalizeStalkerPlaybackCommand(originalCmd); + if ( + normalizedCmd.startsWith('http://') || + normalizedCmd.startsWith('https://') + ) { + return `${normalizedCmd}${url}`; + } + + return `${portalUrlObj.origin}${basePath}${normalizedCmd}${url}`; + } + + if (url.startsWith('/')) { + return `${portalUrlObj.origin}${basePath}${url}`; + } + } catch { + return url; + } + + return url; +} + +export function shouldResolveMovieFileId( + item: Pick | null | undefined, + cmd: string +): boolean { + return ( + item?.has_files !== undefined && + !cmd.includes('://') && + cmd.includes('/media/') && + !cmd.includes('/media/file_') + ); +} + +export async function fetchStalkerPlaybackLink( + deps: StalkerPlayerRequestDeps, + options: { + playlist: PlaylistMeta; + selectedContentType: StalkerContentType; + cmd: string; + series?: number; + forcedContentType?: StalkerContentType; + } +): Promise { + const contentType = + options.forcedContentType ?? options.selectedContentType; + const response = await executeStalkerRequest( + deps, + options.playlist, + { + action: StalkerContentTypes[contentType].getLink, + cmd: options.cmd, + type: options.series ? 'vod' : contentType, + disable_ad: '0', + download: '0', + JsHttpRequest: '1-xml', + ...(options.series ? { series: String(options.series) } : {}), + } + ); + + if (response.js?.error) { + throw new Error(response.js.error); + } + + const streamUrl = resolveStalkerPlaybackUrl( + options.playlist.portalUrl ?? '', + options.cmd, + response.js?.cmd ?? '' + ); + + if (!streamUrl) { + throw new Error('nothing_to_play'); + } + + return streamUrl; +} + +export async function fetchStalkerMovieFileId( + deps: StalkerPlayerRequestDeps, + playlist: PlaylistMeta, + movieId: string +): Promise { + const response = await executeStalkerRequest( + deps, + playlist, + { + action: StalkerPortalActions.GetOrderedList, + type: 'vod', + movie_id: movieId, + p: '1', + } + ); + + const fileId = response?.js?.data?.[0]?.id; + return fileId == null ? null : String(fileId); +} + +export async function fetchStalkerExpireDate( + deps: StalkerPlayerRequestDeps, + playlist: PlaylistMeta +): Promise { + const response = await executeStalkerRequest( + deps, + playlist, + { + type: 'account_info', + action: 'get_main_info', + JsHttpRequest: '1-xml', + } + ); + + const expireDate = response?.js?.account_info?.expire_date; + const numericExpireDate = Number(expireDate); + + if (expireDate && !Number.isNaN(numericExpireDate)) { + return new Date(numericExpireDate * 1000).toLocaleDateString(); + } + + return expireDate ? String(expireDate) : 'Unknown'; +} diff --git a/libs/portal/stalker/data-access/src/lib/stores/utils/stalker-request.utils.ts b/libs/portal/stalker/data-access/src/lib/stores/utils/stalker-request.utils.ts new file mode 100644 index 000000000..a9edff924 --- /dev/null +++ b/libs/portal/stalker/data-access/src/lib/stores/utils/stalker-request.utils.ts @@ -0,0 +1,34 @@ +import { DataService } from 'services'; +import { Playlist, PlaylistMeta, STALKER_REQUEST } from 'shared-interfaces'; +import { StalkerSessionService } from '../../stalker-session.service'; + +export interface StalkerRequestDeps { + dataService: DataService; + stalkerSession: StalkerSessionService; +} + +export function toStalkerSessionPlaylist(playlist: PlaylistMeta): Playlist { + return { + lastUsage: '', + ...playlist, + } as Playlist; +} + +export async function executeStalkerRequest( + deps: StalkerRequestDeps, + playlist: PlaylistMeta, + params: Record +): Promise { + if (playlist.isFullStalkerPortal) { + return deps.stalkerSession.makeAuthenticatedRequest( + toStalkerSessionPlaylist(playlist), + params + ); + } + + return deps.dataService.sendIpcEvent(STALKER_REQUEST, { + url: playlist.portalUrl, + macAddress: playlist.macAddress, + params, + }); +}