From 3a2ee74b96599655b86d246da0a3bf390888d131 Mon Sep 17 00:00:00 2001 From: 4gray Date: Sun, 11 Jan 2026 06:26:38 +0100 Subject: [PATCH] refactor: add Xtream data source abstraction --- .../electron-xtream-data-source.ts | 281 ++++++++++ .../app/xtream-tauri/data-sources/index.ts | 39 ++ .../data-sources/pwa-xtream-data-source.ts | 511 ++++++++++++++++++ .../xtream-data-source.interface.ts | 320 +++++++++++ 4 files changed, 1151 insertions(+) create mode 100644 apps/web/src/app/xtream-tauri/data-sources/electron-xtream-data-source.ts create mode 100644 apps/web/src/app/xtream-tauri/data-sources/index.ts create mode 100644 apps/web/src/app/xtream-tauri/data-sources/pwa-xtream-data-source.ts create mode 100644 apps/web/src/app/xtream-tauri/data-sources/xtream-data-source.interface.ts diff --git a/apps/web/src/app/xtream-tauri/data-sources/electron-xtream-data-source.ts b/apps/web/src/app/xtream-tauri/data-sources/electron-xtream-data-source.ts new file mode 100644 index 000000000..2ee794e14 --- /dev/null +++ b/apps/web/src/app/xtream-tauri/data-sources/electron-xtream-data-source.ts @@ -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 { + const playlist = await this.dbService.getPlaylistById(playlistId); + return playlist as XtreamPlaylistData | null; + } + + async createPlaylist(playlist: XtreamPlaylistData): Promise { + 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 + ): Promise { + await this.dbService.updateXtreamPlaylistDetails({ + id: playlistId, + title: updates.name, + username: updates.username, + password: updates.password, + serverUrl: updates.serverUrl, + }); + } + + async deletePlaylist(playlistId: string): Promise { + await this.dbService.deletePlaylist(playlistId); + } + + // ========================================================================= + // Category Operations + // ========================================================================= + + async hasCategories( + playlistId: string, + type: DbCategoryType + ): Promise { + return this.dbService.hasXtreamCategories(playlistId, type); + } + + async getCategories( + playlistId: string, + credentials: XtreamCredentials, + type: CategoryType + ): Promise { + 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 { + return this.dbService.getAllXtreamCategories(playlistId, type); + } + + async saveCategories( + playlistId: string, + categories: XtreamCategory[], + type: DbCategoryType + ): Promise { + await this.dbService.saveXtreamCategories(playlistId, categories, type); + } + + async updateCategoryVisibility( + categoryIds: number[], + hidden: boolean + ): Promise { + await this.dbService.updateCategoryVisibility(categoryIds, hidden); + } + + // ========================================================================= + // Content/Stream Operations + // ========================================================================= + + async hasContent( + playlistId: string, + type: 'live' | 'movie' | 'series' + ): Promise { + return this.dbService.hasXtreamContent(playlistId, type); + } + + async getContent( + playlistId: string, + credentials: XtreamCredentials, + type: StreamType + ): Promise { + // 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 { + return this.dbService.saveXtreamContent( + playlistId, + streams, + type, + onProgress + ); + } + + // ========================================================================= + // Search Operations + // ========================================================================= + + async searchContent( + playlistId: string, + searchTerm: string, + types: string[] + ): Promise { + return this.dbService.searchXtreamContent(playlistId, searchTerm, types); + } + + // ========================================================================= + // Favorites Operations + // ========================================================================= + + async getFavorites(playlistId: string): Promise { + return this.dbService.getFavorites(playlistId); + } + + async addFavorite(contentId: number, playlistId: string): Promise { + await this.dbService.addToFavorites(contentId, playlistId); + } + + async removeFavorite(contentId: number, playlistId: string): Promise { + await this.dbService.removeFromFavorites(contentId, playlistId); + } + + async isFavorite(contentId: number, playlistId: string): Promise { + return this.dbService.isFavorite(contentId, playlistId); + } + + // ========================================================================= + // Recently Viewed Operations + // ========================================================================= + + async getRecentItems(playlistId: string): Promise { + return this.dbService.getRecentItems(playlistId); + } + + async addRecentItem(contentId: number, playlistId: string): Promise { + await this.dbService.addRecentItem(contentId, playlistId); + } + + async removeRecentItem( + contentId: number, + playlistId: string + ): Promise { + await this.dbService.removeRecentItem(contentId, playlistId); + } + + async clearRecentItems(playlistId: string): Promise { + await this.dbService.clearPlaylistRecentItems(playlistId); + } + + // ========================================================================= + // Content Lookup + // ========================================================================= + + async getContentByXtreamId( + xtreamId: number, + playlistId: string + ): Promise { + 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 { + await this.dbService.restoreXtreamUserData( + playlistId, + favoritedXtreamIds, + recentlyViewedXtreamIds + ); + } +} diff --git a/apps/web/src/app/xtream-tauri/data-sources/index.ts b/apps/web/src/app/xtream-tauri/data-sources/index.ts new file mode 100644 index 000000000..5686f3792 --- /dev/null +++ b/apps/web/src/app/xtream-tauri/data-sources/index.ts @@ -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, + }, + ]; +} diff --git a/apps/web/src/app/xtream-tauri/data-sources/pwa-xtream-data-source.ts b/apps/web/src/app/xtream-tauri/data-sources/pwa-xtream-data-source.ts new file mode 100644 index 000000000..20f82bae1 --- /dev/null +++ b/apps/web/src/app/xtream-tauri/data-sources/pwa-xtream-data-source.ts @@ -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(); + private contentCache = new Map(); + + // ========================================================================= + // Playlist Operations (localStorage) + // ========================================================================= + + async getPlaylist(playlistId: string): Promise { + const playlists = this.getPlaylistsFromStorage(); + return playlists.find((p) => p.id === playlistId) || null; + } + + async createPlaylist(playlist: XtreamPlaylistData): Promise { + const playlists = this.getPlaylistsFromStorage(); + playlists.push(playlist); + this.savePlaylistsToStorage(playlists); + } + + async updatePlaylist( + playlistId: string, + updates: Partial + ): Promise { + 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 { + 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 { + const cacheKey = `${playlistId}-${type}-categories`; + return this.categoryCache.has(cacheKey); + } + + async getCategories( + playlistId: string, + credentials: XtreamCredentials, + type: CategoryType + ): Promise { + 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 { + // PWA doesn't track hidden categories - return empty + return []; + } + + async saveCategories( + playlistId: string, + categories: XtreamCategory[], + type: DbCategoryType + ): Promise { + // 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 { + // 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 { + const cacheKey = `${playlistId}-${type}-content`; + return this.contentCache.has(cacheKey); + } + + async getContent( + playlistId: string, + credentials: XtreamCredentials, + type: StreamType + ): Promise { + 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 { + // 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 { + 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 { + 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 { + 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 { + 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 { + const allFavorites = this.getFavoritesFromStorage(); + return (allFavorites[playlistId] || []).includes(contentId); + } + + private getFavoritesFromStorage(): Record { + try { + const data = localStorage.getItem(STORAGE_KEYS.FAVORITES); + return data ? JSON.parse(data) : {}; + } catch { + return {}; + } + } + + private saveFavoritesToStorage( + favorites: Record + ): 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 { + 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 { + 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 { + 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 { + 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 + ): 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 { + 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 { + // 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(); + } +} diff --git a/apps/web/src/app/xtream-tauri/data-sources/xtream-data-source.interface.ts b/apps/web/src/app/xtream-tauri/data-sources/xtream-data-source.interface.ts new file mode 100644 index 000000000..9cfd4afb8 --- /dev/null +++ b/apps/web/src/app/xtream-tauri/data-sources/xtream-data-source.interface.ts @@ -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; + + /** + * Create a new playlist + */ + createPlaylist(playlist: XtreamPlaylistData): Promise; + + /** + * Update playlist details + */ + updatePlaylist( + playlistId: string, + updates: Partial + ): Promise; + + /** + * Delete a playlist and all its data + */ + deletePlaylist(playlistId: string): Promise; + + // ========================================================================= + // Category Operations + // ========================================================================= + + /** + * Check if categories exist for a playlist and type + */ + hasCategories( + playlistId: string, + type: DbCategoryType + ): Promise; + + /** + * Get categories for a playlist and type + * Returns only visible categories by default + */ + getCategories( + playlistId: string, + credentials: XtreamCredentials, + type: CategoryType + ): Promise; + + /** + * Get all categories including hidden (for management) + */ + getAllCategories( + playlistId: string, + type: DbCategoryType + ): Promise; + + /** + * Save categories in bulk + */ + saveCategories( + playlistId: string, + categories: XtreamCategory[], + type: DbCategoryType + ): Promise; + + /** + * Update category visibility + */ + updateCategoryVisibility( + categoryIds: number[], + hidden: boolean + ): Promise; + + // ========================================================================= + // Content/Stream Operations + // ========================================================================= + + /** + * Check if content exists for a playlist and type + */ + hasContent( + playlistId: string, + type: 'live' | 'movie' | 'series' + ): Promise; + + /** + * Get content/streams for a playlist and type + */ + getContent( + playlistId: string, + credentials: XtreamCredentials, + type: StreamType + ): Promise; + + /** + * Save content in bulk + */ + saveContent( + playlistId: string, + streams: any[], + type: 'live' | 'movie' | 'series', + onProgress?: ProgressCallback + ): Promise; + + // ========================================================================= + // Search Operations + // ========================================================================= + + /** + * Search content within a playlist + */ + searchContent( + playlistId: string, + searchTerm: string, + types: string[] + ): Promise; + + // ========================================================================= + // Favorites Operations + // ========================================================================= + + /** + * Get all favorites for a playlist + */ + getFavorites(playlistId: string): Promise; + + /** + * Add content to favorites + */ + addFavorite(contentId: number, playlistId: string): Promise; + + /** + * Remove content from favorites + */ + removeFavorite(contentId: number, playlistId: string): Promise; + + /** + * Check if content is favorited + */ + isFavorite(contentId: number, playlistId: string): Promise; + + // ========================================================================= + // Recently Viewed Operations + // ========================================================================= + + /** + * Get recently viewed items for a playlist + */ + getRecentItems(playlistId: string): Promise; + + /** + * Add item to recently viewed + */ + addRecentItem(contentId: number, playlistId: string): Promise; + + /** + * Remove item from recently viewed + */ + removeRecentItem(contentId: number, playlistId: string): Promise; + + /** + * Clear recently viewed for a playlist + */ + clearRecentItems(playlistId: string): Promise; + + // ========================================================================= + // Content Lookup + // ========================================================================= + + /** + * Get content by xtream ID + */ + getContentByXtreamId( + xtreamId: number, + playlistId: string + ): Promise; + + // ========================================================================= + // 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; +} + +/** + * Injection token for the data source + */ +export const XTREAM_DATA_SOURCE = new InjectionToken( + 'XtreamDataSource' +);