Files
iptvnator/apps/web/src/app/xtream-tauri/xtream.store.ts
T

969 lines
38 KiB
TypeScript

import { computed, inject } from '@angular/core';
import {
patchState,
signalStore,
withComputed,
withMethods,
withState,
} from '@ngrx/signals';
import { rxMethod } from '@ngrx/signals/rxjs-interop';
import { Store } from '@ngrx/store';
import { selectActivePlaylist } from 'm3u-state';
import {
catchError,
combineLatestWith,
EMPTY,
from,
lastValueFrom,
pipe,
switchMap,
tap,
} from 'rxjs';
import { DatabaseService, DataService } from 'services';
import {
Playlist,
XtreamCodeActions,
XtreamSerieDetails,
XtreamSerieEpisode,
XtreamVodDetails,
} from 'shared-interfaces';
import { PlayerService } from '../services/player.service';
import { SettingsStore } from '../services/settings-store.service';
import { XtreamAccountInfo } from './account-info/account-info.interface';
import { withFavorites } from './with-favorites.feature';
import { withRecentItems } from './with-recent-items';
import { XtreamState } from './xtream-state';
const initialState: XtreamState = {
isLoadingCategories: false,
isLoadingContent: false,
isImporting: false,
itemsToImport: 0,
liveCategories: [],
vodCategories: [],
serialCategories: [],
liveStreams: [],
vodStreams: [],
serialStreams: [],
page: 0,
limit: Number(localStorage.getItem('xtream-page-size') ?? 25),
selectedCategoryId: null,
searchResults: [],
selectedContentType: 'vod',
selectedItem: null,
importCount: 0,
currentPlaylist: null,
epgItems: [],
hideExternalInfoDialog:
localStorage.getItem('hideExternalInfoDialog') === 'true',
portalStatus: 'unavailable',
globalSearchResults: [],
streamUrl: null,
playlistId: null,
};
/** to decode epg */
function b64DecodeUnicode(str: string) {
return decodeURIComponent(
Array.prototype.map
.call(atob(str), function (c) {
return '%' + ('00' + c.charCodeAt(0).toString(16)).slice(-2);
})
.join('')
);
}
export const XtreamStore = signalStore(
{ providedIn: 'root' },
withState(initialState),
withRecentItems(),
withFavorites(),
withComputed((store) => ({
getCategoriesBySelectedType: computed(() => {
const type = store.selectedContentType();
return type === 'live'
? store.liveCategories()
: type === 'vod'
? store.vodCategories()
: store.serialCategories();
}),
/**
* Memoized category item counts map.
* Returns a Map<categoryId, count> for the currently selected content type.
* The Map is recomputed only when streams or content type changes.
*/
getCategoryItemCounts: computed(() => {
const type = store.selectedContentType();
const streams =
type === 'live'
? store.liveStreams()
: type === 'vod'
? store.vodStreams()
: store.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;
}),
getSelectedCategory: computed(() => {
const categoryId = store.selectedCategoryId();
return [
...store.vodCategories(),
...store.liveCategories(),
...store.serialCategories(),
].find((c) => c.id === categoryId);
}),
getImportCount: computed(() => store.importCount()),
getSelectedItemById: computed(() => {
const categoryType = store.selectedContentType();
const content =
categoryType === 'live'
? store.liveStreams()
: categoryType === 'vod'
? store.vodStreams()
: store.serialStreams();
if (!store.selectedItem()) return null;
return content.find(
(item) =>
(item as any).stream_id ===
store.selectedItem().stream_id ||
(item as any).id === store.selectedItem().id
);
}),
getPaginatedContent: computed(() => {
const startIndex = store.page() * store.limit();
const endIndex = startIndex + store.limit();
const categoryId = store.selectedCategoryId();
if (!categoryId) {
return [];
}
const categoryType = store.selectedContentType();
const content =
categoryType === 'live'
? store.liveStreams()
: categoryType === 'vod'
? store.vodStreams()
: store.serialStreams();
return content
.filter((item) => item.category_id === categoryId)
.slice(startIndex, endIndex);
}),
isPaginatedContentLoading: computed(() => store.isLoadingContent()),
getTotalPages: computed(() => {
const categoryId = store.selectedCategoryId();
if (!categoryId) return 0;
const categoryType = store.selectedContentType();
const totalItems =
categoryType === 'live'
? store
.liveStreams()
.filter((i) => Number((i as any).id) === categoryId)
.length
: categoryType === 'vod'
? store
.vodStreams()
.filter((i) => Number(i.category_id) === categoryId)
.length
: store
.serialStreams()
.filter((i) => i.category_id === categoryId).length;
return Math.ceil(totalItems / store.limit());
}),
selectItemsFromSelectedCategory: computed(() => {
const categoryId = store.selectedCategoryId();
const categoryType = store.selectedContentType();
const content =
categoryType === 'live'
? store.liveStreams()
: categoryType === 'vod'
? store.vodStreams()
: store.serialStreams();
return content.filter((item) => item.category_id === categoryId);
}),
globalRecentItems: computed(() => {
return store.recentItems(); // Remove the filter since we're getting global items directly from DB
}),
})),
withMethods(
(
store,
dataService = inject(DataService),
dbService = inject(DatabaseService),
oldStore = inject(Store),
settingsStore = inject(SettingsStore),
playerService = inject(PlayerService)
) => {
const fetchCategories = (
action: XtreamCodeActions,
stateKey: keyof Pick<
XtreamState,
'liveCategories' | 'vodCategories' | 'serialCategories'
>,
type: 'live' | 'movies' | 'series'
) => {
const playlistId = store.playlistId();
console.log(`Fetching ${type} categories for playlist ${playlistId}...`);
patchState(store, { isLoadingCategories: true });
const queryParams = {
action,
username: store.currentPlaylist().username,
password: store.currentPlaylist().password,
};
return from(
dbService.hasXtreamCategories(playlistId, type)
).pipe(
switchMap(async (exists) => {
try {
if (exists) {
const localData =
await dbService.getXtreamCategories(
playlistId,
type
);
patchState(store, {
[stateKey]: localData,
isLoadingCategories: false,
});
return localData;
}
const remoteData = await dataService.fetchData(
`${
store.currentPlaylist().serverUrl
}/player_api.php`,
queryParams
);
if (remoteData && Array.isArray(remoteData)) {
await dbService.saveXtreamCategories(
playlistId,
remoteData,
type
);
}
const localData =
await dbService.getXtreamCategories(
playlistId,
type
);
patchState(store, {
[stateKey]: localData,
isLoadingCategories: false,
});
return localData;
} catch (err) {
console.error(
`Error in fetchCategories for ${type}:`,
err
);
patchState(store, { isLoadingCategories: false });
throw err;
}
})
);
};
const fetchStreams = (
action: XtreamCodeActions,
stateKey: keyof Pick<
XtreamState,
'liveStreams' | 'vodStreams' | 'serialStreams'
>,
type: 'live' | 'movie' | 'series'
) => {
const playlistId = store.playlistId();
console.log(`Fetching ${type} streams for playlist ${playlistId}...`);
patchState(store, { isLoadingContent: true });
const queryParams = {
action,
username: store.currentPlaylist().username,
password: store.currentPlaylist().password,
};
return from(
dbService.hasXtreamContent(playlistId, type)
).pipe(
switchMap(async (exists) => {
try {
if (exists) {
const localData =
await dbService.getXtreamContent(
playlistId,
type
);
patchState(store, {
[stateKey]: localData,
isLoadingContent: false,
});
return localData;
}
const remoteData = await dataService.fetchData(
`${
store.currentPlaylist().serverUrl
}/player_api.php`,
queryParams
);
if (remoteData && Array.isArray(remoteData)) {
patchState(store, {
itemsToImport:
store.itemsToImport() +
remoteData.length,
});
const insertedCount =
await dbService.saveXtreamContent(
playlistId,
remoteData,
type,
(count) =>
patchState(store, {
importCount: count,
})
);
console.log(`Inserted ${insertedCount} items`);
}
const localData = await dbService.getXtreamContent(
playlistId,
type
);
patchState(store, {
[stateKey]: localData,
isLoadingContent: false,
});
return localData;
} catch (err) {
console.error('Error in fetchStreams:', err);
patchState(store, { isLoadingContent: false });
throw err;
}
})
);
};
const searchContent = async (
searchTerm: string,
types: string[]
) => {
const playlistId = store.playlistId();
if (!playlistId) return;
const results = await dbService.searchXtreamContent(
playlistId,
searchTerm,
types
);
return results;
};
const fetchXtreamPlaylist = async () => {
const playlistId = store.playlistId();
console.log('fetchXtreamPlaylist called with playlistId:', playlistId);
if (!playlistId) {
console.log('No playlistId, skipping fetch');
return;
}
try {
const playlist = await dbService.getPlaylistById(playlistId);
console.log('Fetched playlist from DB:', playlist);
if (playlist) {
patchState(store, {
currentPlaylist: playlist,
});
} else {
const activePlaylist = oldStore.selectSignal(
selectActivePlaylist
)() as Playlist;
console.log('No playlist in DB, using active playlist:', activePlaylist);
if (activePlaylist) {
await dbService.createPlaylist(activePlaylist);
patchState(store, {
currentPlaylist: {
...activePlaylist,
serverUrl: activePlaylist.serverUrl,
id: activePlaylist._id,
name: activePlaylist.title,
},
});
}
}
} catch (error) {
console.error('Error fetching playlist:', error);
}
};
const fetchLiveCategories = rxMethod<void>(
pipe(
switchMap(() =>
fetchCategories(
XtreamCodeActions.GetLiveCategories,
'liveCategories',
'live'
)
)
)
);
const fetchVodCategories = rxMethod<void>(
pipe(
switchMap(() =>
fetchCategories(
XtreamCodeActions.GetVodCategories,
'vodCategories',
'movies'
)
)
)
);
const fetchSerialCategories = rxMethod<void>(
pipe(
switchMap(() =>
fetchCategories(
XtreamCodeActions.GetSeriesCategories,
'serialCategories',
'series'
)
)
)
);
const fetchLiveStreams = rxMethod<void>(
pipe(
switchMap(() =>
fetchStreams(
XtreamCodeActions.GetLiveStreams,
'liveStreams',
'live'
)
)
)
);
const fetchVodStreams = rxMethod<void>(
pipe(
switchMap(() =>
fetchStreams(
XtreamCodeActions.GetVodStreams,
'vodStreams',
'movie'
)
)
)
);
const fetchSerialStreams = rxMethod<void>(
pipe(
switchMap(() =>
fetchStreams(
XtreamCodeActions.GetSeries,
'serialStreams',
'series'
)
)
)
);
const setPage = (page: number) => patchState(store, { page });
const setLimit = (limit: number) => patchState(store, { limit });
const setSelectedCategory = (categoryId: number) => {
patchState(store, {
selectedCategoryId: Number(categoryId),
page: 0,
});
};
const setSelectedContentType = (type: 'live' | 'vod' | 'series') =>
patchState(store, {
selectedContentType: type,
selectedCategoryId: null,
});
const setSelectedItem = (item: any) =>
patchState(store, { selectedItem: item });
const searchContentMethod = rxMethod<{
term: string;
types: string[];
}>(
pipe(
switchMap(({ term, types }) =>
from(searchContent(term, types)).pipe(
tap((results) => {
patchState(store, {
searchResults: results || [],
});
})
)
)
)
);
const fetchVodDetailsWithMetadata = rxMethod<{
vodId: string;
categoryId: number;
}>(
pipe(
combineLatestWith(oldStore.select(selectActivePlaylist)),
switchMap(([{ vodId, categoryId }, playlist]) => {
if (!playlist) return EMPTY;
return from(
dataService.fetchData(
`${playlist.serverUrl}/player_api.php`,
{
action: XtreamCodeActions.GetVodInfo,
username: playlist.username,
password: playlist.password,
vod_id: vodId,
}
)
).pipe(
tap((currentVod: XtreamVodDetails) => {
patchState(store, {
selectedCategoryId: Number(categoryId),
});
patchState(store, {
selectedItem: {
...currentVod,
stream_id: vodId,
},
});
})
);
})
)
);
const fetchSerialDetailsWithMetadata = rxMethod<{
serialId: string;
categoryId: number;
}>(
pipe(
combineLatestWith(oldStore.select(selectActivePlaylist)),
switchMap(([{ serialId, categoryId }, playlist]) => {
if (!playlist) return EMPTY;
return from(
dataService.fetchData(
`${playlist.serverUrl}/player_api.php`,
{
action: XtreamCodeActions.GetSeriesInfo,
username: playlist.username,
password: playlist.password,
series_id: serialId,
}
)
).pipe(
tap((currentSerial: XtreamSerieDetails) => {
patchState(store, {
selectedCategoryId: Number(categoryId),
});
patchState(store, {
selectedItem: {
...currentSerial,
series_id: serialId,
},
});
})
);
})
)
);
const initializeContent = async () => {
patchState(store, { isImporting: true });
try {
console.log('Starting content initialization...');
// Fetch all categories in parallel
console.log('Fetching categories...');
await Promise.all([
lastValueFrom(
fetchCategories(
XtreamCodeActions.GetLiveCategories,
'liveCategories',
'live'
)
),
lastValueFrom(
fetchCategories(
XtreamCodeActions.GetVodCategories,
'vodCategories',
'movies'
)
),
lastValueFrom(
fetchCategories(
XtreamCodeActions.GetSeriesCategories,
'serialCategories',
'series'
)
),
]);
// Fetch all content in parallel
console.log('Fetching content...');
await Promise.all([
lastValueFrom(
fetchStreams(
XtreamCodeActions.GetLiveStreams,
'liveStreams',
'live'
)
),
lastValueFrom(
fetchStreams(
XtreamCodeActions.GetVodStreams,
'vodStreams',
'movie'
)
),
lastValueFrom(
fetchStreams(
XtreamCodeActions.GetSeries,
'serialStreams',
'series'
)
),
]);
console.log('Content initialization completed');
// Check if we need to restore favorites and recently viewed after refresh
const currentPlaylistId = store.playlistId();
if (currentPlaylistId) {
const restoreKey = `xtream-restore-${currentPlaylistId}`;
const restoreData = localStorage.getItem(restoreKey);
if (restoreData) {
try {
const {
favoritedXtreamIds,
recentlyViewedXtreamIds,
} = JSON.parse(restoreData);
await dbService.restoreXtreamUserData(
currentPlaylistId,
favoritedXtreamIds,
recentlyViewedXtreamIds
);
localStorage.removeItem(restoreKey);
} catch (err) {
console.error(
'Error restoring user data:',
err
);
}
}
}
} catch (error) {
console.error(
'Error during content initialization:',
error
);
} finally {
patchState(store, { isImporting: false });
}
};
const checkPortalStatus = rxMethod<void>(
pipe(
combineLatestWith(oldStore.select(selectActivePlaylist)),
switchMap(([, playlist]) => {
if (!playlist) return EMPTY;
return from(
dataService.fetchData(
`${playlist.serverUrl}/player_api.php`,
{
username: playlist.username,
password: playlist.password,
action: 'get_account_info',
}
)
).pipe(
tap((response: XtreamAccountInfo) => {
console.log(
'Portal status response:',
response
);
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',
});
}
}),
catchError((error) => {
console.error(
'Error checking portal status:',
error
);
patchState(store, {
portalStatus: 'unavailable',
});
return EMPTY;
})
);
})
)
);
return {
createLinkToPlayVod: () => {},
fetchXtreamPlaylist,
fetchLiveCategories,
fetchVodCategories,
fetchSerialCategories,
fetchLiveStreams,
fetchVodStreams,
fetchSerialStreams,
setPage,
setLimit,
setSelectedCategory,
setSelectedContentType,
setSelectedItem,
searchContent: searchContentMethod,
fetchVodDetailsWithMetadata,
fetchSerialDetailsWithMetadata,
initializeContent,
/**
* Set the current playlist ID
*/
setPlaylistId(playlistId: string) {
patchState(store, { playlistId });
},
/**
* Reset store to initial state for switching between playlists
*/
resetStore(newPlaylistId?: string) {
patchState(store, {
...initialState,
playlistId: newPlaylistId ?? null,
limit: Number(
localStorage.getItem('xtream-page-size') ?? 25
),
hideExternalInfoDialog:
localStorage.getItem('hideExternalInfoDialog') ===
'true',
});
},
updatePlaylist(playlist: any) {
patchState(store, {
currentPlaylist: {
...store.currentPlaylist(),
playlist,
},
});
},
resetSearchResults() {
patchState(store, {
searchResults: [],
globalSearchResults: [],
});
},
async loadEpg() {
console.log('Loading EPG...');
const remoteData = await dataService.fetchData(
`${store.currentPlaylist().serverUrl}/player_api.php`,
{
action: 'get_short_epg',
username: store.currentPlaylist().username,
password: store.currentPlaylist().password,
stream_id: store.selectedItem().xtream_id,
limit: 10,
}
);
if (
remoteData?.epg_listings &&
Array.isArray(remoteData.epg_listings)
) {
console.log(`Got epg data categories:`, remoteData);
patchState(store, {
epgItems: remoteData.epg_listings.map((i) => ({
...i,
title: b64DecodeUnicode(i.title).trim(),
description: b64DecodeUnicode(
i.description
).trim(),
})),
});
} else {
console.error(
'Invalid remote data received:',
remoteData
);
patchState(store, {
epgItems: [],
});
return [];
}
},
async loadChannelEpg(streamId: number) {
try {
const response = await dataService.fetchData(
`${
store.currentPlaylist().serverUrl
}/player_api.php`,
{
action: 'get_short_epg',
username: store.currentPlaylist().username,
password: store.currentPlaylist().password,
stream_id: streamId,
limit: 1,
}
);
if (
response?.epg_listings &&
Array.isArray(response.epg_listings)
) {
return response.epg_listings.map((item) => ({
...item,
title: b64DecodeUnicode(item.title).trim(),
description: b64DecodeUnicode(
item.description
).trim(),
}));
}
return [];
} catch (error) {
console.error('Error loading channel EPG:', error);
return [];
}
},
setHideExternalInfoDialog(hideExternalInfoDialog: boolean) {
localStorage.setItem(
'hideExternalInfoDialog',
store.hideExternalInfoDialog.toString()
);
patchState(store, { hideExternalInfoDialog });
},
checkPortalStatus,
setGlobalSearchResults(results: any[]) {
patchState(store, {
searchResults: results,
globalSearchResults: results,
});
},
constructStreamUrl(item: any) {
const { serverUrl, username, password } =
store.currentPlaylist();
const streamFormat = settingsStore.streamFormat() ?? 'ts';
const streamUrl = `${serverUrl}/live/${username}/${password}/${item.xtream_id}.${streamFormat}`;
// Set the selected item and load EPG data in the store
setSelectedItem(item);
this.loadEpg();
patchState(store, { streamUrl });
return streamUrl;
},
constructVodStreamUrl(vodItem: XtreamVodDetails) {
const { serverUrl, username, password } =
store.currentPlaylist();
const streamUrl = `${serverUrl}/movie/${username}/${password}/${vodItem.movie_data.stream_id}.${vodItem.movie_data.container_extension}`;
patchState(store, { streamUrl });
return streamUrl;
},
constructEpisodeStreamUrl(episode: XtreamSerieEpisode) {
const { serverUrl, username, password } =
store.currentPlaylist();
const streamUrl = `${serverUrl}/series/${username}/${password}/${episode.id}.${episode.container_extension}`;
patchState(store, { streamUrl });
return streamUrl;
},
openPlayer(
streamUrl: string,
title: string,
thumbnail: string = null
) {
const playlist = store.currentPlaylist();
playerService.openPlayer(
streamUrl,
title,
thumbnail,
localStorage.getItem('hideExternalInfoDialog') ===
'true',
store.selectedContentType() === 'live',
playlist?.userAgent,
playlist?.referrer,
playlist?.origin
);
},
addToFavorites(item: any) {
console.log('not needed now', item);
},
removeFromFavorites(favoriteId: string) {
console.log('not needed now', favoriteId);
},
/**
* Reload categories from database after visibility changes
*/
async reloadCategories() {
const playlistId = store.playlistId();
if (!playlistId) return;
try {
const [
liveCategories,
vodCategories,
serialCategories,
] = await Promise.all([
dbService.getXtreamCategories(playlistId, 'live'),
dbService.getXtreamCategories(playlistId, 'movies'),
dbService.getXtreamCategories(playlistId, 'series'),
]);
patchState(store, {
liveCategories: liveCategories as any,
vodCategories: vodCategories as any,
serialCategories: serialCategories as any,
});
} catch (error) {
console.error('Error reloading categories:', error);
}
},
};
}
)
);