diff --git a/apps/electron-backend/src/app/events/database.events.ts b/apps/electron-backend/src/app/events/database.events.ts index 82fad2b88..fce5dadf2 100644 --- a/apps/electron-backend/src/app/events/database.events.ts +++ b/apps/electron-backend/src/app/events/database.events.ts @@ -3,1013 +3,17 @@ * 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'; + +import './database/category.events'; +import './database/content.events'; +import './database/favorites.events'; +import './database/playlist.events'; +import './database/recently-viewed.events'; +import './database/xtream.events'; 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); - - // Before deleting content, save xtreamIds of favorited and recently viewed items - // so they can be restored after refresh - let favoritedXtreamIds: number[] = []; - let recentlyViewedXtreamIds: { xtreamId: number; viewedAt: string }[] = []; - - if (categoryIds.length > 0) { - // Get favorites with their xtreamIds - const favorites = await db - .select({ - xtreamId: schema.content.xtreamId, - }) - .from(schema.favorites) - .innerJoin(schema.content, eq(schema.favorites.contentId, schema.content.id)) - .where( - and( - eq(schema.favorites.playlistId, playlistId), - inArray(schema.content.categoryId, categoryIds) - ) - ); - - favoritedXtreamIds = favorites.map((f) => f.xtreamId); - - // Get recently viewed with their xtreamIds and timestamps - const recentlyViewed = await db - .select({ - xtreamId: schema.content.xtreamId, - viewedAt: schema.recentlyViewed.viewedAt, - }) - .from(schema.recentlyViewed) - .innerJoin(schema.content, eq(schema.recentlyViewed.contentId, schema.content.id)) - .where( - and( - eq(schema.recentlyViewed.playlistId, playlistId), - inArray(schema.content.categoryId, categoryIds) - ) - ); - - recentlyViewedXtreamIds = recentlyViewed.map((rv) => ({ - xtreamId: rv.xtreamId, - viewedAt: rv.viewedAt || new Date().toISOString(), - })); - - // Delete all content for these categories - // (This will cascade delete favorites and recently viewed) - await db - .delete(schema.content) - .where(inArray(schema.content.categoryId, categoryIds)); - } - - // Delete all categories for this playlist - await db - .delete(schema.categories) - .where(eq(schema.categories.playlistId, playlistId)); - - // Return the saved xtreamIds so favorites and recently viewed can be restored - return { - success: true, - favoritedXtreamIds, - recentlyViewedXtreamIds, - }; - } catch (error) { - console.error('Error deleting Xtream content:', error); - throw error; - } - } -); - -/** - * Restore favorites and recently viewed items after Xtream refresh - * Matches content by xtreamId and re-creates favorites/recently viewed entries - */ -ipcMain.handle( - 'DB_RESTORE_XTREAM_USER_DATA', - async ( - event, - playlistId: string, - favoritedXtreamIds: number[], - recentlyViewedXtreamIds: { xtreamId: number; viewedAt: string }[] - ) => { - try { - const db = await getDatabase(); - - // Restore favorites - if (favoritedXtreamIds.length > 0) { - // Find content IDs for the xtreamIds - const content = await db - .select({ id: schema.content.id, xtreamId: schema.content.xtreamId }) - .from(schema.content) - .innerJoin(schema.categories, eq(schema.content.categoryId, schema.categories.id)) - .where( - and( - eq(schema.categories.playlistId, playlistId), - inArray(schema.content.xtreamId, favoritedXtreamIds) - ) - ); - - // Re-create favorite entries - for (const item of content) { - await db.insert(schema.favorites).values({ - contentId: item.id, - playlistId: playlistId, - addedAt: new Date().toISOString(), - }); - } - } - - // Restore recently viewed - if (recentlyViewedXtreamIds.length > 0) { - const xtreamIds = recentlyViewedXtreamIds.map((rv) => rv.xtreamId); - - // Find content IDs for the xtreamIds - const content = await db - .select({ id: schema.content.id, xtreamId: schema.content.xtreamId }) - .from(schema.content) - .innerJoin(schema.categories, eq(schema.content.categoryId, schema.categories.id)) - .where( - and( - eq(schema.categories.playlistId, playlistId), - inArray(schema.content.xtreamId, xtreamIds) - ) - ); - - // Create a map of xtreamId -> contentId - const xtreamIdToContentId = new Map( - content.map((item) => [item.xtreamId, item.id]) - ); - - // Re-create recently viewed entries with original timestamps - for (const item of recentlyViewedXtreamIds) { - const contentId = xtreamIdToContentId.get(item.xtreamId); - if (contentId) { - await db.insert(schema.recentlyViewed).values({ - contentId: contentId, - playlistId: playlistId, - viewedAt: item.viewedAt, - }); - } - } - } - - return { success: true }; - } catch (error) { - console.error('Error restoring Xtream user data:', 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; - } - } -); - -/** - * Delete all playlists and related data from SQLite - */ -ipcMain.handle('DB_DELETE_ALL_PLAYLISTS', async () => { - try { - const db = await getDatabase(); - - // Delete in order respecting foreign key constraints - // First delete favorites and recently_viewed (they reference content) - await db.delete(schema.favorites); - await db.delete(schema.recentlyViewed); - - // Then delete content (references categories) - await db.delete(schema.content); - - // Then delete categories (references playlists) - await db.delete(schema.categories); - - // Finally delete playlists - await db.delete(schema.playlists); - - return { success: true }; - } catch (error) { - console.error('Error deleting all playlists:', error); - throw error; - } -}); diff --git a/apps/electron-backend/src/app/events/database/category.events.ts b/apps/electron-backend/src/app/events/database/category.events.ts new file mode 100644 index 000000000..f5dad10a2 --- /dev/null +++ b/apps/electron-backend/src/app/events/database/category.events.ts @@ -0,0 +1,92 @@ +/** + * Category IPC event handlers + * Operations for managing categories within playlists + */ + +import { and, eq, sql } from 'drizzle-orm'; +import { ipcMain } from 'electron'; +import { getDatabase } from '../../database/connection'; +import * as schema from '../../database/schema'; + +/** + * 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; + } + } +); diff --git a/apps/electron-backend/src/app/events/database/content.events.ts b/apps/electron-backend/src/app/events/database/content.events.ts new file mode 100644 index 000000000..d4805bb81 --- /dev/null +++ b/apps/electron-backend/src/app/events/database/content.events.ts @@ -0,0 +1,328 @@ +/** + * Content IPC event handlers + * Operations for managing and searching content (streams, movies, series) + */ + +import { and, desc, eq, inArray, sql } from 'drizzle-orm'; +import { ipcMain } from 'electron'; +import { getDatabase } from '../../database/connection'; +import * as schema from '../../database/schema'; + +/** + * Escape special LIKE pattern characters to prevent unexpected matching behavior. + * This escapes %, _, and \ which have special meaning in SQL LIKE patterns. + */ +function escapeLikePattern(term: string): string { + return term.replace(/[%_\\]/g, '\\$&'); +} + +/** + * 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; + } + } +); + +/** + * 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; + } + } +); + +/** + * Search content within a specific playlist + */ +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), + sql`LOWER(${schema.content.title}) LIKE ${'%' + escapeLikePattern(searchTerm.toLowerCase()) + '%'} ESCAPE '\\'`, + 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( + sql`LOWER(${schema.content.title}) LIKE ${'%' + escapeLikePattern(searchTerm.toLowerCase()) + '%'} ESCAPE '\\'`, + 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; + } + } +); diff --git a/apps/electron-backend/src/app/events/database/favorites.events.ts b/apps/electron-backend/src/app/events/database/favorites.events.ts new file mode 100644 index 000000000..5f8613119 --- /dev/null +++ b/apps/electron-backend/src/app/events/database/favorites.events.ts @@ -0,0 +1,110 @@ +/** + * Favorites IPC event handlers + * Operations for managing user's favorite content + */ + +import { and, desc, eq, sql } from 'drizzle-orm'; +import { ipcMain } from 'electron'; +import { getDatabase } from '../../database/connection'; +import * as schema from '../../database/schema'; + +/** + * 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; + } +}); diff --git a/apps/electron-backend/src/app/events/database/playlist.events.ts b/apps/electron-backend/src/app/events/database/playlist.events.ts new file mode 100644 index 000000000..bb5f54d71 --- /dev/null +++ b/apps/electron-backend/src/app/events/database/playlist.events.ts @@ -0,0 +1,144 @@ +/** + * Playlist IPC event handlers + * CRUD operations for playlists + */ + +import { eq } from 'drizzle-orm'; +import { ipcMain } from 'electron'; +import { getDatabase } from '../../database/connection'; +import * as schema from '../../database/schema'; + +/** + * 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 playlists and related data from SQLite + */ +ipcMain.handle('DB_DELETE_ALL_PLAYLISTS', async () => { + try { + const db = await getDatabase(); + + // Delete in order respecting foreign key constraints + // First delete favorites and recently_viewed (they reference content) + await db.delete(schema.favorites); + await db.delete(schema.recentlyViewed); + + // Then delete content (references categories) + await db.delete(schema.content); + + // Then delete categories (references playlists) + await db.delete(schema.categories); + + // Finally delete playlists + await db.delete(schema.playlists); + + return { success: true }; + } catch (error) { + console.error('Error deleting all playlists:', error); + throw error; + } +}); diff --git a/apps/electron-backend/src/app/events/database/recently-viewed.events.ts b/apps/electron-backend/src/app/events/database/recently-viewed.events.ts new file mode 100644 index 000000000..9fdf2232e --- /dev/null +++ b/apps/electron-backend/src/app/events/database/recently-viewed.events.ts @@ -0,0 +1,210 @@ +/** + * Recently Viewed IPC event handlers + * Operations for managing user's recently viewed content + */ + +import { and, desc, eq, inArray, sql } from 'drizzle-orm'; +import { ipcMain } from 'electron'; +import { getDatabase } from '../../database/connection'; +import * as schema from '../../database/schema'; + +/** + * Get recently viewed items across all playlists + */ +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 all 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; + } +}); + +/** + * 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; + } + } +); diff --git a/apps/electron-backend/src/app/events/database/xtream.events.ts b/apps/electron-backend/src/app/events/database/xtream.events.ts new file mode 100644 index 000000000..0a04c68d4 --- /dev/null +++ b/apps/electron-backend/src/app/events/database/xtream.events.ts @@ -0,0 +1,198 @@ +/** + * Xtream-specific IPC event handlers + * Operations for refreshing and managing Xtream playlist data + */ + +import { and, eq, inArray } from 'drizzle-orm'; +import { ipcMain } from 'electron'; +import { getDatabase } from '../../database/connection'; +import * as schema from '../../database/schema'; + +/** + * 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); + + // Before deleting content, save xtreamIds of favorited and recently viewed items + // so they can be restored after refresh + let favoritedXtreamIds: number[] = []; + let recentlyViewedXtreamIds: { + xtreamId: number; + viewedAt: string; + }[] = []; + + if (categoryIds.length > 0) { + // Get favorites with their xtreamIds + const favorites = await db + .select({ + xtreamId: schema.content.xtreamId, + }) + .from(schema.favorites) + .innerJoin( + schema.content, + eq(schema.favorites.contentId, schema.content.id) + ) + .where( + and( + eq(schema.favorites.playlistId, playlistId), + inArray(schema.content.categoryId, categoryIds) + ) + ); + + favoritedXtreamIds = favorites.map((f) => f.xtreamId); + + // Get recently viewed with their xtreamIds and timestamps + const recentlyViewed = await db + .select({ + xtreamId: schema.content.xtreamId, + viewedAt: schema.recentlyViewed.viewedAt, + }) + .from(schema.recentlyViewed) + .innerJoin( + schema.content, + eq(schema.recentlyViewed.contentId, schema.content.id) + ) + .where( + and( + eq(schema.recentlyViewed.playlistId, playlistId), + inArray(schema.content.categoryId, categoryIds) + ) + ); + + recentlyViewedXtreamIds = recentlyViewed.map((rv) => ({ + xtreamId: rv.xtreamId, + viewedAt: rv.viewedAt || new Date().toISOString(), + })); + + // Delete all content for these categories + // (This will cascade delete favorites and recently viewed) + await db + .delete(schema.content) + .where(inArray(schema.content.categoryId, categoryIds)); + } + + // Delete all categories for this playlist + await db + .delete(schema.categories) + .where(eq(schema.categories.playlistId, playlistId)); + + // Return the saved xtreamIds so favorites and recently viewed can be restored + return { + success: true, + favoritedXtreamIds, + recentlyViewedXtreamIds, + }; + } catch (error) { + console.error('Error deleting Xtream content:', error); + throw error; + } + } +); + +/** + * Restore favorites and recently viewed items after Xtream refresh + * Matches content by xtreamId and re-creates favorites/recently viewed entries + */ +ipcMain.handle( + 'DB_RESTORE_XTREAM_USER_DATA', + async ( + event, + playlistId: string, + favoritedXtreamIds: number[], + recentlyViewedXtreamIds: { xtreamId: number; viewedAt: string }[] + ) => { + try { + const db = await getDatabase(); + + // Restore favorites + if (favoritedXtreamIds.length > 0) { + // Find content IDs for the xtreamIds + const content = await db + .select({ + id: schema.content.id, + xtreamId: schema.content.xtreamId, + }) + .from(schema.content) + .innerJoin( + schema.categories, + eq(schema.content.categoryId, schema.categories.id) + ) + .where( + and( + eq(schema.categories.playlistId, playlistId), + inArray(schema.content.xtreamId, favoritedXtreamIds) + ) + ); + + // Re-create favorite entries + for (const item of content) { + await db.insert(schema.favorites).values({ + contentId: item.id, + playlistId: playlistId, + addedAt: new Date().toISOString(), + }); + } + } + + // Restore recently viewed + if (recentlyViewedXtreamIds.length > 0) { + const xtreamIds = recentlyViewedXtreamIds.map( + (rv) => rv.xtreamId + ); + + // Find content IDs for the xtreamIds + const content = await db + .select({ + id: schema.content.id, + xtreamId: schema.content.xtreamId, + }) + .from(schema.content) + .innerJoin( + schema.categories, + eq(schema.content.categoryId, schema.categories.id) + ) + .where( + and( + eq(schema.categories.playlistId, playlistId), + inArray(schema.content.xtreamId, xtreamIds) + ) + ); + + // Create a map of xtreamId -> contentId + const xtreamIdToContentId = new Map( + content.map((item) => [item.xtreamId, item.id]) + ); + + // Re-create recently viewed entries with original timestamps + for (const item of recentlyViewedXtreamIds) { + const contentId = xtreamIdToContentId.get(item.xtreamId); + if (contentId) { + await db.insert(schema.recentlyViewed).values({ + contentId: contentId, + playlistId: playlistId, + viewedAt: item.viewedAt, + }); + } + } + } + + return { success: true }; + } catch (error) { + console.error('Error restoring Xtream user data:', error); + throw error; + } + } +);