Merge pull request #724 from 4gray/refactor/store-refactoring

refactor/store-refactoring
This commit is contained in:
4gray authored and GitHub committed 2026-01-12 00:40:40 +01:00
commit 33d8036ecc
44 files changed
+3287 -1070

No files matched your search

+74
View File
@@ -111,6 +111,80 @@ This is an Nx monorepo with the following structure:
- Entity adapter pattern for managing playlists collection
- Router store integration for route-based state
**XtreamStore Architecture** (Signal Store with Feature Composition):
The Xtream Codes module uses NgRx Signal Store with a layered architecture:
```
┌─────────────────────────────────────────────────────────────────┐
│ PRESENTATION LAYER │
│ Components use XtreamStore (facade) │
└─────────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────┐
│ FACADE LAYER │
│ XtreamStore │
│ (Composes feature stores, unified API) │
└─────────────────────────────────────────────────────────────────┘
│
┌────────────┬────────────┼────────────┬────────────┐
▼ ▼ ▼ ▼ ▼
┌────────────┐ ┌────────────┐ ┌────────────┐ ┌────────────┐ ┌────────────┐
│ withPortal│ │withContent │ │withSelection│ │ withSearch │ │ withPlayer │
└────────────┘ └────────────┘ └────────────┘ └────────────┘ └────────────┘
│ │ │
└───────────────────────────┼──────────────┘
▼
┌─────────────────────────────────────────────────────────────────┐
│ DATA SOURCE LAYER │
│ IXtreamDataSource │
│ ┌───────────────────┬───────────────────┐ │
│ ▼ ▼ │
│ ElectronDataSource PwaDataSource │
│ (DB-first + API) (API-only) │
└─────────────────────────────────────────────────────────────────┘
```
File structure:
```
apps/web/src/app/xtream-tauri/
├── stores/
│ ├── features/
│ │ ├── with-portal.feature.ts # Playlist & portal status
│ │ ├── with-content.feature.ts # Categories & streams
│ │ ├── with-selection.feature.ts # UI selection & pagination
│ │ ├── with-search.feature.ts # Search functionality
│ │ ├── with-epg.feature.ts # EPG data
│ │ ├── with-player.feature.ts # Stream URLs & player
│ │ └── index.ts
│ ├── xtream.store.ts # Facade composing all features
│ └── index.ts
├── services/
│ ├── xtream-api.service.ts # Xtream Codes API calls
│ ├── xtream-url.service.ts # Stream URL construction
│ └── index.ts
├── data-sources/
│ ├── xtream-data-source.interface.ts # Abstract interface + types
│ ├── electron-xtream-data-source.ts # DB-first implementation
│ ├── pwa-xtream-data-source.ts # API-only implementation
│ └── index.ts # Factory provider
└── with-favorites.feature.ts # Favorites (existing)
└── with-recent-items.ts # Recently viewed (existing)
```
Key patterns:
- **Feature stores**: Each `with*.feature.ts` uses `signalStoreFeature()` for focused functionality
- **Facade pattern**: `XtreamStore` composes all features, maintaining backward compatibility
- **Data source abstraction**: `IXtreamDataSource` interface with environment-specific implementations
- **Factory injection**: `provideXtreamDataSource()` selects Electron or PWA implementation at runtime
Data strategies by environment:
| Environment | Strategy |
|-------------|----------|
| **Electron** | DB-first: Check DB → fetch API if missing → cache to DB |
| **PWA** | API-only: Always fetch from API, store in memory |
**Routing**: Lazy-loaded routes in `apps/web/src/app/app.routes.ts`
- Home/playlists overview: `/`
@@ -129,8 +129,19 @@ contextBridge.exposeInMainWorld('electron', {
ipcRenderer.invoke('DB_HAS_CATEGORIES', playlistId, type),
dbGetCategories: (playlistId: string, type: string) =>
ipcRenderer.invoke('DB_GET_CATEGORIES', playlistId, type),
dbSaveCategories: (playlistId: string, categories: any[], type: string) =>
ipcRenderer.invoke('DB_SAVE_CATEGORIES', playlistId, categories, type),
dbSaveCategories: (
playlistId: string,
categories: any[],
type: string,
hiddenCategoryXtreamIds?: number[]
) =>
ipcRenderer.invoke(
'DB_SAVE_CATEGORIES',
playlistId,
categories,
type,
hiddenCategoryXtreamIds
),
dbGetAllCategories: (playlistId: string, type: string) =>
ipcRenderer.invoke('DB_GET_ALL_CATEGORIES', playlistId, type),
dbUpdateCategoryVisibility: (categoryIds: number[], hidden: boolean) =>
@@ -62,6 +62,7 @@ ipcMain.handle(
/**
* Save categories in bulk
* Optionally accepts hidden category data to restore visibility preferences
*/
ipcMain.handle(
'DB_SAVE_CATEGORIES',
@@ -72,7 +73,8 @@ ipcMain.handle(
category_name: string;
category_id: number;
}>,
type: 'live' | 'movies' | 'series'
type: 'live' | 'movies' | 'series',
hiddenCategoryXtreamIds?: number[]
) => {
try {
// Skip if no categories to save
@@ -81,11 +83,17 @@ ipcMain.handle(
}
const db = await getDatabase();
// Create a Set of hidden xtreamIds for efficient lookup
const hiddenSet = new Set(hiddenCategoryXtreamIds || []);
const values = categories.map((cat) => ({
playlistId,
name: cat.category_name,
type,
xtreamId: cat.category_id,
// Restore hidden status if the category was previously hidden
hidden: hiddenSet.has(cat.category_id),
}));
await db.insert(schema.categories).values(values);
@@ -18,14 +18,27 @@ ipcMain.handle(
try {
const db = await getDatabase();
// First, get all category IDs for this playlist
// First, get all categories for this playlist (including hidden status)
const categories = await db
.select({ id: schema.categories.id })
.select({
id: schema.categories.id,
xtreamId: schema.categories.xtreamId,
type: schema.categories.type,
hidden: schema.categories.hidden,
})
.from(schema.categories)
.where(eq(schema.categories.playlistId, playlistId));
const categoryIds = categories.map((c) => c.id);
// Save hidden categories info to restore after refresh
const hiddenCategories = categories
.filter((c) => c.hidden)
.map((c) => ({
xtreamId: c.xtreamId,
type: c.type,
}));
// Before deleting content, save xtreamIds of favorited and recently viewed items
// so they can be restored after refresh
let favoritedXtreamIds: number[] = [];
@@ -89,11 +102,12 @@ ipcMain.handle(
.delete(schema.categories)
.where(eq(schema.categories.playlistId, playlistId));
// Return the saved xtreamIds so favorites and recently viewed can be restored
// Return the saved xtreamIds so favorites, recently viewed, and hidden categories can be restored
return {
success: true,
favoritedXtreamIds,
recentlyViewedXtreamIds,
hiddenCategories,
};
} catch (error) {
console.error('Error deleting Xtream content:', error);
+2
View File
@@ -26,6 +26,7 @@ import { AppConfig } from '../environments/environment';
import { routes } from './app.routes';
import { ElectronService } from './services/electron.service';
import { PwaService } from './services/pwa.service';
import { provideXtreamDataSource } from './xtream-tauri/data-sources';
// AoT requires an exported function for factories
export function HttpLoaderFactory(http: HttpClient): TranslateHttpLoader {
@@ -84,5 +85,6 @@ export const appConfig: ApplicationConfig = {
useFactory: DataFactory,
deps: [NgxIndexedDBService, HttpClient],
},
...provideXtreamDataSource(),
],
};
@@ -6,14 +6,15 @@ import { PlaylistActions, selectPlaylistById } from 'm3u-state';
import { map, switchMap } from 'rxjs';
import { NavigationComponent } from '../../xtream-tauri/navigation/navigation.component';
import { NavigationItem } from '../../xtream-tauri/navigation/navigation.interface';
import { XtreamStore } from '../../xtream-tauri/stores/xtream.store';
import { StalkerStore } from '../stalker.store';
@Component({
selector: 'app-stalker-shell',
templateUrl: './stalker-shell.component.html',
styleUrls: ['./stalker-shell.component.scss'],
standalone: true,
imports: [NavigationComponent, RouterOutlet, NavigationComponent],
imports: [NavigationComponent, RouterOutlet],
providers: [XtreamStore],
})
export class StalkerShellComponent implements OnDestroy {
private readonly route = inject(ActivatedRoute);
@@ -8,7 +8,7 @@ import { StalkerSeriesViewComponent } from '../../stalker/stalker-series-view/st
import { StalkerStore } from '../../stalker/stalker.store';
import { VodDetailsComponent } from '../../xtream/vod-details/vod-details.component';
import { PlaylistErrorViewComponent } from '../playlist-error-view/playlist-error-view.component';
import { XtreamStore } from '../xtream.store';
import { XtreamStore } from '../stores/xtream.store';
@Component({
selector: 'app-category-content-view',
@@ -0,0 +1,320 @@
import { inject, Injectable } from '@angular/core';
import { DatabaseService } from 'services';
import {
XtreamCategory,
XtreamLiveStream,
XtreamSerieItem,
XtreamVodStream,
} from 'shared-interfaces';
import { CategoryType, StreamType, XtreamApiService, XtreamCredentials } from '../services/xtream-api.service';
import {
DbCategoryType,
IXtreamDataSource,
mapCategoryTypeToDbType,
ProgressCallback,
XtreamCategoryFromDb,
XtreamContentItem,
XtreamPlaylistData,
} from './xtream-data-source.interface';
/**
* Electron implementation of the Xtream data source.
* Uses DB-first strategy: check DB, fetch API if needed, cache to DB.
*/
@Injectable({ providedIn: 'root' })
export class ElectronXtreamDataSource implements IXtreamDataSource {
private readonly dbService = inject(DatabaseService);
private readonly apiService = inject(XtreamApiService);
// =========================================================================
// Playlist Operations
// =========================================================================
async getPlaylist(playlistId: string): Promise<XtreamPlaylistData | null> {
const playlist = await this.dbService.getPlaylistById(playlistId);
return playlist as XtreamPlaylistData | null;
}
async createPlaylist(playlist: XtreamPlaylistData): Promise<void> {
await this.dbService.createPlaylist({
_id: playlist.id,
title: playlist.name,
serverUrl: playlist.serverUrl,
username: playlist.username,
password: playlist.password,
} as any);
}
async updatePlaylist(
playlistId: string,
updates: Partial<XtreamPlaylistData>
): Promise<void> {
await this.dbService.updateXtreamPlaylistDetails({
id: playlistId,
title: updates.name,
username: updates.username,
password: updates.password,
serverUrl: updates.serverUrl,
});
}
async deletePlaylist(playlistId: string): Promise<void> {
await this.dbService.deletePlaylist(playlistId);
}
// =========================================================================
// Category Operations
// =========================================================================
async hasCategories(
playlistId: string,
type: DbCategoryType
): Promise<boolean> {
return this.dbService.hasXtreamCategories(playlistId, type);
}
async getCategories(
playlistId: string,
credentials: XtreamCredentials,
type: CategoryType
): Promise<XtreamCategoryFromDb[]> {
const dbType = mapCategoryTypeToDbType(type);
// Check if we have cached data
const exists = await this.dbService.hasXtreamCategories(
playlistId,
dbType
);
if (exists) {
// Return from cache
return this.dbService.getXtreamCategories(playlistId, dbType);
}
// Fetch from API and cache
const remoteData = await this.apiService.getCategories(
credentials,
type
);
if (remoteData && Array.isArray(remoteData) && remoteData.length > 0) {
// Check if there are saved hidden categories to restore
const hiddenCategoryXtreamIds =
this.getHiddenCategoryXtreamIds(playlistId, dbType);
await this.dbService.saveXtreamCategories(
playlistId,
remoteData,
dbType,
hiddenCategoryXtreamIds
);
}
// Return from cache (now populated)
return this.dbService.getXtreamCategories(playlistId, dbType);
}
/**
* Get hidden category xtreamIds from localStorage for a specific playlist and type
* Used to restore visibility preferences after playlist refresh
*/
private getHiddenCategoryXtreamIds(
playlistId: string,
type: 'live' | 'movies' | 'series'
): number[] | undefined {
const restoreKey = `xtream-restore-${playlistId}`;
const restoreDataStr = localStorage.getItem(restoreKey);
if (!restoreDataStr) {
return undefined;
}
try {
const restoreData = JSON.parse(restoreDataStr);
const hiddenCategories = restoreData.hiddenCategories as
| { xtreamId: number; type: string }[]
| undefined;
if (!hiddenCategories || hiddenCategories.length === 0) {
return undefined;
}
// Filter for the specific type and return xtreamIds
return hiddenCategories
.filter((cat) => cat.type === type)
.map((cat) => cat.xtreamId);
} catch {
return undefined;
}
}
async getAllCategories(
playlistId: string,
type: DbCategoryType
): Promise<XtreamCategoryFromDb[]> {
return this.dbService.getAllXtreamCategories(playlistId, type);
}
async saveCategories(
playlistId: string,
categories: XtreamCategory[],
type: DbCategoryType
): Promise<void> {
await this.dbService.saveXtreamCategories(playlistId, categories, type);
}
async updateCategoryVisibility(
categoryIds: number[],
hidden: boolean
): Promise<void> {
await this.dbService.updateCategoryVisibility(categoryIds, hidden);
}
// =========================================================================
// Content/Stream Operations
// =========================================================================
async hasContent(
playlistId: string,
type: 'live' | 'movie' | 'series'
): Promise<boolean> {
return this.dbService.hasXtreamContent(playlistId, type);
}
async getContent(
playlistId: string,
credentials: XtreamCredentials,
type: StreamType
): Promise<XtreamContentItem[]> {
// Check if we have cached data
const exists = await this.dbService.hasXtreamContent(playlistId, type);
if (exists) {
// Return from cache
return this.dbService.getXtreamContent(playlistId, type);
}
// Fetch from API
const remoteData = await this.apiService.getStreams(credentials, type);
if (remoteData && Array.isArray(remoteData) && remoteData.length > 0) {
// Save to cache
await this.dbService.saveXtreamContent(
playlistId,
remoteData as any[],
type
);
}
// Return from cache (now populated)
return this.dbService.getXtreamContent(playlistId, type);
}
async saveContent(
playlistId: string,
streams: any[],
type: 'live' | 'movie' | 'series',
onProgress?: ProgressCallback
): Promise<number> {
return this.dbService.saveXtreamContent(
playlistId,
streams,
type,
onProgress
);
}
// =========================================================================
// Search Operations
// =========================================================================
async searchContent(
playlistId: string,
searchTerm: string,
types: string[]
): Promise<XtreamContentItem[]> {
return this.dbService.searchXtreamContent(playlistId, searchTerm, types);
}
// =========================================================================
// Favorites Operations
// =========================================================================
async getFavorites(playlistId: string): Promise<XtreamContentItem[]> {
return this.dbService.getFavorites(playlistId);
}
async addFavorite(contentId: number, playlistId: string): Promise<void> {
await this.dbService.addToFavorites(contentId, playlistId);
}
async removeFavorite(contentId: number, playlistId: string): Promise<void> {
await this.dbService.removeFromFavorites(contentId, playlistId);
}
async isFavorite(contentId: number, playlistId: string): Promise<boolean> {
return this.dbService.isFavorite(contentId, playlistId);
}
// =========================================================================
// Recently Viewed Operations
// =========================================================================
async getRecentItems(playlistId: string): Promise<XtreamContentItem[]> {
return this.dbService.getRecentItems(playlistId);
}
async addRecentItem(contentId: number, playlistId: string): Promise<void> {
await this.dbService.addRecentItem(contentId, playlistId);
}
async removeRecentItem(
contentId: number,
playlistId: string
): Promise<void> {
await this.dbService.removeRecentItem(contentId, playlistId);
}
async clearRecentItems(playlistId: string): Promise<void> {
await this.dbService.clearPlaylistRecentItems(playlistId);
}
// =========================================================================
// Content Lookup
// =========================================================================
async getContentByXtreamId(
xtreamId: number,
playlistId: string
): Promise<XtreamContentItem | null> {
return this.dbService.getContentByXtreamId(xtreamId, playlistId);
}
// =========================================================================
// Cleanup Operations
// =========================================================================
async clearPlaylistContent(playlistId: string): Promise<{
favoritedXtreamIds: number[];
recentlyViewedXtreamIds: { xtreamId: number; viewedAt: string }[];
}> {
const result =
await this.dbService.deleteXtreamPlaylistContent(playlistId);
return {
favoritedXtreamIds: result.favoritedXtreamIds,
recentlyViewedXtreamIds: result.recentlyViewedXtreamIds,
};
}
async restoreUserData(
playlistId: string,
favoritedXtreamIds: number[],
recentlyViewedXtreamIds: { xtreamId: number; viewedAt: string }[]
): Promise<void> {
await this.dbService.restoreXtreamUserData(
playlistId,
favoritedXtreamIds,
recentlyViewedXtreamIds
);
}
}
@@ -0,0 +1,39 @@
import { EnvironmentProviders, inject, Provider } from '@angular/core';
import { ElectronXtreamDataSource } from './electron-xtream-data-source';
import { PwaXtreamDataSource } from './pwa-xtream-data-source';
import { IXtreamDataSource, XTREAM_DATA_SOURCE } from './xtream-data-source.interface';
// Re-export all types and interfaces
export * from './xtream-data-source.interface';
export { ElectronXtreamDataSource } from './electron-xtream-data-source';
export { PwaXtreamDataSource } from './pwa-xtream-data-source';
/**
* Factory function that returns the appropriate data source based on environment.
* - Electron: Uses DatabaseService for DB-first caching
* - PWA: Uses API-only with in-memory caching and localStorage for user data
*/
export function xtreamDataSourceFactory(): IXtreamDataSource {
// Check if we're in Electron environment
if (typeof window !== 'undefined' && (window as any).electron) {
return inject(ElectronXtreamDataSource);
}
// Default to PWA implementation
return inject(PwaXtreamDataSource);
}
/**
* Provider for the Xtream data source.
* Add this to your app providers to enable the data source abstraction.
*/
export function provideXtreamDataSource(): Provider[] {
return [
ElectronXtreamDataSource,
PwaXtreamDataSource,
{
provide: XTREAM_DATA_SOURCE,
useFactory: xtreamDataSourceFactory,
},
];
}
@@ -0,0 +1,511 @@
import { inject, Injectable } from '@angular/core';
import {
XtreamCategory,
XtreamLiveStream,
XtreamSerieItem,
XtreamVodStream,
} from 'shared-interfaces';
import { CategoryType, StreamType, XtreamApiService, XtreamCredentials } from '../services/xtream-api.service';
import {
DbCategoryType,
IXtreamDataSource,
ProgressCallback,
XtreamCategoryFromDb,
XtreamContentItem,
XtreamPlaylistData,
} from './xtream-data-source.interface';
/**
* LocalStorage keys for PWA persistence
*/
const STORAGE_KEYS = {
FAVORITES: 'xtream-favorites',
RECENT_ITEMS: 'xtream-recent-items',
PLAYLISTS: 'xtream-playlists',
};
/**
* PWA implementation of the Xtream data source.
* Uses API-only strategy: always fetch from API, no database caching.
* Favorites and recently viewed are stored in localStorage.
*/
@Injectable({ providedIn: 'root' })
export class PwaXtreamDataSource implements IXtreamDataSource {
private readonly apiService = inject(XtreamApiService);
// In-memory cache for the current session
private categoryCache = new Map<string, XtreamCategory[]>();
private contentCache = new Map<string, any[]>();
// =========================================================================
// Playlist Operations (localStorage)
// =========================================================================
async getPlaylist(playlistId: string): Promise<XtreamPlaylistData | null> {
const playlists = this.getPlaylistsFromStorage();
return playlists.find((p) => p.id === playlistId) || null;
}
async createPlaylist(playlist: XtreamPlaylistData): Promise<void> {
const playlists = this.getPlaylistsFromStorage();
playlists.push(playlist);
this.savePlaylistsToStorage(playlists);
}
async updatePlaylist(
playlistId: string,
updates: Partial<XtreamPlaylistData>
): Promise<void> {
const playlists = this.getPlaylistsFromStorage();
const index = playlists.findIndex((p) => p.id === playlistId);
if (index !== -1) {
playlists[index] = { ...playlists[index], ...updates };
this.savePlaylistsToStorage(playlists);
}
}
async deletePlaylist(playlistId: string): Promise<void> {
const playlists = this.getPlaylistsFromStorage();
const filtered = playlists.filter((p) => p.id !== playlistId);
this.savePlaylistsToStorage(filtered);
// Also clear favorites and recent items for this playlist
this.clearFavoritesForPlaylist(playlistId);
this.clearRecentItemsForPlaylist(playlistId);
// Clear cache
this.clearCacheForPlaylist(playlistId);
}
private getPlaylistsFromStorage(): XtreamPlaylistData[] {
try {
const data = localStorage.getItem(STORAGE_KEYS.PLAYLISTS);
return data ? JSON.parse(data) : [];
} catch {
return [];
}
}
private savePlaylistsToStorage(playlists: XtreamPlaylistData[]): void {
localStorage.setItem(STORAGE_KEYS.PLAYLISTS, JSON.stringify(playlists));
}
// =========================================================================
// Category Operations (API + in-memory cache)
// =========================================================================
async hasCategories(
playlistId: string,
type: DbCategoryType
): Promise<boolean> {
const cacheKey = `${playlistId}-${type}-categories`;
return this.categoryCache.has(cacheKey);
}
async getCategories(
playlistId: string,
credentials: XtreamCredentials,
type: CategoryType
): Promise<XtreamCategory[]> {
const cacheKey = `${playlistId}-${type}-categories`;
// Check in-memory cache first
if (this.categoryCache.has(cacheKey)) {
return this.categoryCache.get(cacheKey)!;
}
// Fetch from API
const categories = await this.apiService.getCategories(
credentials,
type
);
// Cache in memory
this.categoryCache.set(cacheKey, categories);
return categories;
}
async getAllCategories(
playlistId: string,
type: DbCategoryType
): Promise<XtreamCategoryFromDb[]> {
// PWA doesn't track hidden categories - return empty
return [];
}
async saveCategories(
playlistId: string,
categories: XtreamCategory[],
type: DbCategoryType
): Promise<void> {
// In PWA mode, we just cache in memory
const cacheKey = `${playlistId}-${type}-categories`;
this.categoryCache.set(cacheKey, categories);
}
async updateCategoryVisibility(
categoryIds: number[],
hidden: boolean
): Promise<void> {
// Category visibility is not supported in PWA mode
console.warn('Category visibility not supported in PWA mode');
}
// =========================================================================
// Content/Stream Operations (API + in-memory cache)
// =========================================================================
async hasContent(
playlistId: string,
type: 'live' | 'movie' | 'series'
): Promise<boolean> {
const cacheKey = `${playlistId}-${type}-content`;
return this.contentCache.has(cacheKey);
}
async getContent(
playlistId: string,
credentials: XtreamCredentials,
type: StreamType
): Promise<XtreamLiveStream[] | XtreamVodStream[] | XtreamSerieItem[]> {
const cacheKey = `${playlistId}-${type}-content`;
// Check in-memory cache first
if (this.contentCache.has(cacheKey)) {
return this.contentCache.get(cacheKey)!;
}
// Fetch from API
const content = await this.apiService.getStreams(credentials, type);
// Cache in memory
this.contentCache.set(cacheKey, content);
return content;
}
async saveContent(
playlistId: string,
streams: any[],
type: 'live' | 'movie' | 'series',
onProgress?: ProgressCallback
): Promise<number> {
// In PWA mode, we just cache in memory
const cacheKey = `${playlistId}-${type}-content`;
this.contentCache.set(cacheKey, streams);
if (onProgress) {
onProgress(streams.length);
}
return streams.length;
}
// =========================================================================
// Search Operations (in-memory filter)
// =========================================================================
async searchContent(
playlistId: string,
searchTerm: string,
types: string[]
): Promise<XtreamContentItem[]> {
const results: any[] = [];
const searchLower = searchTerm.toLowerCase();
for (const type of types) {
const cacheKey = `${playlistId}-${type}-content`;
const content = this.contentCache.get(cacheKey) || [];
const filtered = content.filter((item: any) => {
const title =
item.name || item.title || item.stream_display_name || '';
return title.toLowerCase().includes(searchLower);
});
results.push(...filtered);
}
return results as XtreamContentItem[];
}
// =========================================================================
// Favorites Operations (localStorage)
// =========================================================================
async getFavorites(playlistId: string): Promise<XtreamContentItem[]> {
const allFavorites = this.getFavoritesFromStorage();
const playlistFavorites = allFavorites[playlistId] || [];
// Match favorites with cached content
const results: any[] = [];
for (const type of ['live', 'movie', 'series']) {
const cacheKey = `${playlistId}-${type}-content`;
const content = this.contentCache.get(cacheKey) || [];
for (const item of content as any[]) {
const itemId = item.stream_id || item.series_id || item.id;
if (playlistFavorites.includes(itemId)) {
results.push(item);
}
}
}
return results as XtreamContentItem[];
}
async addFavorite(contentId: number, playlistId: string): Promise<void> {
const allFavorites = this.getFavoritesFromStorage();
if (!allFavorites[playlistId]) {
allFavorites[playlistId] = [];
}
if (!allFavorites[playlistId].includes(contentId)) {
allFavorites[playlistId].push(contentId);
}
this.saveFavoritesToStorage(allFavorites);
}
async removeFavorite(contentId: number, playlistId: string): Promise<void> {
const allFavorites = this.getFavoritesFromStorage();
if (allFavorites[playlistId]) {
allFavorites[playlistId] = allFavorites[playlistId].filter(
(id: number) => id !== contentId
);
}
this.saveFavoritesToStorage(allFavorites);
}
async isFavorite(contentId: number, playlistId: string): Promise<boolean> {
const allFavorites = this.getFavoritesFromStorage();
return (allFavorites[playlistId] || []).includes(contentId);
}
private getFavoritesFromStorage(): Record<string, number[]> {
try {
const data = localStorage.getItem(STORAGE_KEYS.FAVORITES);
return data ? JSON.parse(data) : {};
} catch {
return {};
}
}
private saveFavoritesToStorage(
favorites: Record<string, number[]>
): void {
localStorage.setItem(STORAGE_KEYS.FAVORITES, JSON.stringify(favorites));
}
private clearFavoritesForPlaylist(playlistId: string): void {
const allFavorites = this.getFavoritesFromStorage();
delete allFavorites[playlistId];
this.saveFavoritesToStorage(allFavorites);
}
// =========================================================================
// Recently Viewed Operations (localStorage)
// =========================================================================
async getRecentItems(playlistId: string): Promise<XtreamContentItem[]> {
const allRecent = this.getRecentItemsFromStorage();
const playlistRecent = allRecent[playlistId] || [];
// Match recent items with cached content
const results: any[] = [];
for (const type of ['live', 'movie', 'series']) {
const cacheKey = `${playlistId}-${type}-content`;
const content = this.contentCache.get(cacheKey) || [];
for (const item of content as any[]) {
const itemId = item.stream_id || item.series_id || item.id;
const recentEntry = playlistRecent.find(
(r: any) => r.id === itemId
);
if (recentEntry) {
results.push({
...item,
viewed_at: recentEntry.viewedAt,
});
}
}
}
// Sort by viewed_at descending
results.sort(
(a, b) =>
new Date(b.viewed_at).getTime() -
new Date(a.viewed_at).getTime()
);
return results as XtreamContentItem[];
}
async addRecentItem(contentId: number, playlistId: string): Promise<void> {
const allRecent = this.getRecentItemsFromStorage();
if (!allRecent[playlistId]) {
allRecent[playlistId] = [];
}
// Remove existing entry if present
allRecent[playlistId] = allRecent[playlistId].filter(
(r: any) => r.id !== contentId
);
// Add new entry at the beginning
allRecent[playlistId].unshift({
id: contentId,
viewedAt: new Date().toISOString(),
});
// Keep only last 50 items
allRecent[playlistId] = allRecent[playlistId].slice(0, 50);
this.saveRecentItemsToStorage(allRecent);
}
async removeRecentItem(
contentId: number,
playlistId: string
): Promise<void> {
const allRecent = this.getRecentItemsFromStorage();
if (allRecent[playlistId]) {
allRecent[playlistId] = allRecent[playlistId].filter(
(r: any) => r.id !== contentId
);
}
this.saveRecentItemsToStorage(allRecent);
}
async clearRecentItems(playlistId: string): Promise<void> {
this.clearRecentItemsForPlaylist(playlistId);
}
private getRecentItemsFromStorage(): Record<
string,
{ id: number; viewedAt: string }[]
> {
try {
const data = localStorage.getItem(STORAGE_KEYS.RECENT_ITEMS);
return data ? JSON.parse(data) : {};
} catch {
return {};
}
}
private saveRecentItemsToStorage(
recentItems: Record<string, { id: number; viewedAt: string }[]>
): void {
localStorage.setItem(
STORAGE_KEYS.RECENT_ITEMS,
JSON.stringify(recentItems)
);
}
private clearRecentItemsForPlaylist(playlistId: string): void {
const allRecent = this.getRecentItemsFromStorage();
delete allRecent[playlistId];
this.saveRecentItemsToStorage(allRecent);
}
// =========================================================================
// Content Lookup
// =========================================================================
async getContentByXtreamId(
xtreamId: number,
playlistId: string
): Promise<XtreamContentItem | null> {
for (const type of ['live', 'movie', 'series']) {
const cacheKey = `${playlistId}-${type}-content`;
const content = this.contentCache.get(cacheKey) || [];
const found = (content as any[]).find((item) => {
const itemXtreamId =
item.stream_id || item.series_id || item.id;
return itemXtreamId === xtreamId;
});
if (found) {
return found as XtreamContentItem;
}
}
return null;
}
// =========================================================================
// Cleanup Operations
// =========================================================================
async clearPlaylistContent(playlistId: string): Promise<{
favoritedXtreamIds: number[];
recentlyViewedXtreamIds: { xtreamId: number; viewedAt: string }[];
}> {
// Get current favorites and recent items
const favorites = this.getFavoritesFromStorage();
const recentItems = this.getRecentItemsFromStorage();
const favoritedXtreamIds = favorites[playlistId] || [];
const recentlyViewedXtreamIds = (recentItems[playlistId] || []).map(
(r: any) => ({
xtreamId: r.id,
viewedAt: r.viewedAt,
})
);
// Clear in-memory cache
this.clearCacheForPlaylist(playlistId);
return { favoritedXtreamIds, recentlyViewedXtreamIds };
}
async restoreUserData(
playlistId: string,
favoritedXtreamIds: number[],
recentlyViewedXtreamIds: { xtreamId: number; viewedAt: string }[]
): Promise<void> {
// Restore favorites
const favorites = this.getFavoritesFromStorage();
favorites[playlistId] = favoritedXtreamIds;
this.saveFavoritesToStorage(favorites);
// Restore recent items
const recentItems = this.getRecentItemsFromStorage();
recentItems[playlistId] = recentlyViewedXtreamIds.map((r) => ({
id: r.xtreamId,
viewedAt: r.viewedAt,
}));
this.saveRecentItemsToStorage(recentItems);
}
// =========================================================================
// Cache Management
// =========================================================================
private clearCacheForPlaylist(playlistId: string): void {
const keysToDelete: string[] = [];
this.categoryCache.forEach((_, key) => {
if (key.startsWith(playlistId)) {
keysToDelete.push(key);
}
});
keysToDelete.forEach((key) => this.categoryCache.delete(key));
const contentKeysToDelete: string[] = [];
this.contentCache.forEach((_, key) => {
if (key.startsWith(playlistId)) {
contentKeysToDelete.push(key);
}
});
contentKeysToDelete.forEach((key) => this.contentCache.delete(key));
}
/**
* Clear all in-memory caches
*/
clearAllCaches(): void {
this.categoryCache.clear();
this.contentCache.clear();
}
}
@@ -0,0 +1,320 @@
import { InjectionToken } from '@angular/core';
import {
XtreamCategory,
XtreamLiveStream,
XtreamSerieItem,
XtreamVodStream,
} from 'shared-interfaces';
import { XtreamCredentials, CategoryType, StreamType } from '../services/xtream-api.service';
/**
* Playlist representation in the data source
*/
export interface XtreamPlaylistData {
id: string;
name: string;
title?: string; // Alias for name, used in some templates
serverUrl: string;
username: string;
password: string;
type: string;
userAgent?: string;
referrer?: string;
origin?: string;
}
/**
* Content item from the data source.
* Includes fields from XtreamItem for template compatibility.
*/
export interface XtreamContentItem {
// Core DB fields
id: number;
category_id: number | string; // Can be number (DB) or string (API)
title: string;
rating: string;
added: string;
poster_url: string;
xtream_id: number;
type: string;
added_at?: string;
viewed_at?: string;
// XtreamItem compatibility fields (optional for search/navigation)
num?: number;
name?: string;
stream_type?: 'live' | 'movie';
stream_id?: number;
stream_icon?: string;
custom_sid?: string;
direct_source?: string;
rating_imdb?: string;
// Global search result fields
description?: string;
playlist_id?: string;
playlist_name?: string;
}
/**
* Category from the database with additional fields
*/
export interface XtreamCategoryFromDb {
id: number;
name: string;
playlist_id: string;
type: 'movies' | 'live' | 'series';
xtream_id: number;
hidden: boolean;
}
/**
* Recently viewed item with playlist info
*/
export interface RecentlyViewedItem extends XtreamContentItem {
viewed_at: string;
}
/**
* Database category type mapping (differs from API type)
*/
export type DbCategoryType = 'live' | 'movies' | 'series';
/**
* Maps CategoryType to DbCategoryType
*/
export function mapCategoryTypeToDbType(type: CategoryType): DbCategoryType {
switch (type) {
case 'live':
return 'live';
case 'vod':
return 'movies';
case 'series':
return 'series';
}
}
/**
* Maps StreamType to DbCategoryType for content storage
*/
export function mapStreamTypeToDbType(type: StreamType): 'live' | 'movie' | 'series' {
return type;
}
/**
* Progress callback for bulk operations
*/
export type ProgressCallback = (count: number) => void;
/**
* Abstract interface for Xtream data source.
* Allows different implementations for Electron (DB-first) and PWA (API-only).
*/
export interface IXtreamDataSource {
// =========================================================================
// Playlist Operations
// =========================================================================
/**
* Get playlist by ID
*/
getPlaylist(playlistId: string): Promise<XtreamPlaylistData | null>;
/**
* Create a new playlist
*/
createPlaylist(playlist: XtreamPlaylistData): Promise<void>;
/**
* Update playlist details
*/
updatePlaylist(
playlistId: string,
updates: Partial<XtreamPlaylistData>
): Promise<void>;
/**
* Delete a playlist and all its data
*/
deletePlaylist(playlistId: string): Promise<void>;
// =========================================================================
// Category Operations
// =========================================================================
/**
* Check if categories exist for a playlist and type
*/
hasCategories(
playlistId: string,
type: DbCategoryType
): Promise<boolean>;
/**
* Get categories for a playlist and type
* Returns only visible categories by default
*/
getCategories(
playlistId: string,
credentials: XtreamCredentials,
type: CategoryType
): Promise<XtreamCategory[] | XtreamCategoryFromDb[]>;
/**
* Get all categories including hidden (for management)
*/
getAllCategories(
playlistId: string,
type: DbCategoryType
): Promise<XtreamCategoryFromDb[]>;
/**
* Save categories in bulk
*/
saveCategories(
playlistId: string,
categories: XtreamCategory[],
type: DbCategoryType
): Promise<void>;
/**
* Update category visibility
*/
updateCategoryVisibility(
categoryIds: number[],
hidden: boolean
): Promise<void>;
// =========================================================================
// Content/Stream Operations
// =========================================================================
/**
* Check if content exists for a playlist and type
*/
hasContent(
playlistId: string,
type: 'live' | 'movie' | 'series'
): Promise<boolean>;
/**
* Get content/streams for a playlist and type
*/
getContent(
playlistId: string,
credentials: XtreamCredentials,
type: StreamType
): Promise<XtreamLiveStream[] | XtreamVodStream[] | XtreamSerieItem[] | XtreamContentItem[]>;
/**
* Save content in bulk
*/
saveContent(
playlistId: string,
streams: any[],
type: 'live' | 'movie' | 'series',
onProgress?: ProgressCallback
): Promise<number>;
// =========================================================================
// Search Operations
// =========================================================================
/**
* Search content within a playlist
*/
searchContent(
playlistId: string,
searchTerm: string,
types: string[]
): Promise<XtreamContentItem[]>;
// =========================================================================
// Favorites Operations
// =========================================================================
/**
* Get all favorites for a playlist
*/
getFavorites(playlistId: string): Promise<XtreamContentItem[]>;
/**
* Add content to favorites
*/
addFavorite(contentId: number, playlistId: string): Promise<void>;
/**
* Remove content from favorites
*/
removeFavorite(contentId: number, playlistId: string): Promise<void>;
/**
* Check if content is favorited
*/
isFavorite(contentId: number, playlistId: string): Promise<boolean>;
// =========================================================================
// Recently Viewed Operations
// =========================================================================
/**
* Get recently viewed items for a playlist
*/
getRecentItems(playlistId: string): Promise<XtreamContentItem[]>;
/**
* Add item to recently viewed
*/
addRecentItem(contentId: number, playlistId: string): Promise<void>;
/**
* Remove item from recently viewed
*/
removeRecentItem(contentId: number, playlistId: string): Promise<void>;
/**
* Clear recently viewed for a playlist
*/
clearRecentItems(playlistId: string): Promise<void>;
// =========================================================================
// Content Lookup
// =========================================================================
/**
* Get content by xtream ID
*/
getContentByXtreamId(
xtreamId: number,
playlistId: string
): Promise<XtreamContentItem | null>;
// =========================================================================
// Cleanup Operations
// =========================================================================
/**
* Clear all content and categories for a playlist (for refresh)
* Returns user data (favorites, recently viewed) for restoration
*/
clearPlaylistContent(playlistId: string): Promise<{
favoritedXtreamIds: number[];
recentlyViewedXtreamIds: { xtreamId: number; viewedAt: string }[];
}>;
/**
* Restore user data after refresh
*/
restoreUserData(
playlistId: string,
favoritedXtreamIds: number[],
recentlyViewedXtreamIds: { xtreamId: number; viewedAt: string }[]
): Promise<void>;
}
/**
* Injection token for the data source
*/
export const XTREAM_DATA_SOURCE = new InjectionToken<IXtreamDataSource>(
'XtreamDataSource'
);
@@ -8,7 +8,7 @@ import { XtreamCategory } from 'shared-interfaces';
import { FavoritesLayoutComponent } from '../../shared/components/favorites-layout/favorites-layout.component';
import { FavoriteItem } from '../services/favorite-item.interface';
import { FavoritesService } from '../services/favorites.service';
import { XtreamStore } from '../xtream.store';
import { XtreamStore } from '../stores/xtream.store';
@Component({
selector: 'app-favorites',
@@ -25,7 +25,7 @@ import {
import { CategoryViewComponent } from '../category-view/category-view.component';
import { PortalChannelsListComponent } from '../portal-channels-list/portal-channels-list.component';
import { FavoritesService } from '../services/favorites.service';
import { XtreamStore } from '../xtream.store';
import { XtreamStore } from '../stores/xtream.store';
@Component({
selector: 'app-live-stream-layout',
@@ -11,7 +11,7 @@ import { selectPlaylistById } from 'm3u-state';
import { Playlist } from 'shared-interfaces';
import { SettingsComponent } from '../../settings/settings.component';
import { AccountInfoComponent } from '../account-info/account-info.component';
import { XtreamStore } from '../xtream.store';
import { XtreamStore } from '../stores/xtream.store';
import { NavigationItem } from './navigation.interface';
@Component({
@@ -26,7 +26,7 @@ import { FilterPipe } from '@iptvnator/pipes';
import { TranslatePipe } from '@ngx-translate/core';
import { XtreamCategory, XtreamItem } from 'shared-interfaces';
import { FavoritesService } from '../services/favorites.service';
import { XtreamStore } from '../xtream.store';
import { XtreamStore } from '../stores/xtream.store';
interface EpgProgram {
id: string;
@@ -6,7 +6,7 @@ import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog';
import { MatIcon } from '@angular/material/icon';
import { ActivatedRoute, Router } from '@angular/router';
import groupBy from 'lodash/groupBy';
import { XtreamStore } from '../xtream.store';
import { XtreamStore } from '../stores/xtream.store';
@Component({
selector: 'app-recently-viewed',
@@ -20,6 +20,9 @@ import { XtreamStore } from '../xtream.store';
MatIconButton,
TitleCasePipe,
],
providers: [
XtreamStore
],
templateUrl: './recently-viewed.component.html',
styleUrl: './recently-viewed.component.scss',
})
@@ -25,10 +25,11 @@ import { ActivatedRoute, Router } from '@angular/router';
import { TranslatePipe } from '@ngx-translate/core';
import groupBy from 'lodash/groupBy';
import { DatabaseService } from 'services';
import { XtreamItem } from 'shared-interfaces';
import { XtreamContentItem } from '../data-sources/xtream-data-source.interface';
import { ContentType } from '../xtream-state';
import { SearchFormComponent } from '../../shared/components/search-form/search-form.component';
import { SearchResultItemComponent } from '../../shared/components/search-result-item/search-result-item.component';
import { XtreamStore } from '../xtream.store';
import { XtreamStore } from '../stores/xtream.store';
interface SearchResultsData {
isGlobalSearch: boolean;
@@ -50,6 +51,9 @@ interface SearchResultsData {
SearchResultItemComponent,
TranslatePipe,
],
providers: [
XtreamStore
],
templateUrl: './search-results.component.html',
styleUrls: ['./search-results.component.scss'],
})
@@ -138,9 +142,7 @@ export class SearchResultsComponent implements AfterViewInit {
}
}
selectItem(
item: XtreamItem & { playlist_id?: string; playlist_name?: string }
) {
selectItem(item: XtreamContentItem) {
if (this.isGlobalSearch && item.playlist_id) {
this.dialogRef?.close();
const type = item.type === 'movie' ? 'vod' : item.type;
@@ -152,7 +154,7 @@ export class SearchResultsComponent implements AfterViewInit {
item.xtream_id,
]);
} else {
const type = item.type === 'movie' ? 'vod' : item.type;
const type = (item.type === 'movie' ? 'vod' : item.type) as ContentType;
this.xtreamStore.resetSearchResults();
this.xtreamStore.setSelectedContentType(type);
@@ -72,5 +72,7 @@
(episodeClicked)="playEpisode($event)"
/>
</app-content-hero>
} @else {
<app-content-hero [isLoading]="true" (backClicked)="goBack()" />
}
@@ -6,7 +6,7 @@ import { TranslatePipe } from '@ngx-translate/core';
import { ContentHeroComponent } from 'components';
import { XtreamSerieEpisode } from 'shared-interfaces';
import { SeasonContainerComponent } from '../season-container/season-container.component';
import { XtreamStore } from '../xtream.store';
import { XtreamStore } from '../stores/xtream.store';
@Component({
selector: 'app-serial-details',
@@ -0,0 +1,2 @@
export * from './xtream-api.service';
export * from './xtream-url.service';
@@ -0,0 +1,259 @@
import { inject, Injectable } from '@angular/core';
import { DataService } from 'services';
import {
EpgItem,
XtreamCategory,
XtreamCodeActions,
XtreamLiveStream,
XtreamSerieDetails,
XtreamSerieItem,
XtreamVodDetails,
XtreamVodStream,
} from 'shared-interfaces';
import { XtreamAccountInfo } from '../account-info/account-info.interface';
/**
* Xtream API credentials
*/
export interface XtreamCredentials {
serverUrl: string;
username: string;
password: string;
}
/**
* Category type mapping for API calls
*/
export type CategoryType = 'live' | 'vod' | 'series';
/**
* Stream type mapping for API calls
*/
export type StreamType = 'live' | 'movie' | 'series';
/**
* Raw EPG listing from API (before decoding)
*/
interface RawEpgListing {
title: string;
description: string;
start: string;
end: string;
start_timestamp: string;
stop_timestamp: string;
[key: string]: unknown;
}
/**
* EPG API response
*/
interface EpgResponse {
epg_listings?: RawEpgListing[];
}
/**
* Service for all Xtream Codes API interactions.
* Extracts API communication logic from the XtreamStore.
*/
@Injectable({ providedIn: 'root' })
export class XtreamApiService {
private readonly dataService = inject(DataService);
/**
* Get account/portal status
*/
async getAccountInfo(
credentials: XtreamCredentials
): Promise<XtreamAccountInfo> {
return this.dataService.fetchData(
`${credentials.serverUrl}/player_api.php`,
{
username: credentials.username,
password: credentials.password,
action: 'get_account_info',
}
);
}
/**
* Get categories by type
*/
async getCategories(
credentials: XtreamCredentials,
type: CategoryType
): Promise<XtreamCategory[]> {
const actionMap: Record<CategoryType, XtreamCodeActions> = {
live: XtreamCodeActions.GetLiveCategories,
vod: XtreamCodeActions.GetVodCategories,
series: XtreamCodeActions.GetSeriesCategories,
};
const response = await this.dataService.fetchData(
`${credentials.serverUrl}/player_api.php`,
{
action: actionMap[type],
username: credentials.username,
password: credentials.password,
}
);
return Array.isArray(response) ? response : [];
}
/**
* Get live streams
*/
async getLiveStreams(
credentials: XtreamCredentials
): Promise<XtreamLiveStream[]> {
const response = await this.dataService.fetchData(
`${credentials.serverUrl}/player_api.php`,
{
action: XtreamCodeActions.GetLiveStreams,
username: credentials.username,
password: credentials.password,
}
);
return Array.isArray(response) ? response : [];
}
/**
* Get VOD streams
*/
async getVodStreams(
credentials: XtreamCredentials
): Promise<XtreamVodStream[]> {
const response = await this.dataService.fetchData(
`${credentials.serverUrl}/player_api.php`,
{
action: XtreamCodeActions.GetVodStreams,
username: credentials.username,
password: credentials.password,
}
);
return Array.isArray(response) ? response : [];
}
/**
* Get series items
*/
async getSeriesStreams(
credentials: XtreamCredentials
): Promise<XtreamSerieItem[]> {
const response = await this.dataService.fetchData(
`${credentials.serverUrl}/player_api.php`,
{
action: XtreamCodeActions.GetSeries,
username: credentials.username,
password: credentials.password,
}
);
return Array.isArray(response) ? response : [];
}
/**
* Get streams by type (unified method)
*/
async getStreams(
credentials: XtreamCredentials,
type: StreamType
): Promise<XtreamLiveStream[] | XtreamVodStream[] | XtreamSerieItem[]> {
switch (type) {
case 'live':
return this.getLiveStreams(credentials);
case 'movie':
return this.getVodStreams(credentials);
case 'series':
return this.getSeriesStreams(credentials);
}
}
/**
* Get VOD details
*/
async getVodInfo(
credentials: XtreamCredentials,
vodId: string | number
): Promise<XtreamVodDetails> {
return this.dataService.fetchData(
`${credentials.serverUrl}/player_api.php`,
{
action: XtreamCodeActions.GetVodInfo,
username: credentials.username,
password: credentials.password,
vod_id: vodId,
}
);
}
/**
* Get series details
*/
async getSeriesInfo(
credentials: XtreamCredentials,
seriesId: string | number
): Promise<XtreamSerieDetails> {
return this.dataService.fetchData(
`${credentials.serverUrl}/player_api.php`,
{
action: XtreamCodeActions.GetSeriesInfo,
username: credentials.username,
password: credentials.password,
series_id: seriesId,
}
);
}
/**
* Get short EPG for a stream
* Returns decoded EPG items (title and description are base64 decoded)
*/
async getShortEpg(
credentials: XtreamCredentials,
streamId: number,
limit: number = 10
): Promise<EpgItem[]> {
const response: EpgResponse = await this.dataService.fetchData(
`${credentials.serverUrl}/player_api.php`,
{
action: XtreamCodeActions.GetShortEpg,
username: credentials.username,
password: credentials.password,
stream_id: streamId,
limit,
}
);
if (!response?.epg_listings || !Array.isArray(response.epg_listings)) {
return [];
}
return response.epg_listings.map((item) => ({
...item,
title: this.decodeBase64Unicode(item.title).trim(),
description: this.decodeBase64Unicode(item.description).trim(),
})) as EpgItem[];
}
/**
* Decode base64 unicode string (used for EPG data)
*/
private decodeBase64Unicode(str: string): string {
try {
return decodeURIComponent(
Array.prototype.map
.call(atob(str), (c: string) => {
return (
'%' + ('00' + c.charCodeAt(0).toString(16)).slice(-2)
);
})
.join('')
);
} catch {
return str;
}
}
}
@@ -0,0 +1,75 @@
import { inject, Injectable } from '@angular/core';
import {
XtreamSerieEpisode,
XtreamVodDetails,
} from 'shared-interfaces';
import { SettingsStore } from '../../services/settings-store.service';
import { XtreamCredentials } from './xtream-api.service';
/**
* Extended playlist with optional HTTP headers
*/
export interface XtreamPlaylistWithHeaders extends XtreamCredentials {
id: string;
name: string;
type?: string;
userAgent?: string;
referrer?: string;
origin?: string;
}
/**
* Live stream item with xtream_id
*/
export interface LiveStreamItem {
xtream_id: number;
[key: string]: unknown;
}
/**
* Service for constructing Xtream stream URLs.
* Handles URL construction for live streams, VOD, and series episodes.
*/
@Injectable({ providedIn: 'root' })
export class XtreamUrlService {
private readonly settingsStore = inject(SettingsStore);
/**
* Construct live stream URL
* Format: {serverUrl}/live/{username}/{password}/{streamId}.{format}
*/
constructLiveUrl(
credentials: XtreamCredentials,
xtreamId: number,
format?: string
): string {
const streamFormat =
format ?? this.settingsStore.streamFormat() ?? 'ts';
return `${credentials.serverUrl}/live/${credentials.username}/${credentials.password}/${xtreamId}.${streamFormat}`;
}
/**
* Construct VOD stream URL
* Format: {serverUrl}/movie/{username}/{password}/{streamId}.{extension}
*/
constructVodUrl(
credentials: XtreamCredentials,
vodItem: XtreamVodDetails
): string {
const streamId =
vodItem.movie_data.stream_id ?? (vodItem as any).stream_id;
const extension = vodItem.movie_data.container_extension;
return `${credentials.serverUrl}/movie/${credentials.username}/${credentials.password}/${streamId}.${extension}`;
}
/**
* Construct series episode stream URL
* Format: {serverUrl}/series/{username}/{password}/{episodeId}.{extension}
*/
constructEpisodeUrl(
credentials: XtreamCredentials,
episode: XtreamSerieEpisode
): string {
return `${credentials.serverUrl}/series/${credentials.username}/${credentials.password}/${episode.id}.${episode.container_extension}`;
}
}
@@ -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,280 @@
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;
isLoadingDetails: boolean;
}
/**
* Initial selection state
*/
const initialSelectionState: SelectionState = {
selectedContentType: 'vod',
selectedCategoryId: null,
selectedItem: null,
page: 0,
limit: Number(localStorage.getItem('xtream-page-size') ?? 25),
isLoadingDetails: false,
};
/**
* 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 loading details state
*/
setIsLoadingDetails(isLoading: boolean): void {
patchState(store, { isLoadingDetails: isLoading });
},
/**
* 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';
@@ -0,0 +1,231 @@
import { computed, inject } from '@angular/core';
import {
signalStore,
withComputed,
withMethods,
} from '@ngrx/signals';
import { Store } from '@ngrx/store';
import { selectActivePlaylist } from 'm3u-state';
import { DataService } from 'services';
import {
XtreamCodeActions,
XtreamSerieDetails,
XtreamVodDetails,
} from 'shared-interfaces';
// Import existing features that are already separate
import { withFavorites } from '../with-favorites.feature';
import { withRecentItems } from '../with-recent-items';
// Import new feature stores
import {
withPortal,
withContent,
withSelection,
withSearch,
withEpg,
withPlayer,
} from './features';
/**
* XtreamStore - Facade composing all feature stores.
*
* This store provides a unified API for components while delegating
* to specialized feature stores for different concerns:
*
* - withPortal: Playlist and portal status management
* - withContent: Categories and streams management
* - withSelection: UI selection and pagination
* - withSearch: Search functionality
* - withEpg: EPG (Electronic Program Guide) data
* - withPlayer: Stream URL construction and player integration
* - withFavorites: Favorites management
* - withRecentItems: Recently viewed items
*
* @see docs/XTREAM_STORE_REFACTORING_PLAN.md
*/
export const XtreamStore = signalStore(
// Note: NOT providedIn: 'root' because shell provides it
// This matches the original behavior
// Compose all features
withPortal(),
withContent(),
withSelection(),
withSearch(),
withEpg(),
withPlayer(),
withFavorites(),
withRecentItems(),
// Cross-feature computed properties
withComputed((store) => ({
/**
* Get global recent items (from withRecentItems)
*/
globalRecentItems: computed(() => {
return store.recentItems();
}),
/**
* Alias for importCount for backward compatibility
*/
getImportCount: computed(() => store.importCount()),
})),
// Cross-feature methods & orchestration
withMethods((store) => {
const dataService = inject(DataService);
const ngrxStore = inject(Store);
return {
/**
* Full store reset for switching between playlists
*/
resetStore(newPlaylistId?: string): void {
store.resetPortal();
store.resetContent();
store.resetSelection();
store.resetSearchResults();
store.clearEpg();
store.resetPlayer();
if (newPlaylistId) {
store.setPlaylistId(newPlaylistId);
}
},
/**
* Initialize the store for a playlist
*/
async initialize(): Promise<void> {
await store.fetchPlaylist();
await store.checkPortalStatus();
await store.initializeContent();
},
/**
* Fetch Xtream playlist (convenience alias)
*/
async fetchXtreamPlaylist(): Promise<void> {
await store.fetchPlaylist();
},
/**
* Fetch VOD details with metadata
* Accepts object format for backward compatibility with rxMethod callers
*/
fetchVodDetailsWithMetadata(
params: { vodId: string; categoryId: number }
): void {
const playlist = ngrxStore.selectSignal(selectActivePlaylist)();
if (!playlist) return;
store.setIsLoadingDetails(true);
dataService.fetchData(
`${playlist.serverUrl}/player_api.php`,
{
action: XtreamCodeActions.GetVodInfo,
username: playlist.username,
password: playlist.password,
vod_id: params.vodId,
}
).then((vodDetails: XtreamVodDetails) => {
store.setSelectedCategory(params.categoryId);
store.setSelectedItem({
...vodDetails,
stream_id: params.vodId,
});
}).catch((error: unknown) => {
console.error('Error fetching VOD details:', error);
}).finally(() => {
store.setIsLoadingDetails(false);
});
},
/**
* Fetch series details with metadata
* Accepts object format for backward compatibility with rxMethod callers
*/
fetchSerialDetailsWithMetadata(
params: { serialId: string; categoryId: number }
): void {
const playlist = ngrxStore.selectSignal(selectActivePlaylist)();
if (!playlist) return;
store.setIsLoadingDetails(true);
dataService.fetchData(
`${playlist.serverUrl}/player_api.php`,
{
action: XtreamCodeActions.GetSeriesInfo,
username: playlist.username,
password: playlist.password,
series_id: params.serialId,
}
).then((serialDetails: XtreamSerieDetails) => {
store.setSelectedCategory(params.categoryId);
store.setSelectedItem({
...serialDetails,
series_id: params.serialId,
});
}).catch((error: unknown) => {
console.error('Error fetching series details:', error);
}).finally(() => {
store.setIsLoadingDetails(false);
});
},
/**
* Legacy method stubs for backward compatibility
*/
createLinkToPlayVod(): void {
// No-op, kept for compatibility
},
addToFavorites(item: unknown): void {
console.log('Legacy addToFavorites called', item);
},
removeFromFavorites(favoriteId: string): void {
console.log('Legacy removeFromFavorites called', favoriteId);
},
// Alias methods for backward compatibility
fetchLiveCategories(): void {
store.fetchAllCategories();
},
fetchVodCategories(): void {
store.fetchAllCategories();
},
fetchSerialCategories(): void {
store.fetchAllCategories();
},
fetchLiveStreams(): void {
store.fetchAllContent();
},
fetchVodStreams(): void {
store.fetchAllContent();
},
fetchSerialStreams(): void {
store.fetchAllContent();
},
/**
* Search content wrapper for rxMethod compatibility
* Can be called with object { term, types } or direct params
*/
searchContent(params: { term: string; types: string[] }): void {
// Call the underlying search method from withSearch
(store as any).searchContent(params.term, params.types);
},
};
})
);
// Type alias for the store
export type XtreamStoreType = InstanceType<typeof XtreamStore>;
@@ -100,5 +100,7 @@
</div>
}
</app-content-hero>
} @else {
<app-content-hero [isLoading]="true" (backClicked)="goBack()" />
}
@@ -7,7 +7,7 @@ import { TranslateModule } from '@ngx-translate/core';
import { ContentHeroComponent } from 'components';
import { XtreamVodDetails } from 'shared-interfaces';
import { SettingsStore } from '../../services/settings-store.service';
import { XtreamStore } from '../xtream.store';
import { XtreamStore } from '../stores/xtream.store';
import { SafePipe } from './safe.pipe';
@Component({
@@ -24,7 +24,7 @@
</div>
<div class="main-content">
@if (!xtreamStore.selectedItem()) {
@if (!xtreamStore.selectedItem() && !xtreamStore.isLoadingDetails()) {
<div class="content-header">
<h2>
<button mat-icon-button disabled>
@@ -36,7 +36,7 @@
}
<div
class="scrollable-content"
[class.no-header]="xtreamStore.selectedItem()"
[class.no-header]="xtreamStore.selectedItem() || xtreamStore.isLoadingDetails()"
>
<router-outlet />
</div>
@@ -12,7 +12,7 @@ import {
CategoryManagementDialogData,
} from './category-management-dialog/category-management-dialog.component';
import { CategoryViewComponent } from './category-view/category-view.component';
import { XtreamStore } from './xtream.store';
import { XtreamStore } from './stores/xtream.store';
@Component({
selector: 'app-xtream-main-container',
@@ -7,7 +7,7 @@ import { PlaylistActions } from 'm3u-state';
import { LoadingOverlayComponent } from '../loading-overlay/loading-overlay.component';
import { NavigationComponent } from '../navigation/navigation.component';
import { NavigationItem } from '../navigation/navigation.interface';
import { XtreamStore } from '../xtream.store';
import { XtreamStore } from '../stores/xtream.store';
@Component({
templateUrl: './xtream-shell.component.html',
@@ -54,7 +54,7 @@ export class XtreamShellComponent {
constructor() {
// Subscribe to route params to handle switching between playlists
this.route.params.pipe(takeUntilDestroyed()).subscribe((params) => {
this.route.params.pipe(takeUntilDestroyed()).subscribe(async (params) => {
const newPlaylistId = params['id'];
if (this.isFirstLoad) {
@@ -72,8 +72,11 @@ export class XtreamShellComponent {
playlistId: newPlaylistId,
})
);
this.xtreamStore.fetchXtreamPlaylist();
this.xtreamStore.checkPortalStatus();
// Must await fetchXtreamPlaylist before checking status
// because checkPortalStatus needs currentPlaylist to be set
await this.xtreamStore.fetchXtreamPlaylist();
await this.xtreamStore.checkPortalStatus();
});
effect(
@@ -1,968 +0,0 @@
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);
}
},
};
}
)
);
+23 -6
View File
@@ -84,10 +84,21 @@ Added `reloadCategories()` method to refresh categories from database after visi
## Behavior Notes
- **New categories**: When a playlist is refreshed, new categories from the remote API are added with `hidden = false` (visible by default)
- **Persistence**: Visibility settings survive playlist refresh
- **Persistence**: Visibility settings survive playlist refresh (see below)
- **Per-playlist, per-type**: Categories are managed per playlist and per content type (live/movies/series)
- **No content deletion**: Hiding a category only affects sidebar visibility; the category and its content remain in the database
### Visibility Preservation During Refresh
When a user refreshes an Xtream playlist, hidden category preferences are preserved through the following mechanism:
1. **Before deletion**: The `DB_DELETE_XTREAM_CONTENT` handler extracts and returns the `hidden` status of all categories (keyed by `xtreamId` and `type`)
2. **Temporary storage**: The hidden categories are stored in `localStorage` under key `xtream-restore-{playlistId}` along with favorites and recently viewed data
3. **During re-import**: When categories are saved via `DB_SAVE_CATEGORIES`, the data source checks `localStorage` for saved hidden category xtreamIds
4. **Restoration**: Categories matching the saved xtreamIds are inserted with `hidden = true`, preserving the user's visibility preferences
This ensures that users don't lose their category visibility customizations when refreshing playlists to get updated content.
## Files Changed
```
@@ -96,17 +107,23 @@ libs/shared/database/src/lib/
└── connection.ts # Added migration for existing databases
apps/electron-backend/src/app/
├── events/database/category.events.ts # New IPC handlers
└── api/main.preload.ts # Exposed new IPC methods
├── events/database/category.events.ts # IPC handlers (including hidden category restoration)
├── events/database/xtream.events.ts # Returns hidden categories during content deletion
└── api/main.preload.ts # Exposed new IPC methods (with hidden category params)
libs/services/src/lib/
└── database-electron.service.ts # New service methods
└── database-electron.service.ts # Service methods (with hidden category support)
libs/ui/components/src/lib/recent-playlists/
└── recent-playlists.component.ts # Stores hidden categories to localStorage on refresh
apps/web/src/app/xtream-tauri/
├── category-management-dialog/ # New dialog component
├── category-management-dialog/ # Dialog component
│ ├── category-management-dialog.component.ts
│ ├── category-management-dialog.component.html
│ └── category-management-dialog.component.scss
├── data-sources/
│ └── electron-xtream-data-source.ts # Reads/passes hidden categories on save
├── xtream-main-container.component.ts # Added button & dialog
├── xtream-main-container.component.html
├── live-stream-layout/
@@ -118,7 +135,7 @@ apps/web/src/app/xtream-tauri/
apps/web/src/assets/i18n/
└── en.json # Added translation keys
global.d.ts # TypeScript types for new IPC methods
global.d.ts # TypeScript types for IPC methods
```
## Translation Keys
Vendored
+6 -1
View File
@@ -101,6 +101,10 @@ declare global {
xtreamId: number;
viewedAt: string;
}[];
hiddenCategories: {
xtreamId: number;
type: string;
}[];
}>;
dbRestoreXtreamUserData: (
playlistId: string,
@@ -121,7 +125,8 @@ declare global {
dbSaveCategories: (
playlistId: string,
categories: any[],
type: string
type: string,
hiddenCategoryXtreamIds?: number[]
) => Promise<{ success: boolean }>;
dbGetAllCategories: (
playlistId: string,
@@ -68,12 +68,13 @@ export class DatabaseService {
/**
* Delete all content and categories for an Xtream playlist (for refresh)
* Keeps the playlist entry but removes all imported data
* Returns saved favorites and recently viewed xtreamIds for restoration
* Returns saved favorites, recently viewed xtreamIds, and hidden categories for restoration
*/
async deleteXtreamPlaylistContent(playlistId: string): Promise<{
success: boolean;
favoritedXtreamIds: number[];
recentlyViewedXtreamIds: { xtreamId: number; viewedAt: string }[];
hiddenCategories: { xtreamId: number; type: string }[];
}> {
return await window.electron.dbDeleteXtreamContent(playlistId);
}
@@ -160,13 +161,20 @@ export class DatabaseService {
/**
* Save categories in bulk
* Optionally accepts hidden category xtreamIds to restore visibility preferences
*/
async saveXtreamCategories(
playlistId: string,
categories: any[],
type: 'live' | 'movies' | 'series'
type: 'live' | 'movies' | 'series',
hiddenCategoryXtreamIds?: number[]
): Promise<void> {
await window.electron.dbSaveCategories(playlistId, categories, type);
await window.electron.dbSaveCategories(
playlistId,
categories,
type,
hiddenCategoryXtreamIds
);
}
/**
@@ -4,69 +4,148 @@
<button class="hero__back-button" (click)="onBack()">
<mat-icon>arrow_back</mat-icon>
</button>
<!-- Backdrop Image -->
<div
class="hero__backdrop"
[class.hero__backdrop--blurred]="!backdropUrl"
[style.backgroundImage]="
backdropUrl
? 'url(' + backdropUrl + ')'
: posterUrl
? 'url(' + posterUrl + ')'
: 'none'
"
></div>
<!-- Gradient Vignette Overlay -->
<div class="hero__vignette"></div>
<!-- Hero Content -->
<div class="hero__content">
<!-- Poster Card -->
<div class="poster">
@if (posterUrl) {
<img
class="poster__image"
[src]="posterUrl"
(error)="
$event.target.src = './assets/images/default-poster.png'
"
alt="Poster"
@if (isLoading) {
<div class="hero__content">
<!-- Skeleton Poster -->
<div class="poster">
<ngx-skeleton-loader
count="1"
[theme]="{
height: '100%',
width: '100%',
'border-radius': '8px',
'aspect-ratio': '2/3',
display: 'block'
}"
/>
} @else {
<div class="poster__placeholder">No Poster</div>
}
</div>
<!-- Skeleton Details -->
<div class="details">
<!-- Title -->
<ngx-skeleton-loader
count="1"
[theme]="{
height: '40px',
width: '60%',
'margin-bottom': '16px',
display: 'block'
}"
/>
<!-- Tags -->
<div style="display: flex; gap: 8px; margin-bottom: 16px">
<ngx-skeleton-loader
count="3"
[theme]="{
height: '24px',
width: '60px',
display: 'block'
}"
/>
</div>
<!-- Description -->
<ngx-skeleton-loader
count="3"
[theme]="{
height: '16px',
'margin-bottom': '8px',
width: '100%',
display: 'block'
}"
/>
<!-- Meta -->
<div style="margin-top: 20px; display: flex; gap: 32px">
<ngx-skeleton-loader
count="2"
[theme]="{
height: '40px',
width: '100px',
display: 'block'
}"
/>
</div>
<!-- Buttons -->
<div style="margin-top: 24px; display: flex; gap: 12px">
<ngx-skeleton-loader
count="2"
[theme]="{
height: '48px',
width: '140px',
display: 'block'
}"
/>
</div>
</div>
</div>
} @else {
<!-- Backdrop Image -->
<div
class="hero__backdrop"
[class.hero__backdrop--blurred]="!backdropUrl"
[style.backgroundImage]="
backdropUrl
? 'url(' + backdropUrl + ')'
: posterUrl
? 'url(' + posterUrl + ')'
: 'none'
"
></div>
<!-- Details -->
<div class="details">
<h1 class="details__title">{{ title }}</h1>
<!-- Gradient Vignette Overlay -->
<div class="hero__vignette"></div>
<!-- Tags Slot -->
<div class="details__tags">
<ng-content select="[hero-tags]"></ng-content>
<!-- Hero Content -->
<div class="hero__content">
<!-- Poster Card -->
<div class="poster">
@if (posterUrl) {
<img
class="poster__image"
[src]="posterUrl"
(error)="
$event.target.src =
'./assets/images/default-poster.png'
"
alt="Poster"
/>
} @else {
<div class="poster__placeholder">No Poster</div>
}
</div>
<!-- Description -->
@if (description) {
<p class="details__description">
{{ description }}
</p>
}
<!-- Details -->
<div class="details">
<h1 class="details__title">{{ title }}</h1>
<!-- Metadata Grid Slot -->
<div class="details__meta">
<ng-content select="[hero-meta]"></ng-content>
<!-- Tags Slot -->
<div class="details__tags">
<ng-content select="[hero-tags]"></ng-content>
</div>
<!-- Description -->
@if (description) {
<p class="details__description">
{{ description }}
</p>
}
<!-- Metadata Grid Slot -->
<div class="details__meta">
<ng-content select="[hero-meta]"></ng-content>
</div>
<!-- Action Buttons Slot -->
<div class="action-buttons">
<ng-content select="[hero-actions]"></ng-content>
</div>
<!-- Additional Content (Seasons, Trailers, etc.) -->
<ng-content></ng-content>
</div>
<!-- Action Buttons Slot -->
<div class="action-buttons">
<ng-content select="[hero-actions]"></ng-content>
</div>
<!-- Additional Content (Seasons, Trailers, etc.) -->
<ng-content></ng-content>
</div>
</div>
</section>
}
</section>
@@ -471,3 +471,30 @@
.hero__content {
animation: fadeInUp 0.6s ease-out 0.2s both;
}
// Skeleton Loader Overrides
ngx-skeleton-loader {
display: block;
width: 100%;
// Ensure the loader inside poster takes up the correct aspect ratio space
::ng-deep .loader {
display: block;
height: 100%;
}
}
:host-context(.dark-theme) {
ngx-skeleton-loader {
::ng-deep .loader {
background-color: rgba(255, 255, 255, 0.1);
}
}
}
.poster {
// ... existing styles ...
// Ensure poster container has height/ratio for skeleton
min-height: 330px; // Fallback
aspect-ratio: 2 / 3;
}
@@ -2,11 +2,17 @@ import { CommonModule } from '@angular/common';
import { Component, EventEmitter, Input, Output } from '@angular/core';
import { MatButtonModule } from '@angular/material/button';
import { MatIconModule } from '@angular/material/icon';
import { NgxSkeletonLoaderComponent } from 'ngx-skeleton-loader';
@Component({
selector: 'app-content-hero',
standalone: true,
imports: [CommonModule, MatIconModule, MatButtonModule],
imports: [
CommonModule,
MatIconModule,
MatButtonModule,
NgxSkeletonLoaderComponent,
],
templateUrl: './content-hero.component.html',
styleUrls: ['./content-hero.component.scss'],
})
@@ -15,6 +21,7 @@ export class ContentHeroComponent {
@Input() description: string | undefined;
@Input() posterUrl: string | undefined;
@Input() backdropUrl: string | undefined;
@Input() isLoading = false;
@Output() backClicked = new EventEmitter<void>();
@@ -222,11 +222,14 @@ export class RecentPlaylistsComponent {
onConfirm: async () => {
try {
// Delete all content and categories for this playlist
// This returns the xtreamIds of favorites and recently viewed items
const { favoritedXtreamIds, recentlyViewedXtreamIds } =
await this.databaseService.deleteXtreamPlaylistContent(
item._id
);
// This returns the xtreamIds of favorites, recently viewed items, and hidden categories
const {
favoritedXtreamIds,
recentlyViewedXtreamIds,
hiddenCategories,
} = await this.databaseService.deleteXtreamPlaylistContent(
item._id
);
const updateDate = Date.now();
@@ -250,6 +253,7 @@ export class RecentPlaylistsComponent {
JSON.stringify({
favoritedXtreamIds,
recentlyViewedXtreamIds,
hiddenCategories,
})
);