mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-08 17:06:15 -08:00
Merge pull request #724 from 4gray/refactor/store-refactoring
refactor/store-refactoring
This commit is contained in:
44 files changed
+3287
-1070
No files matched your search
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
+1
-1
@@ -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({
|
||||
|
||||
+1
-1
@@ -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);
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
)
|
||||
);
|
||||
@@ -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
@@ -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,
|
||||
})
|
||||
);
|
||||
|
||||
|
||||
Reference in new issue
Block a user