mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-08 17:06:15 -08:00
refactor: add xtream feature stores
This commit is contained in:
1 parent
a37192c2ad
commit
97a678a98d
8 files changed
+1150
No files matched your search
@@ -0,0 +1,6 @@
|
||||
export * from './with-portal.feature';
|
||||
export * from './with-content.feature';
|
||||
export * from './with-selection.feature';
|
||||
export * from './with-search.feature';
|
||||
export * from './with-epg.feature';
|
||||
export * from './with-player.feature';
|
||||
@@ -0,0 +1,317 @@
|
||||
import { computed, inject } from '@angular/core';
|
||||
import {
|
||||
patchState,
|
||||
signalStoreFeature,
|
||||
withComputed,
|
||||
withMethods,
|
||||
withState,
|
||||
} from '@ngrx/signals';
|
||||
import {
|
||||
XtreamCategory,
|
||||
XtreamLiveStream,
|
||||
XtreamSerieItem,
|
||||
XtreamVodStream,
|
||||
} from 'shared-interfaces';
|
||||
import { ContentType } from '../../xtream-state';
|
||||
import { XtreamCredentials } from '../../services/xtream-api.service';
|
||||
import {
|
||||
IXtreamDataSource,
|
||||
XTREAM_DATA_SOURCE,
|
||||
XtreamCategoryFromDb,
|
||||
} from '../../data-sources/xtream-data-source.interface';
|
||||
|
||||
/**
|
||||
* Content state for managing categories and streams
|
||||
*/
|
||||
export interface ContentState {
|
||||
liveCategories: (XtreamCategory | XtreamCategoryFromDb)[];
|
||||
vodCategories: (XtreamCategory | XtreamCategoryFromDb)[];
|
||||
serialCategories: (XtreamCategory | XtreamCategoryFromDb)[];
|
||||
liveStreams: XtreamLiveStream[];
|
||||
vodStreams: XtreamVodStream[];
|
||||
serialStreams: XtreamSerieItem[];
|
||||
isLoadingCategories: boolean;
|
||||
isLoadingContent: boolean;
|
||||
isImporting: boolean;
|
||||
importCount: number;
|
||||
itemsToImport: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Initial content state
|
||||
*/
|
||||
const initialContentState: ContentState = {
|
||||
liveCategories: [],
|
||||
vodCategories: [],
|
||||
serialCategories: [],
|
||||
liveStreams: [],
|
||||
vodStreams: [],
|
||||
serialStreams: [],
|
||||
isLoadingCategories: false,
|
||||
isLoadingContent: false,
|
||||
isImporting: false,
|
||||
importCount: 0,
|
||||
itemsToImport: 0,
|
||||
};
|
||||
|
||||
/**
|
||||
* Content feature store for managing Xtream categories and streams.
|
||||
* Handles:
|
||||
* - Fetching categories (live, vod, series)
|
||||
* - Fetching content/streams (live, movies, series)
|
||||
* - Import progress tracking
|
||||
*/
|
||||
export function withContent() {
|
||||
return signalStoreFeature(
|
||||
withState<ContentState>(initialContentState),
|
||||
|
||||
withComputed((store) => ({
|
||||
/**
|
||||
* Get categories by content type
|
||||
*/
|
||||
getCategoriesByType: computed(() => {
|
||||
return (type: ContentType) => {
|
||||
switch (type) {
|
||||
case 'live':
|
||||
return store.liveCategories();
|
||||
case 'vod':
|
||||
return store.vodCategories();
|
||||
case 'series':
|
||||
return store.serialCategories();
|
||||
}
|
||||
};
|
||||
}),
|
||||
|
||||
/**
|
||||
* Get streams/content by type
|
||||
*/
|
||||
getContentByType: computed(() => {
|
||||
return (type: ContentType) => {
|
||||
switch (type) {
|
||||
case 'live':
|
||||
return store.liveStreams();
|
||||
case 'vod':
|
||||
return store.vodStreams();
|
||||
case 'series':
|
||||
return store.serialStreams();
|
||||
}
|
||||
};
|
||||
}),
|
||||
|
||||
/**
|
||||
* Get import count
|
||||
*/
|
||||
getImportCount: computed(() => store.importCount()),
|
||||
|
||||
/**
|
||||
* Check if content is being imported
|
||||
*/
|
||||
isContentImporting: computed(() => store.isImporting()),
|
||||
})),
|
||||
|
||||
withMethods((store) => {
|
||||
const dataSource = inject(XTREAM_DATA_SOURCE);
|
||||
|
||||
/**
|
||||
* Helper to get credentials from parent store
|
||||
* This will be provided by the parent store's currentPlaylist
|
||||
*/
|
||||
const getCredentialsFromStore = (): {
|
||||
playlistId: string;
|
||||
credentials: XtreamCredentials;
|
||||
} | null => {
|
||||
// Access parent store state (currentPlaylist is from withPortal)
|
||||
const storeAny = store as any;
|
||||
const playlist = storeAny.currentPlaylist?.();
|
||||
const playlistId = storeAny.playlistId?.();
|
||||
|
||||
if (!playlist || !playlistId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
playlistId,
|
||||
credentials: {
|
||||
serverUrl: playlist.serverUrl,
|
||||
username: playlist.username,
|
||||
password: playlist.password,
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
return {
|
||||
/**
|
||||
* Fetch all categories in parallel
|
||||
*/
|
||||
async fetchAllCategories(): Promise<void> {
|
||||
const ctx = getCredentialsFromStore();
|
||||
if (!ctx) return;
|
||||
|
||||
patchState(store, { isLoadingCategories: true });
|
||||
|
||||
try {
|
||||
const [live, vod, series] = await Promise.all([
|
||||
dataSource.getCategories(
|
||||
ctx.playlistId,
|
||||
ctx.credentials,
|
||||
'live'
|
||||
),
|
||||
dataSource.getCategories(
|
||||
ctx.playlistId,
|
||||
ctx.credentials,
|
||||
'vod'
|
||||
),
|
||||
dataSource.getCategories(
|
||||
ctx.playlistId,
|
||||
ctx.credentials,
|
||||
'series'
|
||||
),
|
||||
]);
|
||||
|
||||
patchState(store, {
|
||||
liveCategories: live,
|
||||
vodCategories: vod,
|
||||
serialCategories: series,
|
||||
isLoadingCategories: false,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error fetching categories:', error);
|
||||
patchState(store, { isLoadingCategories: false });
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Fetch all content/streams in parallel
|
||||
*/
|
||||
async fetchAllContent(): Promise<void> {
|
||||
const ctx = getCredentialsFromStore();
|
||||
if (!ctx) return;
|
||||
|
||||
patchState(store, { isLoadingContent: true });
|
||||
|
||||
try {
|
||||
const [live, vod, series] = await Promise.all([
|
||||
dataSource.getContent(
|
||||
ctx.playlistId,
|
||||
ctx.credentials,
|
||||
'live'
|
||||
),
|
||||
dataSource.getContent(
|
||||
ctx.playlistId,
|
||||
ctx.credentials,
|
||||
'movie'
|
||||
),
|
||||
dataSource.getContent(
|
||||
ctx.playlistId,
|
||||
ctx.credentials,
|
||||
'series'
|
||||
),
|
||||
]);
|
||||
|
||||
patchState(store, {
|
||||
liveStreams: live as XtreamLiveStream[],
|
||||
vodStreams: vod as XtreamVodStream[],
|
||||
serialStreams: series as XtreamSerieItem[],
|
||||
isLoadingContent: false,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error fetching content:', error);
|
||||
patchState(store, { isLoadingContent: false });
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Initialize content (fetch categories and content)
|
||||
*/
|
||||
async initializeContent(): Promise<void> {
|
||||
const ctx = getCredentialsFromStore();
|
||||
if (!ctx) return;
|
||||
|
||||
patchState(store, { isImporting: true, importCount: 0 });
|
||||
|
||||
try {
|
||||
// Fetch categories first
|
||||
await this.fetchAllCategories();
|
||||
|
||||
// Then fetch content
|
||||
await this.fetchAllContent();
|
||||
|
||||
// Restore user data if needed
|
||||
const restoreKey = `xtream-restore-${ctx.playlistId}`;
|
||||
const restoreData = localStorage.getItem(restoreKey);
|
||||
if (restoreData) {
|
||||
try {
|
||||
const { favoritedXtreamIds, recentlyViewedXtreamIds } =
|
||||
JSON.parse(restoreData);
|
||||
await dataSource.restoreUserData(
|
||||
ctx.playlistId,
|
||||
favoritedXtreamIds,
|
||||
recentlyViewedXtreamIds
|
||||
);
|
||||
localStorage.removeItem(restoreKey);
|
||||
} catch (err) {
|
||||
console.error('Error restoring user data:', err);
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
patchState(store, { isImporting: false });
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Reload categories from database (after visibility changes)
|
||||
*/
|
||||
async reloadCategories(): Promise<void> {
|
||||
const ctx = getCredentialsFromStore();
|
||||
if (!ctx) return;
|
||||
|
||||
try {
|
||||
const [live, vod, series] = await Promise.all([
|
||||
dataSource.getCategories(
|
||||
ctx.playlistId,
|
||||
ctx.credentials,
|
||||
'live'
|
||||
),
|
||||
dataSource.getCategories(
|
||||
ctx.playlistId,
|
||||
ctx.credentials,
|
||||
'vod'
|
||||
),
|
||||
dataSource.getCategories(
|
||||
ctx.playlistId,
|
||||
ctx.credentials,
|
||||
'series'
|
||||
),
|
||||
]);
|
||||
|
||||
patchState(store, {
|
||||
liveCategories: live,
|
||||
vodCategories: vod,
|
||||
serialCategories: series,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error reloading categories:', error);
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Update import progress
|
||||
*/
|
||||
setImportProgress(count: number, total?: number): void {
|
||||
const updates: Partial<ContentState> = { importCount: count };
|
||||
if (total !== undefined) {
|
||||
updates.itemsToImport = total;
|
||||
}
|
||||
patchState(store, updates);
|
||||
},
|
||||
|
||||
/**
|
||||
* Reset content state
|
||||
*/
|
||||
resetContent(): void {
|
||||
patchState(store, initialContentState);
|
||||
},
|
||||
};
|
||||
})
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
import { inject } from '@angular/core';
|
||||
import { patchState, signalStoreFeature, withMethods, withState } from '@ngrx/signals';
|
||||
import { EpgItem } from 'shared-interfaces';
|
||||
import { XtreamApiService, XtreamCredentials } from '../../services/xtream-api.service';
|
||||
|
||||
/**
|
||||
* EPG state for managing Electronic Program Guide data
|
||||
*/
|
||||
export interface EpgState {
|
||||
epgItems: EpgItem[];
|
||||
isLoadingEpg: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Initial EPG state
|
||||
*/
|
||||
const initialEpgState: EpgState = {
|
||||
epgItems: [],
|
||||
isLoadingEpg: false,
|
||||
};
|
||||
|
||||
/**
|
||||
* EPG feature store for managing Electronic Program Guide data.
|
||||
* Handles:
|
||||
* - Loading EPG for selected stream
|
||||
* - Loading channel EPG for preview
|
||||
*/
|
||||
export function withEpg() {
|
||||
return signalStoreFeature(
|
||||
withState<EpgState>(initialEpgState),
|
||||
|
||||
withMethods((store) => {
|
||||
const apiService = inject(XtreamApiService);
|
||||
|
||||
/**
|
||||
* Helper to get credentials from parent store
|
||||
*/
|
||||
const getCredentialsFromStore = (): XtreamCredentials | null => {
|
||||
const storeAny = store as any;
|
||||
const playlist = storeAny.currentPlaylist?.();
|
||||
|
||||
if (!playlist) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
serverUrl: playlist.serverUrl,
|
||||
username: playlist.username,
|
||||
password: playlist.password,
|
||||
};
|
||||
};
|
||||
|
||||
return {
|
||||
/**
|
||||
* Load EPG for the currently selected item
|
||||
*/
|
||||
async loadEpg(): Promise<EpgItem[]> {
|
||||
const credentials = getCredentialsFromStore();
|
||||
if (!credentials) {
|
||||
patchState(store, { epgItems: [] });
|
||||
return [];
|
||||
}
|
||||
|
||||
// Access selected item from parent store (from withSelection)
|
||||
const storeAny = store as any;
|
||||
const selectedItem = storeAny.selectedItem?.();
|
||||
|
||||
if (!selectedItem?.xtream_id) {
|
||||
patchState(store, { epgItems: [] });
|
||||
return [];
|
||||
}
|
||||
|
||||
patchState(store, { isLoadingEpg: true });
|
||||
|
||||
try {
|
||||
const epgItems = await apiService.getShortEpg(
|
||||
credentials,
|
||||
selectedItem.xtream_id,
|
||||
10
|
||||
);
|
||||
|
||||
patchState(store, {
|
||||
epgItems,
|
||||
isLoadingEpg: false,
|
||||
});
|
||||
|
||||
return epgItems;
|
||||
} catch (error) {
|
||||
console.error('Error loading EPG:', error);
|
||||
patchState(store, {
|
||||
epgItems: [],
|
||||
isLoadingEpg: false,
|
||||
});
|
||||
return [];
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Load EPG for a specific channel (for preview)
|
||||
*/
|
||||
async loadChannelEpg(streamId: number): Promise<EpgItem[]> {
|
||||
const credentials = getCredentialsFromStore();
|
||||
if (!credentials) {
|
||||
return [];
|
||||
}
|
||||
|
||||
try {
|
||||
return await apiService.getShortEpg(credentials, streamId, 1);
|
||||
} catch (error) {
|
||||
console.error('Error loading channel EPG:', error);
|
||||
return [];
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Clear EPG data
|
||||
*/
|
||||
clearEpg(): void {
|
||||
patchState(store, initialEpgState);
|
||||
},
|
||||
};
|
||||
})
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
import { inject } from '@angular/core';
|
||||
import { patchState, signalStoreFeature, withMethods, withState } from '@ngrx/signals';
|
||||
import { XtreamSerieEpisode, XtreamVodDetails } from 'shared-interfaces';
|
||||
import { PlayerService } from '../../../services/player.service';
|
||||
import { XtreamUrlService } from '../../services/xtream-url.service';
|
||||
import { XtreamCredentials } from '../../services/xtream-api.service';
|
||||
|
||||
/**
|
||||
* Player state for managing stream URLs and player settings
|
||||
*/
|
||||
export interface PlayerState {
|
||||
streamUrl: string | null;
|
||||
hideExternalInfoDialog: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Initial player state
|
||||
*/
|
||||
const initialPlayerState: PlayerState = {
|
||||
streamUrl: null,
|
||||
hideExternalInfoDialog:
|
||||
localStorage.getItem('hideExternalInfoDialog') === 'true',
|
||||
};
|
||||
|
||||
/**
|
||||
* Player feature store for managing stream playback.
|
||||
* Handles:
|
||||
* - Stream URL construction
|
||||
* - External player integration
|
||||
* - Player settings
|
||||
*/
|
||||
export function withPlayer() {
|
||||
return signalStoreFeature(
|
||||
withState<PlayerState>(initialPlayerState),
|
||||
|
||||
withMethods((store) => {
|
||||
const urlService = inject(XtreamUrlService);
|
||||
const playerService = inject(PlayerService);
|
||||
|
||||
/**
|
||||
* Helper to get credentials from parent store
|
||||
*/
|
||||
const getCredentialsFromStore = (): XtreamCredentials | null => {
|
||||
const storeAny = store as any;
|
||||
const playlist = storeAny.currentPlaylist?.();
|
||||
|
||||
if (!playlist) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
serverUrl: playlist.serverUrl,
|
||||
username: playlist.username,
|
||||
password: playlist.password,
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Helper to get playlist with headers from parent store
|
||||
*/
|
||||
const getPlaylistFromStore = () => {
|
||||
const storeAny = store as any;
|
||||
return storeAny.currentPlaylist?.();
|
||||
};
|
||||
|
||||
return {
|
||||
/**
|
||||
* Construct and return live stream URL
|
||||
*/
|
||||
constructStreamUrl(item: any): string {
|
||||
const credentials = getCredentialsFromStore();
|
||||
if (!credentials) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const streamUrl = urlService.constructLiveUrl(
|
||||
credentials,
|
||||
item.xtream_id
|
||||
);
|
||||
|
||||
// Set selected item in parent store and load EPG
|
||||
const storeAny = store as any;
|
||||
if (storeAny.setSelectedItem) {
|
||||
storeAny.setSelectedItem(item);
|
||||
}
|
||||
if (storeAny.loadEpg) {
|
||||
storeAny.loadEpg();
|
||||
}
|
||||
|
||||
patchState(store, { streamUrl });
|
||||
return streamUrl;
|
||||
},
|
||||
|
||||
/**
|
||||
* Construct and return VOD stream URL
|
||||
*/
|
||||
constructVodStreamUrl(vodItem: XtreamVodDetails): string {
|
||||
const credentials = getCredentialsFromStore();
|
||||
if (!credentials) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const streamUrl = urlService.constructVodUrl(credentials, vodItem);
|
||||
patchState(store, { streamUrl });
|
||||
return streamUrl;
|
||||
},
|
||||
|
||||
/**
|
||||
* Construct and return series episode stream URL
|
||||
*/
|
||||
constructEpisodeStreamUrl(episode: XtreamSerieEpisode): string {
|
||||
const credentials = getCredentialsFromStore();
|
||||
if (!credentials) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const streamUrl = urlService.constructEpisodeUrl(
|
||||
credentials,
|
||||
episode
|
||||
);
|
||||
patchState(store, { streamUrl });
|
||||
return streamUrl;
|
||||
},
|
||||
|
||||
/**
|
||||
* Open external player with stream
|
||||
*/
|
||||
openPlayer(
|
||||
streamUrl: string,
|
||||
title: string,
|
||||
thumbnail: string | null = null
|
||||
): void {
|
||||
const playlist = getPlaylistFromStore();
|
||||
const storeAny = store as any;
|
||||
const contentType = storeAny.selectedContentType?.() || 'vod';
|
||||
|
||||
playerService.openPlayer(
|
||||
streamUrl,
|
||||
title,
|
||||
thumbnail,
|
||||
localStorage.getItem('hideExternalInfoDialog') === 'true',
|
||||
contentType === 'live',
|
||||
playlist?.userAgent,
|
||||
playlist?.referrer,
|
||||
playlist?.origin
|
||||
);
|
||||
},
|
||||
|
||||
/**
|
||||
* Set hide external info dialog preference
|
||||
*/
|
||||
setHideExternalInfoDialog(hide: boolean): void {
|
||||
localStorage.setItem('hideExternalInfoDialog', String(hide));
|
||||
patchState(store, { hideExternalInfoDialog: hide });
|
||||
},
|
||||
|
||||
/**
|
||||
* Reset player state
|
||||
*/
|
||||
resetPlayer(): void {
|
||||
patchState(store, {
|
||||
streamUrl: null,
|
||||
hideExternalInfoDialog:
|
||||
localStorage.getItem('hideExternalInfoDialog') === 'true',
|
||||
});
|
||||
},
|
||||
};
|
||||
})
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
import { inject } from '@angular/core';
|
||||
import { patchState, signalStoreFeature, withMethods, withState } from '@ngrx/signals';
|
||||
import { Store } from '@ngrx/store';
|
||||
import { selectActivePlaylist } from 'm3u-state';
|
||||
import { Playlist } from 'shared-interfaces';
|
||||
import { PortalStatusType } from '../../xtream-state';
|
||||
import { XtreamApiService, XtreamCredentials } from '../../services/xtream-api.service';
|
||||
import {
|
||||
IXtreamDataSource,
|
||||
XTREAM_DATA_SOURCE,
|
||||
XtreamPlaylistData,
|
||||
} from '../../data-sources/xtream-data-source.interface';
|
||||
|
||||
/**
|
||||
* Portal state for managing playlist and portal status
|
||||
*/
|
||||
export interface PortalState {
|
||||
playlistId: string | null;
|
||||
currentPlaylist: XtreamPlaylistData | null;
|
||||
portalStatus: PortalStatusType;
|
||||
}
|
||||
|
||||
/**
|
||||
* Initial portal state
|
||||
*/
|
||||
const initialPortalState: PortalState = {
|
||||
playlistId: null,
|
||||
currentPlaylist: null,
|
||||
portalStatus: 'unavailable',
|
||||
};
|
||||
|
||||
/**
|
||||
* Portal feature store for managing the current Xtream playlist and status.
|
||||
* Handles:
|
||||
* - Setting/clearing playlist ID
|
||||
* - Fetching playlist details
|
||||
* - Checking portal status (active, inactive, expired, unavailable)
|
||||
*/
|
||||
export function withPortal() {
|
||||
return signalStoreFeature(
|
||||
withState<PortalState>(initialPortalState),
|
||||
|
||||
withMethods((store) => {
|
||||
const apiService = inject(XtreamApiService);
|
||||
const dataSource = inject(XTREAM_DATA_SOURCE);
|
||||
const ngrxStore = inject(Store);
|
||||
|
||||
return {
|
||||
/**
|
||||
* Set the current playlist ID
|
||||
*/
|
||||
setPlaylistId(playlistId: string): void {
|
||||
patchState(store, { playlistId });
|
||||
},
|
||||
|
||||
/**
|
||||
* Fetch playlist details from data source
|
||||
*/
|
||||
async fetchPlaylist(): Promise<void> {
|
||||
const playlistId = store.playlistId();
|
||||
if (!playlistId) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const playlist = await dataSource.getPlaylist(playlistId);
|
||||
|
||||
if (playlist) {
|
||||
patchState(store, { currentPlaylist: playlist });
|
||||
} else {
|
||||
// Fallback to NgRx store for M3U playlists that might not be in DB yet
|
||||
const activePlaylist = ngrxStore.selectSignal(
|
||||
selectActivePlaylist
|
||||
)() as Playlist;
|
||||
|
||||
if (activePlaylist) {
|
||||
const newPlaylist: XtreamPlaylistData = {
|
||||
id: activePlaylist._id,
|
||||
name: activePlaylist.title,
|
||||
serverUrl: activePlaylist.serverUrl,
|
||||
username: activePlaylist.username,
|
||||
password: activePlaylist.password,
|
||||
type: 'xtream',
|
||||
};
|
||||
|
||||
await dataSource.createPlaylist(newPlaylist);
|
||||
patchState(store, { currentPlaylist: newPlaylist });
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error fetching playlist:', error);
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Check portal status via API
|
||||
*/
|
||||
async checkPortalStatus(): Promise<void> {
|
||||
const playlist = store.currentPlaylist();
|
||||
if (!playlist) {
|
||||
patchState(store, { portalStatus: 'unavailable' });
|
||||
return;
|
||||
}
|
||||
|
||||
const credentials: XtreamCredentials = {
|
||||
serverUrl: playlist.serverUrl,
|
||||
username: playlist.username,
|
||||
password: playlist.password,
|
||||
};
|
||||
|
||||
try {
|
||||
const response = await apiService.getAccountInfo(credentials);
|
||||
|
||||
if (!response?.user_info?.status) {
|
||||
patchState(store, { portalStatus: 'unavailable' });
|
||||
return;
|
||||
}
|
||||
|
||||
if (response.user_info.status === 'Active') {
|
||||
const expDate = new Date(
|
||||
parseInt(response.user_info.exp_date) * 1000
|
||||
);
|
||||
if (expDate < new Date()) {
|
||||
patchState(store, { portalStatus: 'expired' });
|
||||
} else {
|
||||
patchState(store, { portalStatus: 'active' });
|
||||
}
|
||||
} else {
|
||||
patchState(store, { portalStatus: 'inactive' });
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error checking portal status:', error);
|
||||
patchState(store, { portalStatus: 'unavailable' });
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Update playlist details
|
||||
*/
|
||||
updatePlaylist(updates: Partial<XtreamPlaylistData>): void {
|
||||
const current = store.currentPlaylist();
|
||||
if (current) {
|
||||
patchState(store, {
|
||||
currentPlaylist: { ...current, ...updates },
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Reset portal state
|
||||
*/
|
||||
resetPortal(): void {
|
||||
patchState(store, initialPortalState);
|
||||
},
|
||||
};
|
||||
})
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
import { inject } from '@angular/core';
|
||||
import { patchState, signalStoreFeature, withMethods, withState } from '@ngrx/signals';
|
||||
import { GlobalSearchResult } from 'services';
|
||||
import {
|
||||
IXtreamDataSource,
|
||||
XTREAM_DATA_SOURCE,
|
||||
XtreamContentItem,
|
||||
} from '../../data-sources/xtream-data-source.interface';
|
||||
|
||||
/**
|
||||
* Search state for managing search results
|
||||
*/
|
||||
export interface SearchState {
|
||||
searchResults: XtreamContentItem[];
|
||||
globalSearchResults: GlobalSearchResult[];
|
||||
isSearching: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Initial search state
|
||||
*/
|
||||
const initialSearchState: SearchState = {
|
||||
searchResults: [],
|
||||
globalSearchResults: [],
|
||||
isSearching: false,
|
||||
};
|
||||
|
||||
/**
|
||||
* Search feature store for managing content search.
|
||||
* Handles:
|
||||
* - Local playlist search
|
||||
* - Global search results from external sources
|
||||
*/
|
||||
export function withSearch() {
|
||||
return signalStoreFeature(
|
||||
withState<SearchState>(initialSearchState),
|
||||
|
||||
withMethods((store) => {
|
||||
const dataSource = inject(XTREAM_DATA_SOURCE);
|
||||
|
||||
return {
|
||||
/**
|
||||
* Search content within the current playlist
|
||||
*/
|
||||
async searchContent(
|
||||
searchTerm: string,
|
||||
types: string[]
|
||||
): Promise<XtreamContentItem[]> {
|
||||
// Access parent store's playlistId (from withPortal)
|
||||
const storeAny = store as any;
|
||||
const playlistId = storeAny.playlistId?.();
|
||||
|
||||
if (!playlistId || !searchTerm.trim()) {
|
||||
patchState(store, { searchResults: [] });
|
||||
return [];
|
||||
}
|
||||
|
||||
patchState(store, { isSearching: true });
|
||||
|
||||
try {
|
||||
const results = await dataSource.searchContent(
|
||||
playlistId,
|
||||
searchTerm,
|
||||
types
|
||||
);
|
||||
|
||||
patchState(store, {
|
||||
searchResults: results,
|
||||
isSearching: false,
|
||||
});
|
||||
|
||||
return results;
|
||||
} catch (error) {
|
||||
console.error('Error searching content:', error);
|
||||
patchState(store, {
|
||||
searchResults: [],
|
||||
isSearching: false,
|
||||
});
|
||||
return [];
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Set global search results (from external search)
|
||||
*/
|
||||
setGlobalSearchResults(results: GlobalSearchResult[]): void {
|
||||
patchState(store, {
|
||||
searchResults: results as any,
|
||||
globalSearchResults: results,
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* Clear search results
|
||||
*/
|
||||
resetSearchResults(): void {
|
||||
patchState(store, initialSearchState);
|
||||
},
|
||||
};
|
||||
})
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,271 @@
|
||||
import { computed } from '@angular/core';
|
||||
import {
|
||||
patchState,
|
||||
signalStoreFeature,
|
||||
withComputed,
|
||||
withMethods,
|
||||
withState,
|
||||
} from '@ngrx/signals';
|
||||
import { ContentType } from '../../xtream-state';
|
||||
|
||||
/**
|
||||
* Selection state for managing UI selection and pagination
|
||||
*/
|
||||
export interface SelectionState {
|
||||
selectedContentType: ContentType;
|
||||
selectedCategoryId: number | null;
|
||||
selectedItem: any | null;
|
||||
page: number;
|
||||
limit: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Initial selection state
|
||||
*/
|
||||
const initialSelectionState: SelectionState = {
|
||||
selectedContentType: 'vod',
|
||||
selectedCategoryId: null,
|
||||
selectedItem: null,
|
||||
page: 0,
|
||||
limit: Number(localStorage.getItem('xtream-page-size') ?? 25),
|
||||
};
|
||||
|
||||
/**
|
||||
* Selection feature store for managing UI selection and pagination.
|
||||
* Handles:
|
||||
* - Content type selection (live, vod, series)
|
||||
* - Category selection
|
||||
* - Item selection
|
||||
* - Pagination (page, limit)
|
||||
*/
|
||||
export function withSelection() {
|
||||
return signalStoreFeature(
|
||||
withState<SelectionState>(initialSelectionState),
|
||||
|
||||
withComputed((store) => ({
|
||||
/**
|
||||
* Get the selected category from the parent store's categories
|
||||
*/
|
||||
getSelectedCategory: computed(() => {
|
||||
const categoryId = store.selectedCategoryId();
|
||||
if (!categoryId) return null;
|
||||
|
||||
// Access parent store categories (from withContent)
|
||||
const storeAny = store as any;
|
||||
const allCategories = [
|
||||
...(storeAny.vodCategories?.() || []),
|
||||
...(storeAny.liveCategories?.() || []),
|
||||
...(storeAny.serialCategories?.() || []),
|
||||
];
|
||||
|
||||
return allCategories.find(
|
||||
(c: any) => c.id === categoryId || c.category_id === String(categoryId)
|
||||
);
|
||||
}),
|
||||
|
||||
/**
|
||||
* Get the selected item by ID from content
|
||||
*/
|
||||
getSelectedItemById: computed(() => {
|
||||
const categoryType = store.selectedContentType();
|
||||
const selectedItem = store.selectedItem();
|
||||
|
||||
if (!selectedItem) return null;
|
||||
|
||||
// Access parent store content (from withContent)
|
||||
const storeAny = store as any;
|
||||
const content =
|
||||
categoryType === 'live'
|
||||
? storeAny.liveStreams?.() || []
|
||||
: categoryType === 'vod'
|
||||
? storeAny.vodStreams?.() || []
|
||||
: storeAny.serialStreams?.() || [];
|
||||
|
||||
return content.find(
|
||||
(item: any) =>
|
||||
item.stream_id === selectedItem.stream_id ||
|
||||
item.id === selectedItem.id ||
|
||||
item.series_id === selectedItem.series_id
|
||||
);
|
||||
}),
|
||||
|
||||
/**
|
||||
* Get paginated content for the selected category
|
||||
*/
|
||||
getPaginatedContent: computed(() => {
|
||||
const startIndex = store.page() * store.limit();
|
||||
const endIndex = startIndex + store.limit();
|
||||
const categoryId = store.selectedCategoryId();
|
||||
|
||||
if (!categoryId) return [];
|
||||
|
||||
const categoryType = store.selectedContentType();
|
||||
|
||||
// Access parent store content (from withContent)
|
||||
const storeAny = store as any;
|
||||
const content =
|
||||
categoryType === 'live'
|
||||
? storeAny.liveStreams?.() || []
|
||||
: categoryType === 'vod'
|
||||
? storeAny.vodStreams?.() || []
|
||||
: storeAny.serialStreams?.() || [];
|
||||
|
||||
return content
|
||||
.filter((item: any) => Number(item.category_id) === categoryId)
|
||||
.slice(startIndex, endIndex);
|
||||
}),
|
||||
|
||||
/**
|
||||
* Get all items from the selected category (without pagination)
|
||||
*/
|
||||
selectItemsFromSelectedCategory: computed(() => {
|
||||
const categoryId = store.selectedCategoryId();
|
||||
const categoryType = store.selectedContentType();
|
||||
|
||||
if (!categoryId) return [];
|
||||
|
||||
// Access parent store content (from withContent)
|
||||
const storeAny = store as any;
|
||||
const content =
|
||||
categoryType === 'live'
|
||||
? storeAny.liveStreams?.() || []
|
||||
: categoryType === 'vod'
|
||||
? storeAny.vodStreams?.() || []
|
||||
: storeAny.serialStreams?.() || [];
|
||||
|
||||
return content.filter(
|
||||
(item: any) => Number(item.category_id) === categoryId
|
||||
);
|
||||
}),
|
||||
|
||||
/**
|
||||
* Get total pages for the selected category
|
||||
*/
|
||||
getTotalPages: computed(() => {
|
||||
const categoryId = store.selectedCategoryId();
|
||||
if (!categoryId) return 0;
|
||||
|
||||
const categoryType = store.selectedContentType();
|
||||
|
||||
// Access parent store content (from withContent)
|
||||
const storeAny = store as any;
|
||||
const content =
|
||||
categoryType === 'live'
|
||||
? storeAny.liveStreams?.() || []
|
||||
: categoryType === 'vod'
|
||||
? storeAny.vodStreams?.() || []
|
||||
: storeAny.serialStreams?.() || [];
|
||||
|
||||
const totalItems = content.filter(
|
||||
(item: any) => Number(item.category_id) === categoryId
|
||||
).length;
|
||||
|
||||
return Math.ceil(totalItems / store.limit());
|
||||
}),
|
||||
|
||||
/**
|
||||
* Check if paginated content is loading
|
||||
*/
|
||||
isPaginatedContentLoading: computed(() => {
|
||||
// Access parent store loading state (from withContent)
|
||||
const storeAny = store as any;
|
||||
return storeAny.isLoadingContent?.() || false;
|
||||
}),
|
||||
|
||||
/**
|
||||
* Memoized category item counts map
|
||||
*/
|
||||
getCategoryItemCounts: computed(() => {
|
||||
const type = store.selectedContentType();
|
||||
|
||||
// Access parent store content (from withContent)
|
||||
const storeAny = store as any;
|
||||
const streams =
|
||||
type === 'live'
|
||||
? storeAny.liveStreams?.() || []
|
||||
: type === 'vod'
|
||||
? storeAny.vodStreams?.() || []
|
||||
: storeAny.serialStreams?.() || [];
|
||||
|
||||
const countMap = new Map<number, number>();
|
||||
for (const item of streams) {
|
||||
const catId = Number(item.category_id);
|
||||
if (!isNaN(catId)) {
|
||||
countMap.set(catId, (countMap.get(catId) || 0) + 1);
|
||||
}
|
||||
}
|
||||
return countMap;
|
||||
}),
|
||||
|
||||
/**
|
||||
* Get categories for the currently selected content type
|
||||
*/
|
||||
getCategoriesBySelectedType: computed(() => {
|
||||
const type = store.selectedContentType();
|
||||
|
||||
// Access parent store categories (from withContent)
|
||||
const storeAny = store as any;
|
||||
return type === 'live'
|
||||
? storeAny.liveCategories?.() || []
|
||||
: type === 'vod'
|
||||
? storeAny.vodCategories?.() || []
|
||||
: storeAny.serialCategories?.() || [];
|
||||
}),
|
||||
})),
|
||||
|
||||
withMethods((store) => ({
|
||||
/**
|
||||
* Set the content type (live, vod, series)
|
||||
*/
|
||||
setSelectedContentType(type: ContentType): void {
|
||||
patchState(store, {
|
||||
selectedContentType: type,
|
||||
selectedCategoryId: null,
|
||||
page: 0,
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* Set the selected category
|
||||
*/
|
||||
setSelectedCategory(categoryId: number): void {
|
||||
patchState(store, {
|
||||
selectedCategoryId: Number(categoryId),
|
||||
page: 0,
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* Set the selected item
|
||||
*/
|
||||
setSelectedItem(item: any): void {
|
||||
patchState(store, { selectedItem: item });
|
||||
},
|
||||
|
||||
/**
|
||||
* Set the current page
|
||||
*/
|
||||
setPage(page: number): void {
|
||||
patchState(store, { page });
|
||||
},
|
||||
|
||||
/**
|
||||
* Set the page limit (items per page)
|
||||
*/
|
||||
setLimit(limit: number): void {
|
||||
patchState(store, { limit });
|
||||
localStorage.setItem('xtream-page-size', String(limit));
|
||||
},
|
||||
|
||||
/**
|
||||
* Reset selection state
|
||||
*/
|
||||
resetSelection(): void {
|
||||
patchState(store, {
|
||||
...initialSelectionState,
|
||||
limit: Number(localStorage.getItem('xtream-page-size') ?? 25),
|
||||
});
|
||||
},
|
||||
}))
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export * from './features';
|
||||
export * from './xtream.store';
|
||||
Reference in new issue
Block a user