mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-08 17:06:15 -08:00
refactor: add Xtream data source abstraction
This commit is contained in:
1 parent
aa27e6da0d
commit
3a2ee74b96
4 files changed
+1151
No files matched your search
@@ -0,0 +1,281 @@
|
||||
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) {
|
||||
await this.dbService.saveXtreamCategories(
|
||||
playlistId,
|
||||
remoteData,
|
||||
dbType
|
||||
);
|
||||
}
|
||||
|
||||
// Return from cache (now populated)
|
||||
return this.dbService.getXtreamCategories(playlistId, dbType);
|
||||
}
|
||||
|
||||
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'
|
||||
);
|
||||
Reference in new issue
Block a user