diff --git a/apps/electron-backend/src/app/api/main.preload.ts b/apps/electron-backend/src/app/api/main.preload.ts index 7fdfec469..ba4257ab3 100644 --- a/apps/electron-backend/src/app/api/main.preload.ts +++ b/apps/electron-backend/src/app/api/main.preload.ts @@ -56,4 +56,63 @@ contextBridge.exposeInMainWorld('electron', { ipcRenderer.invoke('SET_VLC_PLAYER_PATH', vlcPlayerPath), updateSettings: (settings: any) => ipcRenderer.invoke('SETTINGS_UPDATE', settings), + stalkerRequest: (payload: { + url: string; + macAddress: string; + params: Record; + }) => ipcRenderer.invoke('STALKER_REQUEST', payload), + xtreamRequest: (payload: { url: string; params: Record }) => + ipcRenderer.invoke('XTREAM_REQUEST', payload), + // Database operations + dbCreatePlaylist: (playlist: any) => + ipcRenderer.invoke('DB_CREATE_PLAYLIST', playlist), + dbGetPlaylist: (playlistId: string) => + ipcRenderer.invoke('DB_GET_PLAYLIST', playlistId), + dbUpdatePlaylist: (playlistId: string, updates: any) => + ipcRenderer.invoke('DB_UPDATE_PLAYLIST', playlistId, updates), + dbDeletePlaylist: (playlistId: string) => + ipcRenderer.invoke('DB_DELETE_PLAYLIST', playlistId), + dbDeleteXtreamContent: (playlistId: string) => + ipcRenderer.invoke('DB_DELETE_XTREAM_CONTENT', playlistId), + dbHasCategories: (playlistId: string, type: string) => + 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), + dbHasContent: (playlistId: string, type: string) => + ipcRenderer.invoke('DB_HAS_CONTENT', playlistId, type), + dbGetContent: (playlistId: string, type: string) => + ipcRenderer.invoke('DB_GET_CONTENT', playlistId, type), + dbSaveContent: (playlistId: string, streams: any[], type: string) => + ipcRenderer.invoke('DB_SAVE_CONTENT', playlistId, streams, type), + dbSearchContent: ( + playlistId: string, + searchTerm: string, + types: string[] + ) => ipcRenderer.invoke('DB_SEARCH_CONTENT', playlistId, searchTerm, types), + dbGlobalSearch: (searchTerm: string, types: string[]) => + ipcRenderer.invoke('DB_GLOBAL_SEARCH', searchTerm, types), + dbGetRecentlyViewed: () => ipcRenderer.invoke('DB_GET_RECENTLY_VIEWED'), + dbClearRecentlyViewed: () => ipcRenderer.invoke('DB_CLEAR_RECENTLY_VIEWED'), + // Favorites + dbAddFavorite: (contentId: number, playlistId: string) => + ipcRenderer.invoke('DB_ADD_FAVORITE', contentId, playlistId), + dbRemoveFavorite: (contentId: number, playlistId: string) => + ipcRenderer.invoke('DB_REMOVE_FAVORITE', contentId, playlistId), + dbIsFavorite: (contentId: number, playlistId: string) => + ipcRenderer.invoke('DB_IS_FAVORITE', contentId, playlistId), + dbGetFavorites: (playlistId: string) => + ipcRenderer.invoke('DB_GET_FAVORITES', playlistId), + // Recently viewed (playlist-specific) + dbGetRecentItems: (playlistId: string) => + ipcRenderer.invoke('DB_GET_RECENT_ITEMS', playlistId), + dbAddRecentItem: (contentId: number, playlistId: string) => + ipcRenderer.invoke('DB_ADD_RECENT_ITEM', contentId, playlistId), + dbClearPlaylistRecentItems: (playlistId: string) => + ipcRenderer.invoke('DB_CLEAR_PLAYLIST_RECENT_ITEMS', playlistId), + dbRemoveRecentItem: (contentId: number, playlistId: string) => + ipcRenderer.invoke('DB_REMOVE_RECENT_ITEM', contentId, playlistId), + dbGetContentByXtreamId: (xtreamId: number, playlistId: string) => + ipcRenderer.invoke('DB_GET_CONTENT_BY_XTREAM_ID', xtreamId, playlistId), }); diff --git a/apps/electron-backend/src/app/app.ts b/apps/electron-backend/src/app/app.ts index f94e1cd32..1e9bdd976 100644 --- a/apps/electron-backend/src/app/app.ts +++ b/apps/electron-backend/src/app/app.ts @@ -68,6 +68,7 @@ export default class App { // Create the browser window. App.mainWindow = new BrowserWindow({ + title: 'IPTVnator', width: width, height: height, show: false, diff --git a/apps/electron-backend/src/app/database/connection.ts b/apps/electron-backend/src/app/database/connection.ts new file mode 100644 index 000000000..c6827138c --- /dev/null +++ b/apps/electron-backend/src/app/database/connection.ts @@ -0,0 +1,176 @@ +/** + * Database connection and initialization for Electron + * Uses Drizzle ORM with libSQL (@libsql/client) + * Default: local file under Electron userData (file: URL) + * Optional: remote instance via env (LIBSQL_URL, LIBSQL_AUTH_TOKEN) + */ + +import { createClient } from '@libsql/client'; +import type { LibSQLDatabase } from 'drizzle-orm/libsql'; +import { drizzle } from 'drizzle-orm/libsql'; +import { existsSync, mkdirSync } from 'fs'; +import { homedir } from 'os'; +import { join } from 'path'; +import * as schema from './schema'; + +let db: LibSQLDatabase | null = null; +let client: ReturnType | null = null; +let initPromise: Promise> | null = null; + +/** + * Get the database file path + * Use a path without spaces to avoid libSQL issues + */ +function getDatabasePath(): string { + // Use home directory with a simple path (no spaces) + // This avoids "Application Support" path which has a space + const dbDir = join(homedir(), '.iptvnator', 'databases'); + + // Ensure the directory exists + if (!existsSync(dbDir)) { + mkdirSync(dbDir, { recursive: true }); + } + + return join(dbDir, 'iptvnator.db'); +} +/** + * Build libSQL connection options (local file by default, remote if env set) + */ +function getLibsqlConfig(): { url: string; authToken?: string } { + const remoteUrl = process.env.LIBSQL_URL?.trim(); + const authToken = process.env.LIBSQL_AUTH_TOKEN?.trim(); + if (remoteUrl) { + // Remote libSQL (e.g., Turso) + return { url: remoteUrl, authToken }; + } + // Local file via libSQL - must use file: scheme + const filePath = getDatabasePath(); + // libSQL requires file: prefix (single slash after colon for absolute paths) + return { url: `file:${filePath}` }; +} + +/** + * Initialize the database connection (async) + * Creates tables if they don't exist + */ +export async function initDatabase(): Promise> { + if (db) return db; + if (initPromise) return initPromise; + + initPromise = (async () => { + const cfg = getLibsqlConfig(); + client = createClient(cfg); + const database = drizzle(client, { schema }); + + // Create tables if they don't exist + await createTables(); + + db = database; + return database; + })(); + + return initPromise; +} + +/** + * Create tables if they don't exist + */ +async function createTables() { + if (!client) return; + + const createTablesSQL = ` + CREATE TABLE IF NOT EXISTS playlists ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + serverUrl TEXT, + username TEXT, + password TEXT, + date_created TEXT DEFAULT (datetime('now')), + last_updated TEXT, + type TEXT NOT NULL CHECK (type IN ('xtream', 'stalker', 'm3u-file', 'm3u-text', 'm3u-url')), + userAgent TEXT, + origin TEXT, + referrer TEXT, + filePath TEXT, + autoRefresh INTEGER DEFAULT 0, + macAddress TEXT, + url TEXT, + last_usage TEXT + ); + + CREATE TABLE IF NOT EXISTS categories ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + playlist_id TEXT NOT NULL, + name TEXT NOT NULL, + type TEXT NOT NULL CHECK (type IN ('live', 'movies', 'series')), + xtream_id INTEGER NOT NULL, + FOREIGN KEY (playlist_id) REFERENCES playlists (id) ON DELETE CASCADE + ); + + CREATE TABLE IF NOT EXISTS content ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + category_id INTEGER NOT NULL, + title TEXT NOT NULL, + rating TEXT, + added TEXT, + poster_url TEXT, + xtream_id INTEGER NOT NULL, + type TEXT NOT NULL CHECK (type IN ('live', 'movie', 'series')), + FOREIGN KEY (category_id) REFERENCES categories (id) ON DELETE CASCADE + ); + + CREATE TABLE IF NOT EXISTS recently_viewed ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + content_id INTEGER NOT NULL, + playlist_id TEXT NOT NULL, + viewed_at TEXT DEFAULT (datetime('now')), + FOREIGN KEY (content_id) REFERENCES content (id) ON DELETE CASCADE, + FOREIGN KEY (playlist_id) REFERENCES playlists (id) ON DELETE CASCADE + ); + + CREATE TABLE IF NOT EXISTS favorites ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + content_id INTEGER NOT NULL, + playlist_id TEXT NOT NULL, + added_at TEXT DEFAULT (datetime('now')), + FOREIGN KEY (content_id) REFERENCES content(id) ON DELETE CASCADE, + FOREIGN KEY (playlist_id) REFERENCES playlists(id) ON DELETE CASCADE + ); + + CREATE INDEX IF NOT EXISTS idx_content_type ON content(type); + CREATE INDEX IF NOT EXISTS idx_content_category ON content(category_id); + CREATE INDEX IF NOT EXISTS idx_categories_playlist ON categories(playlist_id); + CREATE INDEX IF NOT EXISTS idx_content_title ON content(title); + CREATE INDEX IF NOT EXISTS idx_content_xtream ON content(xtream_id); + CREATE INDEX IF NOT EXISTS idx_categories_type ON categories(type); + + CREATE UNIQUE INDEX IF NOT EXISTS favorites_content_playlist_unique ON favorites(content_id, playlist_id); + CREATE INDEX IF NOT EXISTS favorites_playlist_idx ON favorites(playlist_id); + CREATE INDEX IF NOT EXISTS favorites_content_idx ON favorites(content_id); + + CREATE UNIQUE INDEX IF NOT EXISTS recently_viewed_content_playlist_unique ON recently_viewed(content_id, playlist_id); + CREATE INDEX IF NOT EXISTS recently_viewed_playlist_idx ON recently_viewed(playlist_id); + CREATE INDEX IF NOT EXISTS recently_viewed_viewed_at_idx ON recently_viewed(viewed_at); + `; + + // libSQL client doesn't guarantee multi-statement execution in one call + // Split on ';' and execute sequentially for reliability + const statements = createTablesSQL + .split(';') + .map((s) => s.trim()) + .filter((s) => s.length > 0); + + for (const stmt of statements) { + // Add back the semicolon when helpful for parser tolerance + await client.execute(stmt); + } +} + +/** + * Get the database instance + * Initializes the database if not already initialized + */ +export async function getDatabase(): Promise> { + if (db) return db; + return initDatabase(); +} diff --git a/apps/electron-backend/src/app/database/schema.ts b/apps/electron-backend/src/app/database/schema.ts new file mode 100644 index 000000000..8ebfa87e1 --- /dev/null +++ b/apps/electron-backend/src/app/database/schema.ts @@ -0,0 +1,110 @@ +/** + * Drizzle ORM schema for IPTVnator database + * This schema defines the structure for Xtream Codes API data storage + */ + +import { sql } from 'drizzle-orm'; +import { index, integer, sqliteTable, text } from 'drizzle-orm/sqlite-core'; + +// Playlists table +export const playlists = sqliteTable('playlists', { + id: text('id').primaryKey(), + name: text('name').notNull(), + serverUrl: text('serverUrl'), + username: text('username'), + password: text('password'), + dateCreated: text('date_created').default(sql`CURRENT_TIMESTAMP`), + lastUpdated: text('last_updated'), + type: text('type', { + enum: ['xtream', 'stalker', 'm3u-file', 'm3u-text', 'm3u-url'], + }).notNull(), + userAgent: text('userAgent'), + origin: text('origin'), + referrer: text('referrer'), + filePath: text('filePath'), + autoRefresh: integer('autoRefresh', { mode: 'boolean' }).default(false), + macAddress: text('macAddress'), + url: text('url'), + lastUsage: text('last_usage'), +}); + +// Categories table +export const categories = sqliteTable( + 'categories', + { + id: integer('id').primaryKey({ autoIncrement: true }), + playlistId: text('playlist_id') + .notNull() + .references(() => playlists.id, { onDelete: 'cascade' }), + name: text('name').notNull(), + type: text('type', { enum: ['live', 'movies', 'series'] }).notNull(), + xtreamId: integer('xtream_id').notNull(), + }, + (table) => ({ + playlistIdx: index('idx_categories_playlist').on(table.playlistId), + typeIdx: index('idx_categories_type').on(table.type), + }) +); + +// Content table (streams/VODs/series) +export const content = sqliteTable( + 'content', + { + id: integer('id').primaryKey({ autoIncrement: true }), + categoryId: integer('category_id') + .notNull() + .references(() => categories.id, { onDelete: 'cascade' }), + title: text('title').notNull(), + rating: text('rating'), + added: text('added'), + posterUrl: text('poster_url'), + xtreamId: integer('xtream_id').notNull(), + type: text('type', { enum: ['live', 'movie', 'series'] }).notNull(), + }, + (table) => ({ + typeIdx: index('idx_content_type').on(table.type), + categoryIdx: index('idx_content_category').on(table.categoryId), + titleIdx: index('idx_content_title').on(table.title), + xtreamIdx: index('idx_content_xtream').on(table.xtreamId), + }) +); + +// Recently viewed table +export const recentlyViewed = sqliteTable('recently_viewed', { + id: integer('id').primaryKey({ autoIncrement: true }), + contentId: integer('content_id') + .notNull() + .references(() => content.id, { onDelete: 'cascade' }), + playlistId: text('playlist_id') + .notNull() + .references(() => playlists.id, { onDelete: 'cascade' }), + viewedAt: text('viewed_at').default(sql`CURRENT_TIMESTAMP`), +}); + +// Favorites table +export const favorites = sqliteTable('favorites', { + id: integer('id').primaryKey({ autoIncrement: true }), + contentId: integer('content_id') + .notNull() + .references(() => content.id, { onDelete: 'cascade' }), + playlistId: text('playlist_id') + .notNull() + .references(() => playlists.id, { onDelete: 'cascade' }), + addedAt: text('added_at').default(sql`CURRENT_TIMESTAMP`), +}); + +// Type exports for TypeScript +export type Playlist = typeof playlists.$inferSelect; +export type NewPlaylist = typeof playlists.$inferInsert; + +export type Category = typeof categories.$inferSelect; +export type NewCategory = typeof categories.$inferInsert; + +export type Content = typeof content.$inferSelect; +export type NewContent = typeof content.$inferInsert; + +export type RecentlyViewed = typeof recentlyViewed.$inferSelect; +export type NewRecentlyViewed = typeof recentlyViewed.$inferInsert; + +export type Favorite = typeof favorites.$inferSelect; +export type NewFavorite = typeof favorites.$inferInsert; diff --git a/apps/electron-backend/src/app/events/database.events.ts b/apps/electron-backend/src/app/events/database.events.ts new file mode 100644 index 000000000..adfe89c01 --- /dev/null +++ b/apps/electron-backend/src/app/events/database.events.ts @@ -0,0 +1,877 @@ +/** + * Database IPC event handlers for Electron + * Provides database operations to the renderer process + */ + +import { and, desc, eq, inArray, like, sql } from 'drizzle-orm'; +import { ipcMain } from 'electron'; +import { getDatabase } from '../database/connection'; +import * as schema from '../database/schema'; + +export default class DatabaseEvents { + static bootstrapDatabaseEvents(): Electron.IpcMain { + return ipcMain; + } +} + +/** + * Create a new playlist + */ +ipcMain.handle( + 'DB_CREATE_PLAYLIST', + async ( + event, + playlist: { + id: string; + name: string; + serverUrl?: string; + username?: string; + password?: string; + type: string; + } + ) => { + try { + const db = await getDatabase(); + await db.insert(schema.playlists).values({ + id: playlist.id, + name: playlist.name, + serverUrl: playlist.serverUrl, + username: playlist.username, + password: playlist.password, + // enforce supported types + type: playlist.type as + | 'xtream' + | 'stalker' + | 'm3u-file' + | 'm3u-text' + | 'm3u-url', + }); + return { success: true }; + } catch (error) { + console.error('Error creating playlist:', error); + throw error; + } + } +); + +/** + * Get playlist by ID + */ +ipcMain.handle('DB_GET_PLAYLIST', async (event, playlistId: string) => { + try { + const db = await getDatabase(); + const result = await db + .select() + .from(schema.playlists) + .where(eq(schema.playlists.id, playlistId)) + .limit(1); + return result[0] || null; + } catch (error) { + console.error('Error getting playlist:', error); + throw error; + } +}); + +/** + * Update playlist + */ +ipcMain.handle( + 'DB_UPDATE_PLAYLIST', + async ( + event, + playlistId: string, + updates: { + name?: string; + username?: string; + password?: string; + serverUrl?: string; + lastUpdated?: string; + } + ) => { + try { + const db = await getDatabase(); + await db + .update(schema.playlists) + .set(updates) + .where(eq(schema.playlists.id, playlistId)); + return { success: true }; + } catch (error) { + console.error('Error updating playlist:', error); + throw error; + } + } +); + +/** + * Delete playlist and all related data + */ +ipcMain.handle('DB_DELETE_PLAYLIST', async (event, playlistId: string) => { + try { + const db = await getDatabase(); + + // Delete playlist (cascade will handle related data) + await db + .delete(schema.playlists) + .where(eq(schema.playlists.id, playlistId)); + + return { success: true }; + } catch (error) { + console.error('Error deleting playlist:', error); + throw error; + } +}); + +/** + * Delete all content and categories for an Xtream playlist + * Keeps the playlist entry but removes all imported data + */ +ipcMain.handle( + 'DB_DELETE_XTREAM_CONTENT', + async (event, playlistId: string) => { + try { + const db = await getDatabase(); + + // First, get all category IDs for this playlist + const categories = await db + .select({ id: schema.categories.id }) + .from(schema.categories) + .where(eq(schema.categories.playlistId, playlistId)); + + const categoryIds = categories.map((c) => c.id); + + console.log( + `Deleting playlist content for ${playlistId}: ${categoryIds.length} categories found` + ); + + if (categoryIds.length > 0) { + // Delete all content for these categories + await db + .delete(schema.content) + .where(inArray(schema.content.categoryId, categoryIds)); + + console.log( + `Deleted content for ${categoryIds.length} categories` + ); + } + + // Delete all categories for this playlist + // (Could rely on cascade delete, but being explicit is clearer) + await db + .delete(schema.categories) + .where(eq(schema.categories.playlistId, playlistId)); + + console.log( + `Deleted ${categoryIds.length} categories for playlist ${playlistId}` + ); + + // NOTE: Do NOT delete user favorites or recently viewed items here. + // When refreshing an Xtream playlist we want to remove only the + // imported categories and content so user-specific data (favorites, + // recently viewed) is preserved. + + return { success: true }; + } catch (error) { + console.error('Error deleting Xtream content:', error); + throw error; + } + } +); + +/** + * Check if categories exist for a playlist + */ +ipcMain.handle( + 'DB_HAS_CATEGORIES', + async (event, playlistId: string, type: 'live' | 'movies' | 'series') => { + try { + const db = await getDatabase(); + const result = await db + .select({ count: sql`count(*)` }) + .from(schema.categories) + .where( + and( + eq(schema.categories.playlistId, playlistId), + eq(schema.categories.type, type) + ) + ); + return result[0].count > 0; + } catch (error) { + console.error('Error checking categories:', error); + throw error; + } + } +); + +/** + * Get categories for a playlist + */ +ipcMain.handle( + 'DB_GET_CATEGORIES', + async (event, playlistId: string, type: 'live' | 'movies' | 'series') => { + try { + const db = await getDatabase(); + const result = await db + .select() + .from(schema.categories) + .where( + and( + eq(schema.categories.playlistId, playlistId), + eq(schema.categories.type, type) + ) + ) + .orderBy(sql`name COLLATE NOCASE`); + return result; + } catch (error) { + console.error('Error getting categories:', error); + throw error; + } + } +); + +/** + * Save categories in bulk + */ +ipcMain.handle( + 'DB_SAVE_CATEGORIES', + async ( + event, + playlistId: string, + categories: Array<{ + category_name: string; + category_id: number; + }>, + type: 'live' | 'movies' | 'series' + ) => { + try { + const db = await getDatabase(); + const values = categories.map((cat) => ({ + playlistId, + name: cat.category_name, + type, + xtreamId: cat.category_id, + })); + + await db.insert(schema.categories).values(values); + return { success: true }; + } catch (error) { + console.error('Error saving categories:', error); + throw error; + } + } +); + +/** + * Check if content exists + */ +ipcMain.handle( + 'DB_HAS_CONTENT', + async (event, playlistId: string, type: 'live' | 'movie' | 'series') => { + try { + const db = await getDatabase(); + const result = await db + .select({ count: sql`count(*)` }) + .from(schema.content) + .innerJoin( + schema.categories, + eq(schema.content.categoryId, schema.categories.id) + ) + .where( + and( + eq(schema.categories.playlistId, playlistId), + eq(schema.content.type, type) + ) + ); + return result[0].count > 0; + } catch (error) { + console.error('Error checking content:', error); + throw error; + } + } +); + +/** + * Get content for a playlist + */ +ipcMain.handle( + 'DB_GET_CONTENT', + async (event, playlistId: string, type: 'live' | 'movie' | 'series') => { + try { + const db = await getDatabase(); + const result = await db + .select({ + id: schema.content.id, + category_id: schema.content.categoryId, + title: schema.content.title, + rating: schema.content.rating, + added: schema.content.added, + poster_url: schema.content.posterUrl, + xtream_id: schema.content.xtreamId, + type: schema.content.type, + }) + .from(schema.content) + .innerJoin( + schema.categories, + eq(schema.content.categoryId, schema.categories.id) + ) + .where( + and( + eq(schema.categories.playlistId, playlistId), + eq(schema.content.type, type) + ) + ) + .orderBy(desc(schema.content.added)); + return result; + } catch (error) { + console.error('Error getting content:', error); + throw error; + } + } +); + +/** + * Save content in bulk with progress reporting + */ +ipcMain.handle( + 'DB_SAVE_CONTENT', + async ( + event, + playlistId: string, + streams: Array>, + type: 'live' | 'movie' | 'series' + ) => { + try { + console.log( + `>>> DB_SAVE_CONTENT called: ${streams.length} items, type: ${type}, playlist: ${playlistId}` + ); + const db = await getDatabase(); + const dbType = + type === 'series' + ? 'series' + : type === 'movie' + ? 'movies' + : 'live'; + + // Get categories with their IDs + const categories = await db + .select({ + id: schema.categories.id, + xtreamId: schema.categories.xtreamId, + }) + .from(schema.categories) + .where( + and( + eq(schema.categories.playlistId, playlistId), + eq(schema.categories.type, dbType) + ) + ); + + const categoryMap = new Map( + categories.map((c) => [c.xtreamId, c.id]) + ); + + // Prepare bulk insert data + const values = streams + .map((stream) => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const s = stream as any; + const streamCategoryId = + type === 'series' + ? parseInt(s.category_id || '0') + : parseInt(s.category_id); + + const categoryId = categoryMap.get(streamCategoryId); + if (!categoryId) return null; + + const title = + type === 'series' + ? s.title || + s.name || + `Unknown Series ${s.series_id}` + : s.name || + s.title || + `Unknown Stream ${s.stream_id}`; + + return { + categoryId, + title, + rating: s.rating || s.rating_imdb || '', + added: + type === 'series' + ? s.last_modified || '' + : s.added || '', + posterUrl: s.stream_icon || s.poster || s.cover || '', + xtreamId: + type === 'series' + ? parseInt(s.series_id || '0') + : parseInt(s.stream_id || '0'), + type, + }; + }) + .filter((data) => data !== null); + + // Insert in chunks for better performance + const CHUNK_SIZE = 100; + let totalInserted = 0; + + for (let i = 0; i < values.length; i += CHUNK_SIZE) { + const chunk = values.slice(i, i + CHUNK_SIZE); + await db.insert(schema.content).values( + chunk as Array<{ + categoryId: number; + title: string; + rating: string; + added: string; + posterUrl: string; + xtreamId: number; + type: 'live' | 'movie' | 'series'; + }> + ); + totalInserted += chunk.length; + + // Send progress update + event.sender.send('DB_SAVE_CONTENT_PROGRESS', totalInserted); + } + + return { success: true, count: totalInserted }; + } catch (error) { + console.error('Error saving content:', error); + throw error; + } + } +); + +/** + * Search content + */ +ipcMain.handle( + 'DB_SEARCH_CONTENT', + async (event, playlistId: string, searchTerm: string, types: string[]) => { + try { + const db = await getDatabase(); + const result = await db + .select({ + id: schema.content.id, + category_id: schema.content.categoryId, + title: schema.content.title, + rating: schema.content.rating, + added: schema.content.added, + poster_url: schema.content.posterUrl, + xtream_id: schema.content.xtreamId, + type: schema.content.type, + }) + .from(schema.content) + .innerJoin( + schema.categories, + eq(schema.content.categoryId, schema.categories.id) + ) + .where( + and( + eq(schema.categories.playlistId, playlistId), + like(schema.content.title, `%${searchTerm}%`), + inArray( + schema.content.type, + types as Array<'live' | 'movie' | 'series'> + ) + ) + ) + .limit(50); + return result; + } catch (error) { + console.error('Error searching content:', error); + throw error; + } + } +); + +/** + * Global search across all playlists + */ +ipcMain.handle( + 'DB_GLOBAL_SEARCH', + async (event, searchTerm: string, types: string[]) => { + try { + const db = await getDatabase(); + const result = await db + .select({ + id: schema.content.id, + category_id: schema.content.categoryId, + title: schema.content.title, + rating: schema.content.rating, + added: schema.content.added, + poster_url: schema.content.posterUrl, + xtream_id: schema.content.xtreamId, + type: schema.content.type, + playlist_id: schema.categories.playlistId, + playlist_name: schema.playlists.name, + }) + .from(schema.content) + .innerJoin( + schema.categories, + eq(schema.content.categoryId, schema.categories.id) + ) + .innerJoin( + schema.playlists, + eq(schema.categories.playlistId, schema.playlists.id) + ) + .where( + and( + like( + sql`LOWER(${schema.content.title})`, + `%${searchTerm.toLowerCase()}%` + ), + inArray( + schema.content.type, + types as Array<'live' | 'movie' | 'series'> + ) + ) + ) + .orderBy(schema.content.title) + .limit(50); + return result; + } catch (error) { + console.error('Error in global search:', error); + throw error; + } + } +); + +/** + * Get recently viewed items + */ +ipcMain.handle('DB_GET_RECENTLY_VIEWED', async () => { + try { + const db = await getDatabase(); + const result = await db + .select({ + id: schema.content.id, + category_id: schema.content.categoryId, + title: schema.content.title, + rating: schema.content.rating, + added: schema.content.added, + poster_url: schema.content.posterUrl, + xtream_id: schema.content.xtreamId, + type: schema.content.type, + playlist_id: schema.categories.playlistId, + playlist_name: schema.playlists.name, + viewed_at: schema.recentlyViewed.viewedAt, + }) + .from(schema.recentlyViewed) + .innerJoin( + schema.content, + eq(schema.recentlyViewed.contentId, schema.content.id) + ) + .innerJoin( + schema.categories, + eq(schema.content.categoryId, schema.categories.id) + ) + .innerJoin( + schema.playlists, + eq(schema.categories.playlistId, schema.playlists.id) + ) + .orderBy(desc(schema.recentlyViewed.viewedAt)) + .limit(100); + return result; + } catch (error) { + console.error('Error getting recently viewed:', error); + throw error; + } +}); + +/** + * Clear recently viewed items + */ +ipcMain.handle('DB_CLEAR_RECENTLY_VIEWED', async () => { + try { + const db = await getDatabase(); + await db.delete(schema.recentlyViewed); + return { success: true }; + } catch (error) { + console.error('Error clearing recently viewed:', error); + throw error; + } +}); + +/** + * Add content to favorites + */ +ipcMain.handle( + 'DB_ADD_FAVORITE', + async (event, contentId: number, playlistId: string) => { + try { + const db = await getDatabase(); + await db.insert(schema.favorites).values({ + contentId, + playlistId, + }); + return { success: true }; + } catch (error) { + console.error('Error adding favorite:', error); + throw error; + } + } +); + +/** + * Remove content from favorites + */ +ipcMain.handle( + 'DB_REMOVE_FAVORITE', + async (event, contentId: number, playlistId: string) => { + try { + const db = await getDatabase(); + await db + .delete(schema.favorites) + .where( + and( + eq(schema.favorites.contentId, contentId), + eq(schema.favorites.playlistId, playlistId) + ) + ); + return { success: true }; + } catch (error) { + console.error('Error removing favorite:', error); + throw error; + } + } +); + +/** + * Check if content is favorited + */ +ipcMain.handle( + 'DB_IS_FAVORITE', + async (event, contentId: number, playlistId: string) => { + try { + const db = await getDatabase(); + const result = await db + .select({ count: sql`count(*)` }) + .from(schema.favorites) + .where( + and( + eq(schema.favorites.contentId, contentId), + eq(schema.favorites.playlistId, playlistId) + ) + ); + return result[0].count > 0; + } catch (error) { + console.error('Error checking favorite:', error); + throw error; + } + } +); + +/** + * Get all favorites for a playlist + */ +ipcMain.handle('DB_GET_FAVORITES', async (event, playlistId: string) => { + try { + const db = await getDatabase(); + const result = await db + .select({ + id: schema.content.id, + category_id: schema.content.categoryId, + title: schema.content.title, + rating: schema.content.rating, + added: schema.content.added, + poster_url: schema.content.posterUrl, + xtream_id: schema.content.xtreamId, + type: schema.content.type, + added_at: schema.favorites.addedAt, + }) + .from(schema.favorites) + .innerJoin( + schema.content, + eq(schema.favorites.contentId, schema.content.id) + ) + .where(eq(schema.favorites.playlistId, playlistId)) + .orderBy(desc(schema.favorites.addedAt)); + return result; + } catch (error) { + console.error('Error getting favorites:', error); + throw error; + } +}); + +/** + * Get recently viewed items for a specific playlist + */ +ipcMain.handle('DB_GET_RECENT_ITEMS', async (event, playlistId: string) => { + try { + const db = await getDatabase(); + const result = await db + .select({ + id: schema.content.id, + category_id: schema.content.categoryId, + title: schema.content.title, + rating: schema.content.rating, + added: schema.content.added, + poster_url: schema.content.posterUrl, + xtream_id: schema.content.xtreamId, + type: schema.content.type, + viewed_at: schema.recentlyViewed.viewedAt, + }) + .from(schema.recentlyViewed) + .innerJoin( + schema.content, + eq(schema.recentlyViewed.contentId, schema.content.id) + ) + .innerJoin( + schema.categories, + eq(schema.content.categoryId, schema.categories.id) + ) + .where(eq(schema.categories.playlistId, playlistId)) + .orderBy(desc(schema.recentlyViewed.viewedAt)) + .limit(100); + return result; + } catch (error) { + console.error('Error getting recent items:', error); + throw error; + } +}); + +/** + * Add item to recently viewed + */ +ipcMain.handle( + 'DB_ADD_RECENT_ITEM', + async (event, contentId: number, playlistId: string) => { + try { + const db = await getDatabase(); + + // Check if already exists + const existing = await db + .select() + .from(schema.recentlyViewed) + .where( + and( + eq(schema.recentlyViewed.contentId, contentId), + eq(schema.recentlyViewed.playlistId, playlistId) + ) + ) + .limit(1); + + if (existing.length > 0) { + // Update viewed_at timestamp + await db + .update(schema.recentlyViewed) + .set({ viewedAt: sql`CURRENT_TIMESTAMP` }) + .where( + and( + eq(schema.recentlyViewed.contentId, contentId), + eq(schema.recentlyViewed.playlistId, playlistId) + ) + ); + } else { + // Insert new entry + await db.insert(schema.recentlyViewed).values({ + contentId, + playlistId, + }); + } + + return { success: true }; + } catch (error) { + console.error('Error adding recent item:', error); + throw error; + } + } +); + +/** + * Clear recently viewed for a specific playlist + */ +ipcMain.handle( + 'DB_CLEAR_PLAYLIST_RECENT_ITEMS', + async (event, playlistId: string) => { + try { + const db = await getDatabase(); + + // Get content IDs that belong to this playlist + const contentIds = await db + .select({ id: schema.content.id }) + .from(schema.content) + .innerJoin( + schema.categories, + eq(schema.content.categoryId, schema.categories.id) + ) + .where(eq(schema.categories.playlistId, playlistId)); + + if (contentIds.length > 0) { + await db.delete(schema.recentlyViewed).where( + inArray( + schema.recentlyViewed.contentId, + contentIds.map((c) => c.id) + ) + ); + } + + return { success: true }; + } catch (error) { + console.error('Error clearing playlist recent items:', error); + throw error; + } + } +); + +/** + * Remove specific item from recently viewed + */ +ipcMain.handle( + 'DB_REMOVE_RECENT_ITEM', + async (event, contentId: number, playlistId: string) => { + try { + const db = await getDatabase(); + await db + .delete(schema.recentlyViewed) + .where( + and( + eq(schema.recentlyViewed.contentId, contentId), + eq(schema.recentlyViewed.playlistId, playlistId) + ) + ); + return { success: true }; + } catch (error) { + console.error('Error removing recent item:', error); + throw error; + } + } +); + +/** + * Get content by xtream ID + */ +ipcMain.handle( + 'DB_GET_CONTENT_BY_XTREAM_ID', + async (event, xtreamId: number, playlistId: string) => { + try { + const db = await getDatabase(); + const result = await db + .select({ + id: schema.content.id, + category_id: schema.content.categoryId, + title: schema.content.title, + rating: schema.content.rating, + added: schema.content.added, + poster_url: schema.content.posterUrl, + xtream_id: schema.content.xtreamId, + type: schema.content.type, + }) + .from(schema.content) + .innerJoin( + schema.categories, + eq(schema.content.categoryId, schema.categories.id) + ) + .where( + and( + eq(schema.content.xtreamId, xtreamId), + eq(schema.categories.playlistId, playlistId) + ) + ) + .limit(1); + return result[0] || null; + } catch (error) { + console.error('Error getting content by xtream ID:', error); + throw error; + } + } +); diff --git a/apps/electron-backend/src/app/events/stalker.events.ts b/apps/electron-backend/src/app/events/stalker.events.ts new file mode 100644 index 000000000..1d4dc6462 --- /dev/null +++ b/apps/electron-backend/src/app/events/stalker.events.ts @@ -0,0 +1,93 @@ +/** + * This module handles all Stalker portal related IPC communications + * between the frontend and the electron backend. + */ + +import axios, { AxiosRequestConfig } from 'axios'; +import { ipcMain } from 'electron'; +import { STALKER_REQUEST } from 'shared-interfaces'; + +export default class StalkerEvents { + static bootstrapStalkerEvents(): Electron.IpcMain { + return ipcMain; + } +} + +/** + * Handle Stalker API requests with MAC address cookie + */ +ipcMain.handle( + STALKER_REQUEST, + async ( + event, + payload: { + url: string; + macAddress: string; + params: Record; + } + ) => { + try { + const { url, macAddress, params } = payload; + + // Build URL with query parameters + const urlObject = new URL(url); + Object.entries(params).forEach(([key, value]) => { + urlObject.searchParams.append(key, value); + }); + + // Configure axios request with MAC address cookie + const config: AxiosRequestConfig = { + method: 'GET', + url: urlObject.toString(), + headers: { + Cookie: `mac=${macAddress}`, + 'User-Agent': + 'Mozilla/5.0 (QtEmbedded; U; Linux; C) AppleWebKit/533.3 (KHTML, like Gecko) MAG200 stbapp ver: 2 rev: 250 Safari/533.3', + 'X-User-Agent': 'Model: MAG250; Link: WiFi', + }, + timeout: 15000, // 15 seconds timeout + validateStatus: (status) => status < 500, // Don't throw on 4xx errors + }; + + const response = await axios(config); + + // Check if response is successful + if (response.status >= 400) { + throw { + message: `HTTP Error: ${response.statusText}`, + status: response.status, + }; + } + + // Return the response data + return response.data; + } catch (error) { + console.error('Stalker request error:', error); + + // Format error response + if (axios.isAxiosError(error)) { + const errorResponse = { + type: 'ERROR', + message: + error.response?.data?.message || + error.message || + 'Failed to fetch data from Stalker portal', + status: error.response?.status || 500, + }; + throw errorResponse; + } else if ( + error && + typeof error === 'object' && + 'message' in error + ) { + throw error; + } else { + throw { + type: 'ERROR', + message: 'An unknown error occurred', + status: 500, + }; + } + } + } +); diff --git a/apps/electron-backend/src/app/events/xtream.events.ts b/apps/electron-backend/src/app/events/xtream.events.ts new file mode 100644 index 000000000..feac61751 --- /dev/null +++ b/apps/electron-backend/src/app/events/xtream.events.ts @@ -0,0 +1,91 @@ +/** + * This module handles all Xtream Codes API related IPC communications + * between the frontend and the electron backend. + */ + +import axios, { AxiosRequestConfig } from 'axios'; +import { ipcMain } from 'electron'; +import { XTREAM_REQUEST } from 'shared-interfaces'; + +export default class XtreamEvents { + static bootstrapXtreamEvents(): Electron.IpcMain { + return ipcMain; + } +} + +/** + * Handle Xtream Codes API requests + */ +ipcMain.handle( + XTREAM_REQUEST, + async ( + event, + payload: { + url: string; + params: Record; + } + ) => { + try { + const { url, params } = payload; + + // Build URL with query parameters + // Xtream API endpoint is always at /player_api.php + const apiUrl = new URL(`${url}/player_api.php`); + Object.entries(params).forEach(([key, value]) => { + apiUrl.searchParams.append(key, value); + }); + + // Configure axios request + const config: AxiosRequestConfig = { + method: 'GET', + url: apiUrl.toString(), + headers: { + 'User-Agent': + 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36', + Accept: 'application/json', + }, + timeout: 30000, // 30 seconds timeout for Xtream API + validateStatus: (status) => status < 500, // Don't throw on 4xx errors + }; + + const response = await axios(config); + + // Check if response is successful + if (response.status >= 400) { + throw { + message: `HTTP Error: ${response.statusText}`, + status: response.status, + }; + } + + // Xtream API returns JSON data + return { + payload: response.data, + action: params.action, + }; + } catch (error) { + console.error('Xtream request error:', error); + + // Format error response + if (axios.isAxiosError(error)) { + const errorResponse = { + type: 'ERROR', + message: + error.response?.data?.message || + error.message || + 'Failed to fetch data from Xtream server', + status: error.response?.status || 500, + }; + throw errorResponse; + } else if (error && typeof error === 'object' && 'message' in error) { + throw error; + } else { + throw { + type: 'ERROR', + message: 'An unknown error occurred', + status: 500, + }; + } + } + } +); diff --git a/apps/electron-backend/src/main.ts b/apps/electron-backend/src/main.ts index 24f2932c5..2ff9eac46 100644 --- a/apps/electron-backend/src/main.ts +++ b/apps/electron-backend/src/main.ts @@ -1,12 +1,18 @@ import { app, BrowserWindow } from 'electron'; import fixPath from 'fix-path'; import App from './app/app'; +import { initDatabase } from './app/database/connection'; +import DatabaseEvents from './app/events/database.events'; import ElectronEvents from './app/events/electron.events'; import PlayerEvents from './app/events/player.events'; import PlaylistEvents from './app/events/playlist.events'; import SettingsEvents from './app/events/setttings.events'; import SharedEvents from './app/events/shared.events'; import SquirrelEvents from './app/events/squirrel.events'; +import StalkerEvents from './app/events/stalker.events'; +import XtreamEvents from './app/events/xtream.events'; + +app.setName('iptvnator'); export default class Main { static initialize() { @@ -20,12 +26,18 @@ export default class Main { App.main(app, BrowserWindow); } - static bootstrapAppEvents() { + static async bootstrapAppEvents() { + // Initialize database before other events + await initDatabase(); + ElectronEvents.bootstrapElectronEvents(); PlaylistEvents.bootstrapPlaylistEvents(); SharedEvents.bootstrapSharedEvents(); PlayerEvents.bootstrapPlayerEvents(); SettingsEvents.bootstrapSettingsEvents(); + StalkerEvents.bootstrapStalkerEvents(); + XtreamEvents.bootstrapXtreamEvents(); + DatabaseEvents.bootstrapDatabaseEvents(); // initialize auto updater service if (!App.isDevelopmentMode()) { @@ -41,4 +53,8 @@ Main.initialize(); // bootstrap app Main.bootstrapApp(); -Main.bootstrapAppEvents(); + +// Bootstrap app events after Electron app is ready +app.whenReady().then(async () => { + await Main.bootstrapAppEvents(); +}); diff --git a/apps/web/src/app/app.config.ts b/apps/web/src/app/app.config.ts index 68d293811..42c2169f4 100644 --- a/apps/web/src/app/app.config.ts +++ b/apps/web/src/app/app.config.ts @@ -24,8 +24,8 @@ import { DataService } from 'services'; import { dbConfig } from 'shared-interfaces'; import { AppConfig } from '../environments/environment'; import { routes } from './app.routes'; +import { ElectronService } from './services/electron.service'; import { PwaService } from './services/pwa.service'; -import { TauriService } from './services/tauri.service'; // AoT requires an exported function for factories export function HttpLoaderFactory(http: HttpClient): TranslateHttpLoader { @@ -37,7 +37,7 @@ export function HttpLoaderFactory(http: HttpClient): TranslateHttpLoader { */ export function DataFactory() { if (window.electron) { - return new TauriService(); + return new ElectronService(); } return new PwaService(); } diff --git a/apps/web/src/app/services/tauri.service.ts b/apps/web/src/app/services/electron.service.ts similarity index 75% rename from apps/web/src/app/services/tauri.service.ts rename to apps/web/src/app/services/electron.service.ts index aa68375c3..53a567e08 100644 --- a/apps/web/src/app/services/tauri.service.ts +++ b/apps/web/src/app/services/electron.service.ts @@ -1,8 +1,6 @@ import { inject, Injectable } from '@angular/core'; import { MatSnackBar } from '@angular/material/snack-bar'; import { Params } from '@angular/router'; -import { listen } from '@tauri-apps/api/event'; -import { fetch } from '@tauri-apps/plugin-http'; import { DataService } from 'services'; import { AUTO_UPDATE_PLAYLISTS, @@ -21,29 +19,13 @@ import { AppConfig } from '../../environments/environment'; @Injectable({ providedIn: 'root', }) -export class TauriService extends DataService { +export class ElectronService extends DataService { private eventListeners: { [key: string]: () => void } = {}; private snackBar = inject(MatSnackBar); constructor() { super(); - console.log('Tauri service initialized...'); - this.setupEventListeners(); - } - - private async setupEventListeners() { - // Listen for player errors - /* this.eventListeners['player-error'] = await listen( - 'player-error', - (event) => { - console.error('Player error:', event); - this.snackBar.open( - `Player Error: ${event.payload as string}`, - 'Close', - { duration: 5000 } - ); - } - ); */ + console.log('Electron service initialized...'); } getAppVersion(): string { @@ -150,42 +132,22 @@ export class TauriService extends DataService { params: Record; }) { try { - const url = new URL(payload.url); - - Object.entries(payload.params).forEach(([key, value]) => { - url.searchParams.append(key, value); - }); - - const response = await fetch(url.toString(), { - method: 'GET', - headers: { - Cookie: `mac=${payload.macAddress}`, - }, - }); - - if (!response.ok) { - throw new Error( - `Error: ${response.statusText} (Status: ${response.status})` - ); - } - - return await response.json(); - /* window.postMessage({ - type: 'STALKER_RESPONSE', - payload: result, - action: payload.params.action, - }); */ - } catch (err) { - console.log(err); + // Use Electron IPC to make the Stalker request + const response = await window.electron.stalkerRequest(payload); + return response; + } catch (err: any) { + console.error('Stalker request error:', err); window.postMessage({ type: ERROR, message: err.message ?? 'Error: not found', status: err.status ?? 404, }); + throw err; } } private async fetchM3uPlaylistFromUrl(payload: Partial) { + console.log(payload); window.electron.fetchPlaylistByUrl(payload.url).then((result) => { // TODO: call store and decide and store where to store based on the isTemporary flag etc window.postMessage({ @@ -255,36 +217,32 @@ export class TauriService extends DataService { url: string; params: Record; }) { - let result: any; - const url = new URL(`${payload.url}/player_api.php`); - Object.entries(payload.params).forEach(([key, value]) => { - url.searchParams.append(key, value); - }); + try { + // Use Electron IPC to make the Xtream request + const response = await window.electron.xtreamRequest(payload); - const response = await fetch(url.toString()); - - const responseBody = await response.json(); - if (!responseBody) { - result = { - type: ERROR, - status: response.status, - message: responseBody.message ?? 'Unknown error', - }; - window.postMessage(result); - } else { - result = { + const result = { type: XTREAM_RESPONSE, - payload: responseBody, - action: payload.params.action, + payload: response.payload, + action: response.action, }; window.postMessage(result); + return result; + } catch (error: any) { + console.error('Xtream request error:', error); + const result = { + type: ERROR, + status: error.status ?? 500, + message: error.message ?? 'Failed to connect to Xtream server', + }; + window.postMessage(result); + return result; } - return result; } removeAllListeners(type: string): void { if (type === 'all') { - // Unsubscribe from all Tauri events + // Unsubscribe from all event listeners Object.values(this.eventListeners).forEach((unsubscribe) => unsubscribe() ); @@ -296,7 +254,6 @@ export class TauriService extends DataService { } // Also remove any window message listeners - // Note: This is a bit crude, but works for simple cases window.removeEventListener('message', this.getListenerForCommand(type)); } @@ -307,20 +264,12 @@ export class TauriService extends DataService { } listenOn(command: string, callback: (...args: any[]) => void): void { - if (command.startsWith('tauri:')) { - // For Tauri specific events, use the Tauri event system - const tauriEvent = command.replace('tauri:', ''); - listen(tauriEvent, callback).then((unsubscribe) => { - this.eventListeners[command] = unsubscribe; - }); - } else { - // For backward compatibility, use window messages - window.addEventListener('message', callback); - } + // For Electron, use window message events + window.addEventListener('message', callback); } getAppEnvironment(): string { - return 'tauri'; + return 'electron'; } async fetchData(url: string, queryParams: Params) { diff --git a/apps/web/src/app/shared/components/favorites-layout/favorites-layout.component.html b/apps/web/src/app/shared/components/favorites-layout/favorites-layout.component.html index 09bd5f8b5..a12776367 100644 --- a/apps/web/src/app/shared/components/favorites-layout/favorites-layout.component.html +++ b/apps/web/src/app/shared/components/favorites-layout/favorites-layout.component.html @@ -63,7 +63,8 @@ 'CHANNELS.REMOVE_FAVORITE' | translate " (click)=" - $event.stopPropagation(); removeFavorite(item) + $event.stopPropagation(); + removeFromFavorites(item) " > delete @@ -78,5 +79,5 @@ } - + diff --git a/apps/web/src/app/shared/components/favorites-layout/favorites-layout.component.ts b/apps/web/src/app/shared/components/favorites-layout/favorites-layout.component.ts index fa97beb4e..ded9c4a27 100644 --- a/apps/web/src/app/shared/components/favorites-layout/favorites-layout.component.ts +++ b/apps/web/src/app/shared/components/favorites-layout/favorites-layout.component.ts @@ -6,7 +6,6 @@ import { MatIcon } from '@angular/material/icon'; import { MatTooltip } from '@angular/material/tooltip'; import { TranslatePipe } from '@ngx-translate/core'; import { CategoryViewComponent } from '../../../xtream-tauri/category-view/category-view.component'; -import { MpvPlayerBarComponent } from '../mpv-player-bar/mpv-player-bar.component'; @Component({ selector: 'app-favorites-layout', @@ -21,7 +20,7 @@ import { MpvPlayerBarComponent } from '../mpv-player-bar/mpv-player-bar.componen MatCardModule, MatIcon, MatIconButton, - MpvPlayerBarComponent, + /* MpvPlayerBarComponent, */ MatTooltip, TranslatePipe, ], @@ -33,15 +32,15 @@ export class FavoritesLayoutComponent { readonly titleTranslationString = input('CHANNELS.FAVORITES'); readonly categoryClicked = output(); - readonly removeFromFavorites = output(); + readonly removeFavorite = output(); readonly openItem = output(); setCategoryId(categoryId: any) { this.categoryClicked.emit({ category_id: categoryId }); } - removeFavorite(item: any) { - this.removeFromFavorites.emit(item); + removeFromFavorites(item: any) { + this.removeFavorite.emit(item); } openFavorite(item: any) { diff --git a/apps/web/src/app/stalker/stalker-main-container.component.html b/apps/web/src/app/stalker/stalker-main-container.component.html index c77c1102c..bd7374da4 100644 --- a/apps/web/src/app/stalker/stalker-main-container.component.html +++ b/apps/web/src/app/stalker/stalker-main-container.component.html @@ -117,5 +117,5 @@
- + diff --git a/apps/web/src/app/stalker/stalker-main-container.component.ts b/apps/web/src/app/stalker/stalker-main-container.component.ts index abb9df681..16d39fe1c 100644 --- a/apps/web/src/app/stalker/stalker-main-container.component.ts +++ b/apps/web/src/app/stalker/stalker-main-container.component.ts @@ -8,7 +8,6 @@ import { ActivatedRoute, Router, RouterOutlet } from '@angular/router'; import { TranslatePipe } from '@ngx-translate/core'; import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader'; import { PlaylistsService } from 'services'; -import { MpvPlayerBarComponent } from '../shared/components/mpv-player-bar/mpv-player-bar.component'; import { CategoryViewComponent } from '../xtream-tauri/category-view/category-view.component'; import { PlaylistErrorViewComponent } from '../xtream/playlist-error-view/playlist-error-view.component'; import { StalkerStore } from './stalker.store'; @@ -28,7 +27,7 @@ import { StalkerStore } from './stalker.store'; MatIconButton, MatListModule, MatPaginatorModule, - MpvPlayerBarComponent, + /* MpvPlayerBarComponent, */ NgxSkeletonLoaderModule, PlaylistErrorViewComponent, TranslatePipe, diff --git a/apps/web/src/app/xtream-tauri/favorites/favorites.component.ts b/apps/web/src/app/xtream-tauri/favorites/favorites.component.ts index ff1af6c1d..2d0be5634 100644 --- a/apps/web/src/app/xtream-tauri/favorites/favorites.component.ts +++ b/apps/web/src/app/xtream-tauri/favorites/favorites.component.ts @@ -99,7 +99,7 @@ export class FavoritesComponent implements OnInit { async removeFromFavorites(item: any) { await this.favoritesService.removeFromFavorites( - item.id, + item.content_id, item.playlist_id ); // Refresh favorites after removal diff --git a/apps/web/src/app/xtream-tauri/live-stream-layout/live-stream-layout.component.html b/apps/web/src/app/xtream-tauri/live-stream-layout/live-stream-layout.component.html index bdc4e497a..8f37d1aaf 100644 --- a/apps/web/src/app/xtream-tauri/live-stream-layout/live-stream-layout.component.html +++ b/apps/web/src/app/xtream-tauri/live-stream-layout/live-stream-layout.component.html @@ -1,4 +1,5 @@ -@let isEmbeddedPlayer = player() === 'videojs' || player() === 'html5'; +@let isEmbeddedPlayer = + player() === 'videojs' || player() === 'html5' || player() === 'artplayer'; diff --git a/apps/web/src/app/xtream-tauri/live-stream-layout/live-stream-layout.component.scss b/apps/web/src/app/xtream-tauri/live-stream-layout/live-stream-layout.component.scss index 0007e6683..85a277495 100644 --- a/apps/web/src/app/xtream-tauri/live-stream-layout/live-stream-layout.component.scss +++ b/apps/web/src/app/xtream-tauri/live-stream-layout/live-stream-layout.component.scss @@ -54,3 +54,13 @@ font-size: 1.2rem; font-weight: 500; } + +::ng-deep { + .video-player + > app-web-player-view + > app-art-player + > .artplayer-container { + height: 100% !important; + width: 100% !important; + } +} diff --git a/apps/web/src/app/xtream-tauri/live-stream-layout/live-stream-layout.component.ts b/apps/web/src/app/xtream-tauri/live-stream-layout/live-stream-layout.component.ts index 759f0ff8e..fbb0f02af 100644 --- a/apps/web/src/app/xtream-tauri/live-stream-layout/live-stream-layout.component.ts +++ b/apps/web/src/app/xtream-tauri/live-stream-layout/live-stream-layout.component.ts @@ -15,7 +15,6 @@ import { TranslatePipe } from '@ngx-translate/core'; import { XtreamCategory } from 'shared-interfaces'; import { EpgViewComponent, WebPlayerViewComponent } from 'shared-portals'; import { SettingsStore } from '../../services/settings-store.service'; -import { MpvPlayerBarComponent } from '../../shared/components/mpv-player-bar/mpv-player-bar.component'; import { CategoryViewComponent } from '../category-view/category-view.component'; import { PortalChannelsListComponent } from '../portal-channels-list/portal-channels-list.component'; import { FavoritesService } from '../services/favorites.service'; @@ -34,7 +33,7 @@ import { XtreamStore } from '../xtream.store'; MatIconButton, MatInputModule, MatListModule, - MpvPlayerBarComponent, + /* MpvPlayerBarComponent, */ PortalChannelsListComponent, TranslatePipe, WebPlayerViewComponent, diff --git a/apps/web/src/app/xtream-tauri/search-results/search-results.component.ts b/apps/web/src/app/xtream-tauri/search-results/search-results.component.ts index fdeb50fea..b3da52c84 100644 --- a/apps/web/src/app/xtream-tauri/search-results/search-results.component.ts +++ b/apps/web/src/app/xtream-tauri/search-results/search-results.component.ts @@ -3,7 +3,6 @@ import { AfterViewInit, Component, effect, - ElementRef, inject, Inject, Optional, @@ -55,7 +54,7 @@ interface SearchResultsData { styleUrls: ['./search-results.component.scss'], }) export class SearchResultsComponent implements AfterViewInit { - @ViewChild('searchInput') searchInput!: ElementRef; + @ViewChild(SearchFormComponent) searchFormComponent!: SearchFormComponent; readonly xtreamStore = inject(XtreamStore); readonly router = inject(Router); readonly activatedRoute = inject(ActivatedRoute); @@ -105,7 +104,7 @@ export class SearchResultsComponent implements AfterViewInit { ngAfterViewInit() { this.xtreamStore.setSelectedContentType(undefined); setTimeout(() => { - this.searchInput.nativeElement.focus(); + this.searchFormComponent?.focusSearchInput(); }); } diff --git a/apps/web/src/app/xtream-tauri/services/favorite-item.interface.ts b/apps/web/src/app/xtream-tauri/services/favorite-item.interface.ts index 03f9409c7..f591f6b58 100644 --- a/apps/web/src/app/xtream-tauri/services/favorite-item.interface.ts +++ b/apps/web/src/app/xtream-tauri/services/favorite-item.interface.ts @@ -6,4 +6,6 @@ export interface FavoriteItem { stream_icon?: string; poster_url?: string; added_at?: string; + category_id: number; + xtream_id: number; } diff --git a/apps/web/src/app/xtream-tauri/services/favorites.service.ts b/apps/web/src/app/xtream-tauri/services/favorites.service.ts index da9fc2f78..1072d677c 100644 --- a/apps/web/src/app/xtream-tauri/services/favorites.service.ts +++ b/apps/web/src/app/xtream-tauri/services/favorites.service.ts @@ -1,5 +1,5 @@ import { inject, Injectable } from '@angular/core'; -import { from, map, mergeMap, Observable } from 'rxjs'; +import { from, map, Observable } from 'rxjs'; import { DatabaseService } from 'services'; import { FavoriteItem } from './favorite-item.interface'; @@ -13,54 +13,34 @@ export class FavoritesService { content_id: number; playlist_id: string; }): Promise { - const db = await this.dbService.getConnection(); - await db.execute( - `INSERT INTO favorites (content_id, playlist_id) - VALUES (?, ?)`, - [item.content_id, item.playlist_id] - ); + await this.dbService.addToFavorites(item.content_id, item.playlist_id); } async removeFromFavorites( contentId: number, playlistId: string ): Promise { - const db = await this.dbService.getConnection(); - await db.execute( - `DELETE FROM favorites - WHERE content_id = ? AND playlist_id = ?`, - [contentId, playlistId] - ); + await this.dbService.removeFromFavorites(contentId, playlistId); } async isFavorite(contentId: number, playlistId: string): Promise { - const db = await this.dbService.getConnection(); - const result = await db.select( - `SELECT COUNT(*) as count - FROM favorites f - JOIN content c ON f.content_id = c.id - WHERE c.xtream_id = ? AND f.playlist_id = ?`, - [contentId, playlistId] - ); - return result[0].count > 0; + return await this.dbService.isFavorite(contentId, playlistId); } getFavorites(playlistId: string): Observable { - return from(this.dbService.getConnection()).pipe( - mergeMap(async (db) => { - return await db.select( - `SELECT - c.*, - f.playlist_id, - f.added_at - FROM favorites f - JOIN content c ON f.content_id = c.id - WHERE f.playlist_id = ? - ORDER BY f.added_at DESC`, - [playlistId] - ); - }), - map((results) => results) + return from(this.dbService.getFavorites(playlistId)).pipe( + map((items) => + items.map((item) => ({ + content_id: item.id, + playlist_id: playlistId, + type: item.type as 'live' | 'vod' | 'series', + title: item.title, + poster_url: item.poster_url, + added_at: item.added_at, + category_id: item.category_id, + xtream_id: item.xtream_id, + })) + ) ); } } diff --git a/apps/web/src/app/xtream-tauri/with-favorites.feature.ts b/apps/web/src/app/xtream-tauri/with-favorites.feature.ts index 43ed3f42e..2b6e21dae 100644 --- a/apps/web/src/app/xtream-tauri/with-favorites.feature.ts +++ b/apps/web/src/app/xtream-tauri/with-favorites.feature.ts @@ -20,45 +20,42 @@ export const withFavorites = function () { favoritesService = inject(FavoritesService) ) => ({ async toggleFavorite(xtreamId: number, playlistId: string) { - let result = false; - if (!xtreamId || !playlistId) return; - - const db = await dbService.getConnection(); - - const content: any = await db.select( - 'SELECT content.id FROM content ' + - 'INNER JOIN categories ON content.category_id = categories.id ' + - 'WHERE content.xtream_id = ? AND categories.playlist_id = ?', - [xtreamId, playlistId] - ); - - if (!content || content.length === 0) { - console.error('Content not found in database'); - return; + if (!xtreamId || !playlistId) { + return false; } - const contentId = content[0].id; - const isFavorite = await favoritesService.isFavorite( + // Get content by xtream ID + const content = await dbService.getContentByXtreamId( xtreamId, playlistId ); - - if (isFavorite) { - await favoritesService.removeFromFavorites( - contentId, - playlistId + if (!content) { + console.error( + 'Content not found for xtream ID:', + xtreamId ); - result = false; - } else { - await favoritesService.addToFavorites({ - content_id: contentId, - playlist_id: playlistId, - }); - result = true; + return false; } - patchState(store, { isFavorite: !isFavorite }); - return result; + const currentStatus = store.isFavorite(); + + if (currentStatus) { + // Remove from favorites + await favoritesService.removeFromFavorites( + content.id, + playlistId + ); + patchState(store, { isFavorite: false }); + return false; + } else { + // Add to favorites + await favoritesService.addToFavorites({ + content_id: content.id, + playlist_id: playlistId, + }); + patchState(store, { isFavorite: true }); + return true; + } }, async checkFavoriteStatus( @@ -70,13 +67,18 @@ export const withFavorites = function () { return; } - if (!xtreamId) { + // Get content by xtream ID + const content = await dbService.getContentByXtreamId( + xtreamId, + playlistId + ); + if (!content) { patchState(store, { isFavorite: false }); return; } const isFavorite = await favoritesService.isFavorite( - xtreamId, + content.id, playlistId ); diff --git a/apps/web/src/app/xtream-tauri/with-recent-items.ts b/apps/web/src/app/xtream-tauri/with-recent-items.ts index b378a6650..284568b91 100644 --- a/apps/web/src/app/xtream-tauri/with-recent-items.ts +++ b/apps/web/src/app/xtream-tauri/with-recent-items.ts @@ -18,6 +18,7 @@ export interface RecentlyViewedItem { playlist_id: string; viewed_at: string; xtream_id: number; + category_id: number; } export const withRecentItems = function () { @@ -29,30 +30,21 @@ export const withRecentItems = function () { loadRecentItems: rxMethod<{ id: string }>( pipe( switchMap(async (playlist) => { - if (!playlist) return []; - console.log( - 'Loading recent items for playlist', + const items = await dbService.getRecentItems( playlist.id ); - const db = await dbService.getConnection(); - return db.select( - `SELECT - rv.id, - c.title, - c.type, - c.poster_url, - c.id as content_id, - rv.playlist_id, - rv.viewed_at, - c.xtream_id, - c.category_id - FROM recently_viewed rv - JOIN content c ON rv.content_id = c.id - WHERE rv.playlist_id = ? - ORDER BY rv.viewed_at DESC - LIMIT 50`, - [playlist.id] - ); + // Map to RecentlyViewedItem format + return items.map((item) => ({ + id: item.id, + title: item.title, + type: item.type as 'live' | 'movie' | 'series', + poster_url: item.poster_url, + content_id: item.id, + playlist_id: playlist.id, + viewed_at: item.viewed_at || '', + xtream_id: item.xtream_id, + category_id: item.category_id, + })); }), tap((items: RecentlyViewedItem[]) => patchState(store, { recentItems: items }) @@ -67,50 +59,16 @@ export const withRecentItems = function () { }>( pipe( switchMap(async ({ contentId, playlist }) => { - if (!playlist().id) { - console.error('No active playlist found'); - return; - } - - console.log( - 'Adding to recently viewed:', + // contentId is actually xtream_id, need to look up the database content.id + const content = await dbService.getContentByXtreamId( + contentId, playlist().id ); - - const db = await dbService.getConnection(); - - const content: any = await db.select( - 'SELECT content.id FROM content ' + - 'INNER JOIN categories ON content.category_id = categories.id ' + - 'WHERE content.xtream_id = ? AND categories.playlist_id = ?', - [contentId, playlist().id] - ); - - if (content && content.length > 0) { - // Check if item already exists in recently_viewed - const existing: any = await db.select( - 'SELECT recently_viewed.id FROM recently_viewed ' + - 'INNER JOIN content ON recently_viewed.content_id = content.id ' + - 'INNER JOIN categories ON content.category_id = categories.id ' + - 'WHERE content.id = ? AND categories.playlist_id = ?', - [content[0].id, playlist().id] + if (content) { + await dbService.addRecentItem( + content.id, + playlist().id ); - - if (existing && existing.length > 0) { - // Update existing record's viewed_at timestamp - await db.execute( - 'UPDATE recently_viewed SET viewed_at = CURRENT_TIMESTAMP WHERE id = ?', - [existing[0].id] - ); - } else { - // Insert new record - await db.execute( - `INSERT INTO recently_viewed (content_id, playlist_id) - VALUES (?, ?)`, - [content[0].id, playlist().id] - ); - } - return store.loadRecentItems({ id: playlist().id }); } }) ) @@ -118,28 +76,30 @@ export const withRecentItems = function () { clearRecentItems: rxMethod<{ id: string }>( pipe( switchMap(async (playlist) => { - console.log( - 'Clearing recent items for playlist', - playlist.id - ); - const db = await dbService.getConnection(); - await db.execute( - `DELETE FROM recently_viewed WHERE playlist_id = ?`, - [playlist.id] - ); - return store.loadRecentItems({ id: playlist.id }); + await dbService.clearPlaylistRecentItems(playlist.id); + patchState(store, { recentItems: [] }); }) ) ), removeRecentItem: rxMethod<{ itemId: number; playlistId: string }>( pipe( switchMap(async ({ itemId, playlistId }) => { - const db = await dbService.getConnection(); - await db.execute( - `DELETE FROM recently_viewed WHERE id = ? AND playlist_id = ?`, - [itemId, playlistId] - ); - return store.loadRecentItems({ id: playlistId }); + await dbService.removeRecentItem(itemId, playlistId); + // Reload recent items to update UI + const items = + await dbService.getRecentItems(playlistId); + const mappedItems = items.map((item) => ({ + id: item.id, + title: item.title, + type: item.type as 'live' | 'movie' | 'series', + poster_url: item.poster_url, + content_id: item.id, + playlist_id: playlistId, + viewed_at: item.viewed_at || '', + xtream_id: item.xtream_id, + category_id: item.category_id, + })); + patchState(store, { recentItems: mappedItems }); }) ) ), diff --git a/apps/web/src/app/xtream-tauri/xtream-main-container.component.html b/apps/web/src/app/xtream-tauri/xtream-main-container.component.html index cdd557491..7b72c3881 100644 --- a/apps/web/src/app/xtream-tauri/xtream-main-container.component.html +++ b/apps/web/src/app/xtream-tauri/xtream-main-container.component.html @@ -30,5 +30,5 @@
- + diff --git a/apps/web/src/app/xtream-tauri/xtream-main-container.component.ts b/apps/web/src/app/xtream-tauri/xtream-main-container.component.ts index a30554f57..4e3a4b4cf 100644 --- a/apps/web/src/app/xtream-tauri/xtream-main-container.component.ts +++ b/apps/web/src/app/xtream-tauri/xtream-main-container.component.ts @@ -4,7 +4,6 @@ import { MatIcon } from '@angular/material/icon'; import { ActivatedRoute, Router, RouterOutlet } from '@angular/router'; import { TranslateModule, TranslateService } from '@ngx-translate/core'; import { XtreamCategory } from 'shared-interfaces'; -import { MpvPlayerBarComponent } from '../shared/components/mpv-player-bar/mpv-player-bar.component'; import { CategoryViewComponent } from './category-view/category-view.component'; import { XtreamStore } from './xtream.store'; @@ -16,7 +15,7 @@ import { XtreamStore } from './xtream.store'; CategoryViewComponent, TranslateModule, RouterOutlet, - MpvPlayerBarComponent, + /* MpvPlayerBarComponent, */ MatIcon, MatIconButton, ], diff --git a/apps/web/src/app/xtream-tauri/xtream.store.ts b/apps/web/src/app/xtream-tauri/xtream.store.ts index abdbf78a9..a4e37b920 100644 --- a/apps/web/src/app/xtream-tauri/xtream.store.ts +++ b/apps/web/src/app/xtream-tauri/xtream.store.ts @@ -568,51 +568,57 @@ export const XtreamStore = signalStore( try { console.log('Starting content initialization...'); - console.log('Fetching live categories...'); - await lastValueFrom( - fetchCategories( - XtreamCodeActions.GetLiveCategories, - 'liveCategories', - 'live' - ) - ); - await lastValueFrom( - fetchCategories( - XtreamCodeActions.GetVodCategories, - 'vodCategories', - 'movies' - ) - ); - await lastValueFrom( - fetchCategories( - XtreamCodeActions.GetSeriesCategories, - 'serialCategories', - 'series' - ) - ); + // 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 lastValueFrom( - fetchStreams( - XtreamCodeActions.GetLiveStreams, - 'liveStreams', - 'live' - ) - ); - await lastValueFrom( - fetchStreams( - XtreamCodeActions.GetVodStreams, - 'vodStreams', - 'movie' - ) - ); - await lastValueFrom( - fetchStreams( - XtreamCodeActions.GetSeries, - 'serialStreams', - 'series' - ) - ); + 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'); } catch (error) { console.error( @@ -627,7 +633,7 @@ export const XtreamStore = signalStore( const checkPortalStatus = rxMethod( pipe( combineLatestWith(oldStore.select(selectActivePlaylist)), - switchMap(([_, playlist]) => { + switchMap(([, playlist]) => { if (!playlist) return EMPTY; return from( diff --git a/apps/web/src/assets/i18n/en.json b/apps/web/src/assets/i18n/en.json index f140a02cc..ea3f05bc9 100644 --- a/apps/web/src/assets/i18n/en.json +++ b/apps/web/src/assets/i18n/en.json @@ -54,6 +54,13 @@ }, "UPDATED": "Updated", "REFRESH": "Refresh playlist", + "REFRESH_XTREAM": "Refresh Xtream playlist from remote", + "REFRESH_XTREAM_DIALOG": { + "TITLE": "Refresh Xtream Playlist", + "MESSAGE": "This will delete locally cached categories and streams and re-import everything from the remote server. Your favorites and recently viewed items will be preserved. Are you sure you want to continue?", + "STARTED": "Refresh started. Navigating to playlist...", + "ERROR": "Failed to refresh playlist. Please try again." + }, "GLOBAL_FAVORITES": "Global favorites", "GLOBAL_FAVORITES_DESCRIPTION": "Auto-generated playlist with aggregated favorites from all playlists", "MY_PLAYLISTS": "My playlists", diff --git a/drizzle.config.ts b/drizzle.config.ts new file mode 100644 index 000000000..56943a619 --- /dev/null +++ b/drizzle.config.ts @@ -0,0 +1,22 @@ +import type { Config } from 'drizzle-kit'; +import { homedir } from 'os'; +import { join } from 'path'; + +// Use home directory with a simple path (no spaces) to avoid libSQL issues +// This matches the path used in connection.ts +const dbDir = join(homedir(), '.iptvnator', 'databases'); +const dbPath = join(dbDir, 'iptvnator.db'); +// libSQL requires file: prefix +const dbUrl = `file:${dbPath}`; + +export default { + schema: './apps/electron-backend/src/app/database/schema.ts', + out: './apps/electron-backend/src/app/database/migrations', + // libSQL/Turso dialect works for both remote and local file URLs + dialect: 'turso', + dbCredentials: { + // Use file: URL format + url: dbUrl, + // When using remote, set LIBSQL_URL/LIBSQL_AUTH_TOKEN envs and run studio with --config + }, +} satisfies Config; diff --git a/global.d.ts b/global.d.ts index ca7473234..c48fc02b5 100644 --- a/global.d.ts +++ b/global.d.ts @@ -44,6 +44,93 @@ declare global { updateSettings: (settings: any) => Promise; setMpvPlayerPath: (mpvPlayerPath: string) => Promise; setVlcPlayerPath: (vlcPlayerPath: string) => Promise; + stalkerRequest: (payload: { + url: string; + macAddress: string; + params: Record; + }) => Promise; + xtreamRequest: (payload: { + url: string; + params: Record; + }) => Promise<{ payload: any; action: string }>; + // Database operations + dbCreatePlaylist: (playlist: any) => Promise<{ success: boolean }>; + dbGetPlaylist: (playlistId: string) => Promise; + dbUpdatePlaylist: ( + playlistId: string, + updates: any + ) => Promise<{ success: boolean }>; + dbDeletePlaylist: ( + playlistId: string + ) => Promise<{ success: boolean }>; + dbDeleteXtreamContent: ( + playlistId: string + ) => Promise<{ success: boolean }>; + dbHasCategories: ( + playlistId: string, + type: string + ) => Promise; + dbGetCategories: ( + playlistId: string, + type: string + ) => Promise; + dbSaveCategories: ( + playlistId: string, + categories: any[], + type: string + ) => Promise<{ success: boolean }>; + dbHasContent: ( + playlistId: string, + type: string + ) => Promise; + dbGetContent: (playlistId: string, type: string) => Promise; + dbSaveContent: ( + playlistId: string, + streams: any[], + type: string + ) => Promise<{ success: boolean; count: number }>; + dbSearchContent: ( + playlistId: string, + searchTerm: string, + types: string[] + ) => Promise; + dbGlobalSearch: ( + searchTerm: string, + types: string[] + ) => Promise; + dbGetRecentlyViewed: () => Promise; + dbClearRecentlyViewed: () => Promise<{ success: boolean }>; + // Favorites + dbAddFavorite: ( + contentId: number, + playlistId: string + ) => Promise<{ success: boolean }>; + dbRemoveFavorite: ( + contentId: number, + playlistId: string + ) => Promise<{ success: boolean }>; + dbIsFavorite: ( + contentId: number, + playlistId: string + ) => Promise; + dbGetFavorites: (playlistId: string) => Promise; + // Recently viewed (playlist-specific) + dbGetRecentItems: (playlistId: string) => Promise; + dbAddRecentItem: ( + contentId: number, + playlistId: string + ) => Promise<{ success: boolean }>; + dbClearPlaylistRecentItems: ( + playlistId: string + ) => Promise<{ success: boolean }>; + dbRemoveRecentItem: ( + contentId: number, + playlistId: string + ) => Promise<{ success: boolean }>; + dbGetContentByXtreamId: ( + xtreamId: number, + playlistId: string + ) => Promise; }; process: NodeJS.Process; require: NodeRequire; diff --git a/libs/m3u-state/src/lib/effects.ts b/libs/m3u-state/src/lib/effects.ts index 4f6d87072..8860543ba 100644 --- a/libs/m3u-state/src/lib/effects.ts +++ b/libs/m3u-state/src/lib/effects.ts @@ -5,7 +5,6 @@ import { Actions, createEffect, ofType } from '@ngrx/effects'; import { Store } from '@ngrx/store'; import { StorageMap } from '@ngx-pwa/local-storage'; import { TranslateService } from '@ngx-translate/core'; -import Database from '@tauri-apps/plugin-sql'; import { combineLatestWith, firstValueFrom, @@ -253,20 +252,16 @@ export class PlaylistEffects { }), map(async (playlist) => { if (playlist.serverUrl && this.dataService.isElectron) { - const db = await Database.load('sqlite:database.db'); - const result = await db.execute( - `INSERT INTO playlists (id, name, serverUrl, username, password, type) - VALUES (?, ?, ?, ?, ?, ?)`, - [ - playlist._id.toString(), - playlist.title || '', - playlist.serverUrl || '', - playlist.username || '', - playlist.password || '', - 'xtream', - ] - ); - console.log('inserted item', result); + // Use Electron database API + await window.electron.dbCreatePlaylist({ + id: playlist._id.toString(), + name: playlist.title || '', + serverUrl: playlist.serverUrl || '', + username: playlist.username || '', + password: playlist.password || '', + type: 'xtream', + }); + console.log('Playlist created in database'); this.router.navigate(['/xtreams/', playlist._id]); } }) diff --git a/libs/services/src/index.ts b/libs/services/src/index.ts index e61e92cfb..35ee1b2d7 100644 --- a/libs/services/src/index.ts +++ b/libs/services/src/index.ts @@ -1,5 +1,5 @@ export * from './lib/data.service'; -export * from './lib/database.service'; +export * from './lib/database-electron.service'; export * from './lib/epg.service'; export * from './lib/playlists.service'; export * from './lib/portal-status.service'; diff --git a/libs/services/src/lib/database-electron.service.ts b/libs/services/src/lib/database-electron.service.ts new file mode 100644 index 000000000..a35591a8d --- /dev/null +++ b/libs/services/src/lib/database-electron.service.ts @@ -0,0 +1,398 @@ +/** + * Database service for Electron renderer process + * Communicates with the main process database via IPC + */ + +import { Injectable } from '@angular/core'; +import { PlaylistMeta } from 'shared-interfaces'; + +export interface XCategoryFromDb { + id: number; + name: string; + playlist_id: string; + type: 'movies' | 'live' | 'series'; + xtream_id: number; +} + +export interface XtreamContent { + id: number; + category_id: number; + title: string; + rating: string; + added: string; + poster_url: string; + xtream_id: number; + type: string; + added_at?: string; + viewed_at?: string; +} + +export interface XtreamPlaylist { + id: string; + name: string; + serverUrl: string; + username: string; + password: string; + type: string; +} + +export interface GlobalSearchResult extends XtreamContent { + playlist_id: string; + playlist_name: string; +} + +export interface GlobalRecentItem extends XtreamContent { + playlist_id: string; + playlist_name: string; + viewed_at: string; +} + +@Injectable({ + providedIn: 'root', +}) +export class DatabaseService { + /** + * Delete a playlist and all its related data + */ + async deletePlaylist(playlistId: string): Promise { + try { + await window.electron.dbDeletePlaylist(playlistId); + return true; + } catch (error) { + console.error('Error deleting playlist:', error); + return false; + } + } + + /** + * Delete all content and categories for an Xtream playlist (for refresh) + * Keeps the playlist entry but removes all imported data + */ + async deleteXtreamPlaylistContent(playlistId: string): Promise { + await window.electron.dbDeleteXtreamContent(playlistId); + } + + /** + * Update playlist basic info + */ + async updateXtreamPlaylist(playlist: any): Promise { + try { + await window.electron.dbUpdatePlaylist(playlist.id, { + name: playlist.name, + }); + return true; + } catch (error) { + console.error('Error updating playlist:', error); + return false; + } + } + + /** + * Update playlist details including credentials + */ + async updateXtreamPlaylistDetails(playlist: { + id: string; + title?: string; + username?: string; + password?: string; + serverUrl?: string; + updateDate?: number; + }): Promise { + try { + const updates: any = {}; + if (playlist.title) updates.name = playlist.title; + if (playlist.username) updates.username = playlist.username; + if (playlist.password) updates.password = playlist.password; + if (playlist.serverUrl) updates.serverUrl = playlist.serverUrl; + if (playlist.updateDate !== undefined) + updates.lastUpdated = new Date( + playlist.updateDate + ).toISOString(); + + await window.electron.dbUpdatePlaylist(playlist.id, updates); + return true; + } catch (error) { + console.error('Error updating playlist details:', error); + return false; + } + } + + /** + * Check if categories exist + */ + async hasXtreamCategories( + playlistId: string, + type: 'live' | 'movies' | 'series' + ): Promise { + return await window.electron.dbHasCategories(playlistId, type); + } + + /** + * Get categories for a playlist + */ + async getXtreamCategories( + playlistId: string, + type: 'live' | 'movies' | 'series' + ): Promise { + return await window.electron.dbGetCategories(playlistId, type); + } + + /** + * Save categories in bulk + */ + async saveXtreamCategories( + playlistId: string, + categories: any[], + type: 'live' | 'movies' | 'series' + ): Promise { + await window.electron.dbSaveCategories(playlistId, categories, type); + } + + /** + * Check if content exists + */ + async hasXtreamContent( + playlistId: string, + type: 'live' | 'movie' | 'series' + ): Promise { + return await window.electron.dbHasContent(playlistId, type); + } + + /** + * Get content for a playlist + */ + async getXtreamContent( + playlistId: string, + type: 'live' | 'movie' | 'series' + ): Promise { + return await window.electron.dbGetContent(playlistId, type); + } + + /** + * Save content in bulk + */ + async saveXtreamContent( + playlistId: string, + streams: any[], + type: 'live' | 'movie' | 'series', + onProgress?: (count: number) => void + ): Promise { + // Setup progress listener if callback provided + if (onProgress) { + window.addEventListener('message', (event) => { + if (event.data.type === 'DB_SAVE_CONTENT_PROGRESS') { + onProgress(event.data.count); + } + }); + } + + const result = await window.electron.dbSaveContent( + playlistId, + streams, + type + ); + return result.count; + } + + /** + * Search content within a playlist + */ + async searchXtreamContent( + playlistId: string, + searchTerm: string, + types: string[] + ): Promise { + return await window.electron.dbSearchContent( + playlistId, + searchTerm, + types + ); + } + + /** + * Global search across all playlists + */ + async globalSearchContent( + searchTerm: string, + types: string[] + ): Promise { + return await window.electron.dbGlobalSearch(searchTerm, types); + } + + /** + * Get recently viewed items + */ + async getGlobalRecentlyViewed(): Promise { + try { + const items = await window.electron.dbGetRecentlyViewed(); + return items || []; + } catch (error) { + console.error('Error getting recently viewed:', error); + return []; + } + } + + /** + * Clear recently viewed items + */ + async clearGlobalRecentlyViewed(): Promise { + try { + await window.electron.dbClearRecentlyViewed(); + } catch (error) { + console.error('Error clearing recently viewed:', error); + throw error; + } + } + + /** + * Get playlist by ID + */ + async getPlaylistById(playlistId: string): Promise { + return await window.electron.dbGetPlaylist(playlistId); + } + + /** + * Create a new playlist + */ + async createPlaylist(playlist: PlaylistMeta): Promise { + await window.electron.dbCreatePlaylist({ + id: playlist._id, + name: playlist.title, + serverUrl: playlist.serverUrl, + username: playlist.username, + password: playlist.password, + type: 'xtream', + }); + } + + /** + * Add content to favorites + */ + async addToFavorites( + contentId: number, + playlistId: string + ): Promise { + try { + await window.electron.dbAddFavorite(contentId, playlistId); + return true; + } catch (error) { + console.error('Error adding to favorites:', error); + return false; + } + } + + /** + * Remove content from favorites + */ + async removeFromFavorites( + contentId: number, + playlistId: string + ): Promise { + try { + await window.electron.dbRemoveFavorite(contentId, playlistId); + return true; + } catch (error) { + console.error('Error removing from favorites:', error); + return false; + } + } + + /** + * Check if content is favorited + */ + async isFavorite(contentId: number, playlistId: string): Promise { + try { + return await window.electron.dbIsFavorite(contentId, playlistId); + } catch (error) { + console.error('Error checking favorite:', error); + return false; + } + } + + /** + * Get all favorites for a playlist + */ + async getFavorites(playlistId: string): Promise { + try { + return await window.electron.dbGetFavorites(playlistId); + } catch (error) { + console.error('Error getting favorites:', error); + return []; + } + } + + /** + * Get recently viewed items for a specific playlist + */ + async getRecentItems(playlistId: string): Promise { + try { + return await window.electron.dbGetRecentItems(playlistId); + } catch (error) { + console.error('Error getting recent items:', error); + return []; + } + } + + /** + * Add item to recently viewed + */ + async addRecentItem( + contentId: number, + playlistId: string + ): Promise { + try { + await window.electron.dbAddRecentItem(contentId, playlistId); + return true; + } catch (error) { + console.error('Error adding recent item:', error); + return false; + } + } + + /** + * Clear recently viewed for a specific playlist + */ + async clearPlaylistRecentItems(playlistId: string): Promise { + try { + await window.electron.dbClearPlaylistRecentItems(playlistId); + return true; + } catch (error) { + console.error('Error clearing playlist recent items:', error); + return false; + } + } + + /** + * Remove specific item from recently viewed + */ + async removeRecentItem( + contentId: number, + playlistId: string + ): Promise { + try { + await window.electron.dbRemoveRecentItem(contentId, playlistId); + return true; + } catch (error) { + console.error('Error removing recent item:', error); + return false; + } + } + + /** + * Get content by xtream ID + */ + async getContentByXtreamId( + xtreamId: number, + playlistId: string + ): Promise { + try { + return await window.electron.dbGetContentByXtreamId( + xtreamId, + playlistId + ); + } catch (error) { + console.error('Error getting content by xtream ID:', error); + return null; + } + } +} diff --git a/libs/services/src/lib/database.service.ts b/libs/services/src/lib/database.service.ts deleted file mode 100644 index 00f4c4ecd..000000000 --- a/libs/services/src/lib/database.service.ts +++ /dev/null @@ -1,484 +0,0 @@ -import { Injectable } from '@angular/core'; -import Database from '@tauri-apps/plugin-sql'; -import { PlaylistMeta } from 'shared-interfaces'; - -export interface XCategoryFromDb { - id: number; - name: string; - playlist_id: string; - type: 'movies' | 'live' | 'series'; - xtream_id: number; -} - -export interface XtreamContent { - id: number; - category_id: number; - title: string; - rating: string; - added: string; - poster_url: string; - xtream_id: number; - type: string; -} - -export interface XtreamPlaylist { - id: string; - name: string; - serverUrl: string; - username: string; - password: string; - type: string; -} - -export interface GlobalSearchResult extends XtreamContent { - playlist_id: string; - playlist_name: string; -} - -export interface GlobalRecentItem extends XtreamContent { - playlist_id: string; - playlist_name: string; - viewed_at: string; -} - -@Injectable({ - providedIn: 'root', -}) -export class DatabaseService { - private static db: Database | null = null; - - async getConnection(): Promise { - if (!DatabaseService.db) { - DatabaseService.db = await Database.load('sqlite:database.db'); - } - return DatabaseService.db; - } - - /** - * Delete a playlist and all its related data - * @param playlistId ID of the playlist to delete - * @returns True if deletion was successful - */ - async deletePlaylist(playlistId: string): Promise { - try { - const db = await this.getConnection(); - - // Start a transaction to ensure all related data is deleted - await db.execute('BEGIN TRANSACTION'); - - try { - // First, delete favorites related to the playlist to reduce foreign key checks - await db.execute( - 'DELETE FROM favorites WHERE playlist_id = ?', - [playlistId] - ); - - // Delete recently_viewed entries directly using playlist_id - await db.execute( - 'DELETE FROM recently_viewed WHERE playlist_id = ?', - [playlistId] - ); - - // Get category IDs first to speed up content deletion (avoids subquery) - const categories = await db.select<{ id: number }[]>( - 'SELECT id FROM categories WHERE playlist_id = ?', - [playlistId] - ); - - if (categories.length > 0) { - // Create a list of category IDs for the IN clause - const categoryIds = categories.map((cat) => cat.id); - - // Delete content in batches if there are many categories - const BATCH_SIZE = 20; - for (let i = 0; i < categoryIds.length; i += BATCH_SIZE) { - const batchIds = categoryIds.slice(i, i + BATCH_SIZE); - const placeholders = batchIds.map(() => '?').join(','); - - await db.execute( - `DELETE FROM content WHERE category_id IN (${placeholders})`, - batchIds - ); - } - } - - // Delete categories related to the playlist - await db.execute( - 'DELETE FROM categories WHERE playlist_id = ?', - [playlistId] - ); - - // Finally, delete the playlist itself - await db.execute('DELETE FROM playlists WHERE id = ?', [ - playlistId, - ]); - - // Commit the transaction - await db.execute('COMMIT'); - console.log('Playlist deleted successfully'); - return true; - } catch (error) { - // If any error occurs, rollback the transaction - console.error('Error in transaction, rolling back:', error); - await db.execute('ROLLBACK'); - throw error; - } - } catch (error) { - console.error('Error deleting playlist:', error); - return false; - } - } - - async updateXtreamPlaylist(playlist: any): Promise { - try { - const db = await this.getConnection(); - await db.execute('UPDATE playlists SET name = ? WHERE id = ?', [ - playlist.name, - playlist.id, - ]); - return true; - } catch (error) { - console.error('Error updating playlist:', error); - return false; - } - } - - async updateXtreamPlaylistDetails(playlist: { - id: string; - title: string; - username?: string; - password?: string; - serverUrl?: string; - }): Promise { - try { - const db = await this.getConnection(); - const updateFields: string[] = ['name = ?']; - const params: any[] = [playlist.title]; - - if (playlist.username) { - updateFields.push('username = ?'); - params.push(playlist.username); - } - if (playlist.password) { - updateFields.push('password = ?'); - params.push(playlist.password); - } - if (playlist.serverUrl) { - updateFields.push('serverUrl = ?'); - params.push(playlist.serverUrl); - } - - params.push(playlist.id); - - console.log(params); - - const query = `UPDATE playlists SET ${updateFields.join(', ')} WHERE id = ?`; - await db.execute(query, params); - return true; - } catch (error) { - console.error('Error updating playlist details:', error); - return false; - } - } - - async hasXtreamCategories( - playlistId: string, - type: 'live' | 'movies' | 'series' - ): Promise { - const db = await this.getConnection(); - const result = await db.select( - 'SELECT * FROM categories WHERE playlist_id = ? AND type = ?', - [playlistId, type] - ); - return result.length > 0; - } - - async getXtreamCategories( - playlistId: string, - type: 'live' | 'movies' | 'series' - ): Promise { - const db = await this.getConnection(); - return await db.select( - 'SELECT * FROM categories WHERE playlist_id = ? AND type = ? ORDER BY name COLLATE NOCASE', - [playlistId, type] - ); - } - - async saveXtreamCategories( - playlistId: string, - categories: any[], - type: 'live' | 'movies' | 'series' - ): Promise { - const db = await this.getConnection(); - for (const category of categories) { - await db.execute( - 'INSERT INTO categories (playlist_id, name, type, xtream_id) VALUES (?, ?, ?, ?)', - [playlistId, category.category_name, type, category.category_id] - ); - } - } - - async hasXtreamContent( - playlistId: string, - type: 'live' | 'movie' | 'series' - ): Promise { - const db = await this.getConnection(); - const result = await db.select( - `SELECT c.* FROM content c - JOIN categories cat ON c.category_id = cat.id - WHERE cat.playlist_id = ? AND c.type = ? - ORDER BY c.added`, - [playlistId, type] - ); - return (result as any[]).length > 0; - } - - async getXtreamContent( - playlistId: string, - type: 'live' | 'movie' | 'series' - ): Promise { - const db = await this.getConnection(); - return await db.select( - `SELECT - c.id, c.category_id, c.title, c.rating, - c.added, c.poster_url, c.xtream_id, c.type - FROM content c - INNER JOIN categories cat ON c.category_id = cat.id - WHERE cat.playlist_id = ? AND c.type = ? - ORDER BY c.added DESC`, - [playlistId, type] - ); - } - - async saveXtreamContent( - playlistId: string, - streams: any[], - type: 'live' | 'movie' | 'series', - onProgress?: (count: number) => void - ): Promise { - const db = await this.getConnection(); - const dbType = - type === 'series' ? 'series' : type === 'movie' ? 'movies' : 'live'; - - const categories = await db.select<{ id: number; xtream_id: number }[]>( - 'SELECT id, xtream_id FROM categories WHERE playlist_id = ? AND type = ?', - [playlistId, dbType] - ); - - const categoryMap = new Map( - categories.map((c) => [parseInt(c.xtream_id.toString()), c.id]) - ); - - const bulkInsertData = this.prepareBulkInsertData( - streams, - type, - categoryMap - ); - return await this.executeBulkInsert(db, bulkInsertData, onProgress); - } - - async searchXtreamContent( - playlistId: string, - searchTerm: string, - types: string[] - ): Promise { - const db = await this.getConnection(); - const placeholders = types.map(() => '?').join(','); - return await db.select( - `SELECT c.* FROM content c - JOIN categories cat ON c.category_id = cat.id - WHERE (c.title LIKE ?) - AND cat.playlist_id = ? - AND c.type IN (${placeholders}) - LIMIT 50`, - [`%${searchTerm}%`, playlistId, ...types] - ); - } - - async globalSearchContent( - searchTerm: string, - types: string[] - ): Promise { - const db = await this.getConnection(); - const placeholders = types.map(() => '?').join(','); - - // Use a materialized subquery for better performance - return await db.select( - ` - WITH filtered_content AS ( - SELECT - c.id, - c.category_id, - c.title, - c.rating, - c.added, - c.poster_url, - c.xtream_id, - c.type, - cat.playlist_id, - p.name as playlist_name - FROM content c - INNER JOIN categories cat ON c.category_id = cat.id - INNER JOIN playlists p ON cat.playlist_id = p.id - WHERE c.type IN (${placeholders}) - ) - SELECT * FROM filtered_content - WHERE LOWER(title) LIKE LOWER(?) - ORDER BY title - LIMIT 50 - `, - [...types, `%${searchTerm}%`] - ); - } - - async getGlobalRecentlyViewed(): Promise { - try { - console.log('Starting getGlobalRecentlyViewed query...'); - const db = await this.getConnection(); - console.log('Got database connection'); - - // Check if table exists - const tableCheck = await db.select(` - SELECT name FROM sqlite_master - WHERE type='table' AND name='recently_viewed' - `); - console.log('Tables check:', tableCheck); - - const items = await db.select(` - SELECT - c.id, - c.category_id, - c.title, - c.rating, - c.added, - c.poster_url, - c.xtream_id, - c.type, - cat.playlist_id, - p.name as playlist_name, - rv.viewed_at - FROM recently_viewed rv - INNER JOIN content c ON rv.content_id = c.id - INNER JOIN categories cat ON c.category_id = cat.id - INNER JOIN playlists p ON cat.playlist_id = p.id - ORDER BY rv.viewed_at DESC - LIMIT 100 - `); - - console.log('Query executed, items found:', items?.length); - console.log('First few items:', items?.slice(0, 3)); - - return items || []; - } catch (error) { - console.error('Detailed error in getGlobalRecentlyViewed:', error); - throw error; // Let's throw the error to see it in the component - } - } - - async clearGlobalRecentlyViewed(): Promise { - try { - const db = await this.getConnection(); - await db.execute('DELETE FROM recently_viewed'); - } catch (error) { - console.error('Error clearing global recently viewed:', error); - throw error; - } - } - - async getPlaylistById(playlistId: string): Promise { - const db = await this.getConnection(); - const results = await db.select( - 'SELECT * FROM playlists WHERE id = ?', - [playlistId] - ); - return results[0] || null; - } - - async createPlaylist(playlist: PlaylistMeta): Promise { - const db = await this.getConnection(); - await db.execute( - 'INSERT INTO playlists (id, name, serverUrl, username, password, type) VALUES (?, ?, ?, ?, ?, ?)', - [ - playlist._id, - playlist.title, - playlist.serverUrl, - playlist.username, - playlist.password, - 'xtream', - ] - ); - } - - private prepareBulkInsertData( - streams: any[], - type: string, - categoryMap: Map - ): any[] { - return streams - .map((stream) => { - const streamCategoryId = - type === 'series' - ? parseInt(stream.category_id || '0') - : parseInt(stream.category_id); - - const categoryId = categoryMap.get(streamCategoryId); - if (!categoryId) return null; - - const title = - type === 'series' - ? stream.title || - stream.name || - `Unknown Series ${stream.series_id}` - : stream.name || - stream.title || - `Unknown Stream ${stream.stream_id}`; - - return [ - categoryId, - title, - stream.rating || stream.rating_imdb || '', - type === 'series' - ? stream.last_modified || '' - : stream.added || '', - stream.stream_icon || stream.poster || stream.cover || '', - type === 'series' - ? parseInt(stream.series_id || '0') - : parseInt(stream.stream_id || '0'), - type, - ]; - }) - .filter((data) => data !== null); - } - - private async executeBulkInsert( - db: Database, - data: any[], - onProgress?: (count: number) => void - ): Promise { - const CHUNK_SIZE = 100; - let totalInserted = 0; - - for (let i = 0; i < data.length; i += CHUNK_SIZE) { - const chunk = data.slice(i, i + CHUNK_SIZE); - const placeholders = chunk - .map(() => '(?, ?, ?, ?, ?, ?, ?)') - .join(', '); - const query = ` - INSERT INTO content ( - category_id, title, rating, added, - poster_url, xtream_id, type - ) VALUES ${placeholders} - `; - - try { - await db.execute(query, (chunk as any).flat()); - totalInserted += chunk.length; - onProgress?.(totalInserted); - } catch (err) { - console.error('Error in bulk insert chunk:', err); - } - } - - return totalInserted; - } -} diff --git a/libs/services/src/lib/playlists.service.ts b/libs/services/src/lib/playlists.service.ts index 15784339a..947e4c978 100644 --- a/libs/services/src/lib/playlists.service.ts +++ b/libs/services/src/lib/playlists.service.ts @@ -97,6 +97,9 @@ export class PlaylistsService { ...(updatedPlaylist.password !== null ? { password: updatedPlaylist.password } : {}), + ...(updatedPlaylist.updateDate !== undefined + ? { updateDate: updatedPlaylist.updateDate } + : {}), }) ) ); diff --git a/libs/ui/components/src/lib/recent-playlists/playlist-item/playlist-item.component.html b/libs/ui/components/src/lib/recent-playlists/playlist-item/playlist-item.component.html index fd5795d87..1a97d0de5 100644 --- a/libs/ui/components/src/lib/recent-playlists/playlist-item/playlist-item.component.html +++ b/libs/ui/components/src/lib/recent-playlists/playlist-item/playlist-item.component.html @@ -69,8 +69,12 @@ | {{ 'HOME.PLAYLISTS.ADDED' | translate }}: {{ item.importDate | date }} - + + + | {{ 'HOME.PLAYLISTS.UPDATED' | translate }}: + {{ item.updateDate | date: 'MMMM d, yyyy, HH:mm' }} + + @@ -84,6 +88,16 @@ > sync +