From 6dcbbadda8b2df43b7fe628bbf0016fb8bdf6c13 Mon Sep 17 00:00:00 2001 From: 4gray Date: Sat, 18 Oct 2025 13:05:26 +0200 Subject: [PATCH 1/3] refactor(app): replace TauriService with ElectronService usage --- apps/web/src/app/app.config.ts | 4 ++-- .../app/services/{tauri.service.ts => electron.service.ts} | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) rename apps/web/src/app/services/{tauri.service.ts => electron.service.ts} (99%) 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 99% rename from apps/web/src/app/services/tauri.service.ts rename to apps/web/src/app/services/electron.service.ts index aa68375c3..a284565fe 100644 --- a/apps/web/src/app/services/tauri.service.ts +++ b/apps/web/src/app/services/electron.service.ts @@ -21,7 +21,7 @@ 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); From 0a934d2ac6bbad084241b6b22ca6ec8cfabaecc3 Mon Sep 17 00:00:00 2001 From: 4gray Date: Sat, 18 Oct 2025 21:31:29 +0200 Subject: [PATCH 2/3] refactor(electron): migrate from tauri/plugin-sql to libsql --- .../src/app/api/main.preload.ts | 59 + apps/electron-backend/src/app/app.ts | 1 + .../src/app/database/connection.ts | 176 +++ .../src/app/database/schema.ts | 110 ++ .../src/app/events/database.events.ts | 876 +++++++++++ .../src/app/events/stalker.events.ts | 93 ++ .../src/app/events/xtream.events.ts | 91 ++ apps/electron-backend/src/main.ts | 20 +- apps/web/src/app/services/electron.service.ts | 107 +- .../favorites-layout.component.html | 5 +- .../favorites-layout.component.ts | 9 +- .../stalker-main-container.component.html | 2 +- .../stalker-main-container.component.ts | 3 +- .../favorites/favorites.component.ts | 2 +- .../live-stream-layout.component.html | 5 +- .../live-stream-layout.component.scss | 10 + .../live-stream-layout.component.ts | 3 +- .../services/favorite-item.interface.ts | 2 + .../services/favorites.service.ts | 54 +- .../xtream-tauri/with-favorites.feature.ts | 68 +- .../src/app/xtream-tauri/with-recent-items.ts | 118 +- .../xtream-main-container.component.html | 2 +- .../xtream-main-container.component.ts | 3 +- drizzle.config.ts | 22 + global.d.ts | 87 ++ libs/m3u-state/src/lib/effects.ts | 25 +- libs/services/src/index.ts | 2 +- .../src/lib/database-electron.service.ts | 392 +++++ libs/services/src/lib/database.service.ts | 484 ------ package-lock.json | 1376 ++++++++++++++++- package.json | 3 + 31 files changed, 3458 insertions(+), 752 deletions(-) create mode 100644 apps/electron-backend/src/app/database/connection.ts create mode 100644 apps/electron-backend/src/app/database/schema.ts create mode 100644 apps/electron-backend/src/app/events/database.events.ts create mode 100644 apps/electron-backend/src/app/events/stalker.events.ts create mode 100644 apps/electron-backend/src/app/events/xtream.events.ts create mode 100644 drizzle.config.ts create mode 100644 libs/services/src/lib/database-electron.service.ts delete mode 100644 libs/services/src/lib/database.service.ts 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..7492f6cc0 --- /dev/null +++ b/apps/electron-backend/src/app/events/database.events.ts @@ -0,0 +1,876 @@ +/** + * 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; + } + ) => { + 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/services/electron.service.ts b/apps/web/src/app/services/electron.service.ts index a284565fe..53a567e08 100644 --- a/apps/web/src/app/services/electron.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, @@ -27,23 +25,7 @@ export class ElectronService extends DataService { 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 ElectronService 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 ElectronService 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 ElectronService 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 ElectronService 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/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/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..29f66709b --- /dev/null +++ b/libs/services/src/lib/database-electron.service.ts @@ -0,0 +1,392 @@ +/** + * 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; + }): Promise { + try { + await window.electron.dbUpdatePlaylist(playlist.id, { + name: playlist.title, + username: playlist.username, + password: playlist.password, + serverUrl: playlist.serverUrl, + }); + 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/package-lock.json b/package-lock.json index 4440f7ef6..42cc2b884 100644 --- a/package-lock.json +++ b/package-lock.json @@ -20,6 +20,7 @@ "@angular/platform-browser": "~20.1.0", "@angular/platform-browser-dynamic": "~20.1.0", "@angular/router": "~20.1.0", + "@libsql/client": "0.15.15", "@ngrx/component-store": "20.0.0", "@ngrx/effects": "20.0.0", "@ngrx/entity": "20.0.0", @@ -40,6 +41,7 @@ "artplayer": "5.3.0", "axios": "1.12.2", "date-fns": "2.29.3", + "drizzle-orm": "0.44.6", "electron-store": "11.0.2", "epg-parser": "0.1.6", "fix-path": "5.0.0", @@ -115,6 +117,7 @@ "@typescript-eslint/utils": "^8.29.0", "angular-eslint": "^20.0.0", "conventional-changelog-cli": "5.0.0", + "drizzle-kit": "0.31.5", "electron": "^34.5.6", "electron-builder": "26.0.12", "eslint": "^9.8.0", @@ -4380,6 +4383,13 @@ "dev": true, "license": "MIT" }, + "node_modules/@drizzle-team/brocli": { + "version": "0.10.2", + "resolved": "https://registry.npmjs.org/@drizzle-team/brocli/-/brocli-0.10.2.tgz", + "integrity": "sha512-z33Il7l5dKjUgGULTqBsQBQwckHh5AbIuxhdsIxDDiZAzBOrZO6q9ogcWC65kU382AfynTfgNumVcNIjuIua6w==", + "dev": true, + "license": "Apache-2.0" + }, "node_modules/@electron/asar": { "version": "3.2.18", "resolved": "https://registry.npmjs.org/@electron/asar/-/asar-3.2.18.tgz", @@ -5100,6 +5110,174 @@ "tslib": "^2.4.0" } }, + "node_modules/@esbuild-kit/core-utils": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/@esbuild-kit/core-utils/-/core-utils-3.3.2.tgz", + "integrity": "sha512-sPRAnw9CdSsRmEtnsl2WXWdyquogVpB3yZ3dgwJfe8zrOzTsV7cJvmwrKVa+0ma5BoiGJ+BoqkMvawbayKUsqQ==", + "deprecated": "Merged into tsx: https://tsx.is", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "~0.18.20", + "source-map-support": "^0.5.21" + } + }, + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/darwin-arm64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.18.20.tgz", + "integrity": "sha512-bxRHW5kHU38zS2lPTPOyuyTm+S+eobPUnTNkdJEfAddYgEcll4xkT8DB9d2008DtTbl7uJag2HuE5NZAZgnNEA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild-kit/core-utils/node_modules/esbuild": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.18.20.tgz", + "integrity": "sha512-ceqxoedUrcayh7Y7ZX6NdbbDzGROiyVBgC4PriJThBKSVPWnnFHZAkfI1lJT8QFkOwH4qOS2SJkS4wvpGl8BpA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/android-arm": "0.18.20", + "@esbuild/android-arm64": "0.18.20", + "@esbuild/android-x64": "0.18.20", + "@esbuild/darwin-arm64": "0.18.20", + "@esbuild/darwin-x64": "0.18.20", + "@esbuild/freebsd-arm64": "0.18.20", + "@esbuild/freebsd-x64": "0.18.20", + "@esbuild/linux-arm": "0.18.20", + "@esbuild/linux-arm64": "0.18.20", + "@esbuild/linux-ia32": "0.18.20", + "@esbuild/linux-loong64": "0.18.20", + "@esbuild/linux-mips64el": "0.18.20", + "@esbuild/linux-ppc64": "0.18.20", + "@esbuild/linux-riscv64": "0.18.20", + "@esbuild/linux-s390x": "0.18.20", + "@esbuild/linux-x64": "0.18.20", + "@esbuild/netbsd-x64": "0.18.20", + "@esbuild/openbsd-x64": "0.18.20", + "@esbuild/sunos-x64": "0.18.20", + "@esbuild/win32-arm64": "0.18.20", + "@esbuild/win32-ia32": "0.18.20", + "@esbuild/win32-x64": "0.18.20" + } + }, + "node_modules/@esbuild-kit/core-utils/node_modules/source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/@esbuild-kit/core-utils/node_modules/source-map-support": { + "version": "0.5.21", + "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.21.tgz", + "integrity": "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==", + "dev": true, + "license": "MIT", + "dependencies": { + "buffer-from": "^1.0.0", + "source-map": "^0.6.0" + } + }, + "node_modules/@esbuild-kit/esm-loader": { + "version": "2.6.5", + "resolved": "https://registry.npmjs.org/@esbuild-kit/esm-loader/-/esm-loader-2.6.5.tgz", + "integrity": "sha512-FxEMIkJKnodyA1OaCUoEvbYRkoZlLZ4d/eXFu9Fh8CbBBgP5EmZxrfTRyN0qpXZ4vOvqnE5YdRdcrmUUXuU+dA==", + "deprecated": "Merged into tsx: https://tsx.is", + "dev": true, + "license": "MIT", + "dependencies": { + "@esbuild-kit/core-utils": "^3.3.2", + "get-tsconfig": "^4.7.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.5.tgz", + "integrity": "sha512-9o3TMmpmftaCMepOdA5k/yDw8SfInyzWWTjYTFCX3kPSDJMROQTb8jg+h9Cnwnmm1vOzvxN7gIfB5V2ewpjtGA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.18.20.tgz", + "integrity": "sha512-fyi7TDI/ijKKNZTUJAQqiG5T7YjJXgnzkURqmGj13C6dCqckZBLdl4h7bkhHt/t0WP+zO9/zwroDvANaOqO5Sw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.18.20.tgz", + "integrity": "sha512-Nz4rJcchGDtENV0eMKUNa6L12zz2zBDXuhj/Vjh18zGqB44Bi7MBMSXjgunJgjRhCmKOjnPuZp4Mb6OKqtMHLQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.18.20.tgz", + "integrity": "sha512-8GDdlePJA8D6zlZYJV/jnrRAi6rOiNaCC/JclcXpB+KIuvfBN4owLtgzY2bsxnx666XjJx2kDPUmnTtR8qKQUg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, "node_modules/@esbuild/darwin-arm64": { "version": "0.25.5", "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.5.tgz", @@ -5117,6 +5295,346 @@ "node": ">=18" } }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.18.20.tgz", + "integrity": "sha512-pc5gxlMDxzm513qPGbCbDukOdsGtKhfxD1zJKXjCCcU7ju50O7MeAZ8c4krSJcOIJGFR+qx21yMMVYwiQvyTyQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.18.20.tgz", + "integrity": "sha512-yqDQHy4QHevpMAaxhhIwYPMv1NECwOvIpGCZkECn8w2WFHXjEwrBn3CeNIYsibZ/iZEUemj++M26W3cNR5h+Tw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.18.20.tgz", + "integrity": "sha512-tgWRPPuQsd3RmBZwarGVHZQvtzfEBOreNuxEMKFcd5DaDn2PbBxfwLcj4+aenoh7ctXcbXmOQIn8HI6mCSw5MQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.18.20.tgz", + "integrity": "sha512-/5bHkMWnq1EgKr1V+Ybz3s1hWXok7mDFUMQ4cG10AfW3wL02PSZi5kFpYKrptDsgb2WAJIvRcDm+qIvXf/apvg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.18.20.tgz", + "integrity": "sha512-2YbscF+UL7SQAVIpnWvYwM+3LskyDmPhe31pE7/aoTMFKKzIc9lLbyGUpmmb8a8AixOL61sQ/mFh3jEjHYFvdA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.18.20.tgz", + "integrity": "sha512-P4etWwq6IsReT0E1KHU40bOnzMHoH73aXp96Fs8TIT6z9Hu8G6+0SHSw9i2isWrD2nbx2qo5yUqACgdfVGx7TA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.18.20.tgz", + "integrity": "sha512-nXW8nqBTrOpDLPgPY9uV+/1DjxoQ7DoB2N8eocyq8I9XuqJ7BiAMDMf9n1xZM9TgW0J8zrquIb/A7s3BJv7rjg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.18.20.tgz", + "integrity": "sha512-d5NeaXZcHp8PzYy5VnXV3VSd2D328Zb+9dEq5HE6bw6+N86JVPExrA6O68OPwobntbNJ0pzCpUFZTo3w0GyetQ==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.18.20.tgz", + "integrity": "sha512-WHPyeScRNcmANnLQkq6AfyXRFr5D6N2sKgkFo2FqguP44Nw2eyDlbTdZwd9GYk98DZG9QItIiTlFLHJHjxP3FA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.18.20.tgz", + "integrity": "sha512-WSxo6h5ecI5XH34KC7w5veNnKkju3zBRLEQNY7mv5mtBmrP/MjNBCAlsM2u5hDBlS3NGcTQpoBvRzqBcRtpq1A==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.18.20.tgz", + "integrity": "sha512-+8231GMs3mAEth6Ja1iK0a1sQ3ohfcpzpRLH8uuc5/KVDFneH6jtAJLFGafpzpMRO6DzJ6AvXKze9LfFMrIHVQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.18.20.tgz", + "integrity": "sha512-UYqiqemphJcNsFEskc73jQ7B9jgwjWrSayxawS6UVFZGWrAAtkzjxSqnoclCXxWtfwLdzU+vTpcNYhpn43uP1w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.5.tgz", + "integrity": "sha512-pwHtMP9viAy1oHPvgxtOv+OkduK5ugofNTVDilIzBLpoWAM16r7b/mxBvfpuQDpRQFMfuVr5aLcn4yveGvBZvw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.18.20.tgz", + "integrity": "sha512-iO1c++VP6xUBUmltHZoMtCUdPlnPGdBom6IrO4gyKPFFVBKioIImVooR5I83nTew5UOYrk3gIJhbZh8X44y06A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.5.tgz", + "integrity": "sha512-7A208+uQKgTxHd0G0uqZO8UjK2R0DDb4fDmERtARjSHWxqMTye4Erz4zZafx7Di9Cv+lNHYuncAkiGFySoD+Mw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.18.20.tgz", + "integrity": "sha512-e5e4YSsuQfX4cxcygw/UCPIEP6wbIL+se3sxPdCiMbFLBWu0eiZOJ7WoD+ptCLrmjZBK1Wk7I6D/I3NglUGOxg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.18.20.tgz", + "integrity": "sha512-kDbFRFp0YpTQVVrqUd5FTYmWo45zGaXe0X8E1G/LKFC0v8x0vWrhOWSLITcCn63lmZIxfOMXtCfti/RxN/0wnQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.18.20.tgz", + "integrity": "sha512-ddYFR6ItYgoaq4v4JmQQaAI5s7npztfV4Ag6NrhiaW0RrnOXqBkgwZLofVTlq1daVTQNhtI5oieTvkRPfZrePg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.18.20.tgz", + "integrity": "sha512-Wv7QBi3ID/rROT08SABTS7eV4hX26sVduqDOTe1MvGMjNd3EjOz4b7zeexIR62GTIEKrfJXKL9LFxTYgkyeu7g==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.18.20.tgz", + "integrity": "sha512-kTdfRcSiDfQca/y9QIkng02avJ+NCaQvrMejlsB3RRv5sE9rRoeBPISaZpKxHELzRxZyLvNts1P27W3wV+8geQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, "node_modules/@eslint-community/eslint-utils": { "version": "4.9.0", "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.0.tgz", @@ -6662,6 +7180,194 @@ "dev": true, "license": "MIT" }, + "node_modules/@libsql/client": { + "version": "0.15.15", + "resolved": "https://registry.npmjs.org/@libsql/client/-/client-0.15.15.tgz", + "integrity": "sha512-twC0hQxPNHPKfeOv3sNT6u2pturQjLcI+CnpTM0SjRpocEGgfiZ7DWKXLNnsothjyJmDqEsBQJ5ztq9Wlu470w==", + "license": "MIT", + "dependencies": { + "@libsql/core": "^0.15.14", + "@libsql/hrana-client": "^0.7.0", + "js-base64": "^3.7.5", + "libsql": "^0.5.22", + "promise-limit": "^2.7.0" + } + }, + "node_modules/@libsql/core": { + "version": "0.15.15", + "resolved": "https://registry.npmjs.org/@libsql/core/-/core-0.15.15.tgz", + "integrity": "sha512-C88Z6UKl+OyuKKPwz224riz02ih/zHYI3Ho/LAcVOgjsunIRZoBw7fjRfaH9oPMmSNeQfhGklSG2il1URoOIsA==", + "license": "MIT", + "dependencies": { + "js-base64": "^3.7.5" + } + }, + "node_modules/@libsql/darwin-arm64": { + "version": "0.5.22", + "resolved": "https://registry.npmjs.org/@libsql/darwin-arm64/-/darwin-arm64-0.5.22.tgz", + "integrity": "sha512-4B8ZlX3nIDPndfct7GNe0nI3Yw6ibocEicWdC4fvQbSs/jdq/RC2oCsoJxJ4NzXkvktX70C1J4FcmmoBy069UA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@libsql/darwin-x64": { + "version": "0.5.22", + "resolved": "https://registry.npmjs.org/@libsql/darwin-x64/-/darwin-x64-0.5.22.tgz", + "integrity": "sha512-ny2HYWt6lFSIdNFzUFIJ04uiW6finXfMNJ7wypkAD8Pqdm6nAByO+Fdqu8t7sD0sqJGeUCiOg480icjyQ2/8VA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@libsql/hrana-client": { + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/@libsql/hrana-client/-/hrana-client-0.7.0.tgz", + "integrity": "sha512-OF8fFQSkbL7vJY9rfuegK1R7sPgQ6kFMkDamiEccNUvieQ+3urzfDFI616oPl8V7T9zRmnTkSjMOImYCAVRVuw==", + "license": "MIT", + "dependencies": { + "@libsql/isomorphic-fetch": "^0.3.1", + "@libsql/isomorphic-ws": "^0.1.5", + "js-base64": "^3.7.5", + "node-fetch": "^3.3.2" + } + }, + "node_modules/@libsql/hrana-client/node_modules/node-fetch": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-3.3.2.tgz", + "integrity": "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==", + "license": "MIT", + "dependencies": { + "data-uri-to-buffer": "^4.0.0", + "fetch-blob": "^3.1.4", + "formdata-polyfill": "^4.0.10" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/node-fetch" + } + }, + "node_modules/@libsql/isomorphic-fetch": { + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/@libsql/isomorphic-fetch/-/isomorphic-fetch-0.3.1.tgz", + "integrity": "sha512-6kK3SUK5Uu56zPq/Las620n5aS9xJq+jMBcNSOmjhNf/MUvdyji4vrMTqD7ptY7/4/CAVEAYDeotUz60LNQHtw==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@libsql/isomorphic-ws": { + "version": "0.1.5", + "resolved": "https://registry.npmjs.org/@libsql/isomorphic-ws/-/isomorphic-ws-0.1.5.tgz", + "integrity": "sha512-DtLWIH29onUYR00i0GlQ3UdcTRC6EP4u9w/h9LxpUZJWRMARk6dQwZ6Jkd+QdwVpuAOrdxt18v0K2uIYR3fwFg==", + "license": "MIT", + "dependencies": { + "@types/ws": "^8.5.4", + "ws": "^8.13.0" + } + }, + "node_modules/@libsql/linux-arm-gnueabihf": { + "version": "0.5.22", + "resolved": "https://registry.npmjs.org/@libsql/linux-arm-gnueabihf/-/linux-arm-gnueabihf-0.5.22.tgz", + "integrity": "sha512-3Uo3SoDPJe/zBnyZKosziRGtszXaEtv57raWrZIahtQDsjxBVjuzYQinCm9LRCJCUT5t2r5Z5nLDPJi2CwZVoA==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@libsql/linux-arm-musleabihf": { + "version": "0.5.22", + "resolved": "https://registry.npmjs.org/@libsql/linux-arm-musleabihf/-/linux-arm-musleabihf-0.5.22.tgz", + "integrity": "sha512-LCsXh07jvSojTNJptT9CowOzwITznD+YFGGW+1XxUr7fS+7/ydUrpDfsMX7UqTqjm7xG17eq86VkWJgHJfvpNg==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@libsql/linux-arm64-gnu": { + "version": "0.5.22", + "resolved": "https://registry.npmjs.org/@libsql/linux-arm64-gnu/-/linux-arm64-gnu-0.5.22.tgz", + "integrity": "sha512-KSdnOMy88c9mpOFKUEzPskSaF3VLflfSUCBwas/pn1/sV3pEhtMF6H8VUCd2rsedwoukeeCSEONqX7LLnQwRMA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@libsql/linux-arm64-musl": { + "version": "0.5.22", + "resolved": "https://registry.npmjs.org/@libsql/linux-arm64-musl/-/linux-arm64-musl-0.5.22.tgz", + "integrity": "sha512-mCHSMAsDTLK5YH//lcV3eFEgiR23Ym0U9oEvgZA0667gqRZg/2px+7LshDvErEKv2XZ8ixzw3p1IrBzLQHGSsw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@libsql/linux-x64-gnu": { + "version": "0.5.22", + "resolved": "https://registry.npmjs.org/@libsql/linux-x64-gnu/-/linux-x64-gnu-0.5.22.tgz", + "integrity": "sha512-kNBHaIkSg78Y4BqAdgjcR2mBilZXs4HYkAmi58J+4GRwDQZh5fIUWbnQvB9f95DkWUIGVeenqLRFY2pcTmlsew==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@libsql/linux-x64-musl": { + "version": "0.5.22", + "resolved": "https://registry.npmjs.org/@libsql/linux-x64-musl/-/linux-x64-musl-0.5.22.tgz", + "integrity": "sha512-UZ4Xdxm4pu3pQXjvfJiyCzZop/9j/eA2JjmhMaAhe3EVLH2g11Fy4fwyUp9sT1QJYR1kpc2JLuybPM0kuXv/Tg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@libsql/win32-x64-msvc": { + "version": "0.5.22", + "resolved": "https://registry.npmjs.org/@libsql/win32-x64-msvc/-/win32-x64-msvc-0.5.22.tgz", + "integrity": "sha512-Fj0j8RnBpo43tVZUVoNK6BV/9AtDUM5S7DF3LB4qTYg1LMSZqi3yeCneUTLJD6XomQJlZzbI4mst89yspVSAnA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, "node_modules/@listr2/prompt-adapter-inquirer": { "version": "2.0.22", "resolved": "https://registry.npmjs.org/@listr2/prompt-adapter-inquirer/-/prompt-adapter-inquirer-2.0.22.tgz", @@ -7527,6 +8233,12 @@ "node": ">= 10" } }, + "node_modules/@neon-rs/load": { + "version": "0.0.4", + "resolved": "https://registry.npmjs.org/@neon-rs/load/-/load-0.0.4.tgz", + "integrity": "sha512-kTPhdZyTQxB+2wpiRcFWrDcejc4JI6tkPuS7UZCG4l6Zvc5kU/gGQ/ozvHTh1XR5tS+UlfAfGuPajjzQjCiHCw==", + "license": "MIT" + }, "node_modules/@ngrx/component-store": { "version": "20.0.0", "resolved": "https://registry.npmjs.org/@ngrx/component-store/-/component-store-20.0.0.tgz", @@ -10561,7 +11273,6 @@ "version": "20.19.9", "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.9.tgz", "integrity": "sha512-cuVNgarYWZqxRJDQHEB58GEONhOK79QVR/qYx4S7kcUObQvUwvFnYxJuuHUKm2aieN9X3yZB4LZsuYNU1Qphsw==", - "dev": true, "license": "MIT", "dependencies": { "undici-types": "~6.21.0" @@ -10734,7 +11445,6 @@ "version": "8.18.1", "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz", "integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==", - "dev": true, "license": "MIT", "dependencies": { "@types/node": "*" @@ -15049,6 +15759,15 @@ "node": ">=8" } }, + "node_modules/data-uri-to-buffer": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-4.0.1.tgz", + "integrity": "sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A==", + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, "node_modules/data-urls": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-5.0.0.tgz", @@ -15847,6 +16566,147 @@ "url": "https://dotenvx.com" } }, + "node_modules/drizzle-kit": { + "version": "0.31.5", + "resolved": "https://registry.npmjs.org/drizzle-kit/-/drizzle-kit-0.31.5.tgz", + "integrity": "sha512-+CHgPFzuoTQTt7cOYCV6MOw2w8vqEn/ap1yv4bpZOWL03u7rlVRQhUY0WYT3rHsgVTXwYQDZaSUJSQrMBUKuWg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@drizzle-team/brocli": "^0.10.2", + "@esbuild-kit/esm-loader": "^2.5.5", + "esbuild": "^0.25.4", + "esbuild-register": "^3.5.0" + }, + "bin": { + "drizzle-kit": "bin.cjs" + } + }, + "node_modules/drizzle-orm": { + "version": "0.44.6", + "resolved": "https://registry.npmjs.org/drizzle-orm/-/drizzle-orm-0.44.6.tgz", + "integrity": "sha512-uy6uarrrEOc9K1u5/uhBFJbdF5VJ5xQ/Yzbecw3eAYOunv5FDeYkR2m8iitocdHBOHbvorviKOW5GVw0U1j4LQ==", + "license": "Apache-2.0", + "peerDependencies": { + "@aws-sdk/client-rds-data": ">=3", + "@cloudflare/workers-types": ">=4", + "@electric-sql/pglite": ">=0.2.0", + "@libsql/client": ">=0.10.0", + "@libsql/client-wasm": ">=0.10.0", + "@neondatabase/serverless": ">=0.10.0", + "@op-engineering/op-sqlite": ">=2", + "@opentelemetry/api": "^1.4.1", + "@planetscale/database": ">=1.13", + "@prisma/client": "*", + "@tidbcloud/serverless": "*", + "@types/better-sqlite3": "*", + "@types/pg": "*", + "@types/sql.js": "*", + "@upstash/redis": ">=1.34.7", + "@vercel/postgres": ">=0.8.0", + "@xata.io/client": "*", + "better-sqlite3": ">=7", + "bun-types": "*", + "expo-sqlite": ">=14.0.0", + "gel": ">=2", + "knex": "*", + "kysely": "*", + "mysql2": ">=2", + "pg": ">=8", + "postgres": ">=3", + "sql.js": ">=1", + "sqlite3": ">=5" + }, + "peerDependenciesMeta": { + "@aws-sdk/client-rds-data": { + "optional": true + }, + "@cloudflare/workers-types": { + "optional": true + }, + "@electric-sql/pglite": { + "optional": true + }, + "@libsql/client": { + "optional": true + }, + "@libsql/client-wasm": { + "optional": true + }, + "@neondatabase/serverless": { + "optional": true + }, + "@op-engineering/op-sqlite": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@planetscale/database": { + "optional": true + }, + "@prisma/client": { + "optional": true + }, + "@tidbcloud/serverless": { + "optional": true + }, + "@types/better-sqlite3": { + "optional": true + }, + "@types/pg": { + "optional": true + }, + "@types/sql.js": { + "optional": true + }, + "@upstash/redis": { + "optional": true + }, + "@vercel/postgres": { + "optional": true + }, + "@xata.io/client": { + "optional": true + }, + "better-sqlite3": { + "optional": true + }, + "bun-types": { + "optional": true + }, + "expo-sqlite": { + "optional": true + }, + "gel": { + "optional": true + }, + "knex": { + "optional": true + }, + "kysely": { + "optional": true + }, + "mysql2": { + "optional": true + }, + "pg": { + "optional": true + }, + "postgres": { + "optional": true + }, + "prisma": { + "optional": true + }, + "sql.js": { + "optional": true + }, + "sqlite3": { + "optional": true + } + } + }, "node_modules/dunder-proto": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", @@ -16616,6 +17476,19 @@ "@esbuild/win32-x64": "0.25.5" } }, + "node_modules/esbuild-register": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/esbuild-register/-/esbuild-register-3.6.0.tgz", + "integrity": "sha512-H2/S7Pm8a9CL1uhp9OvjwrBh5Pvx0H8qVOxNu8Wed9Y7qv56MPtq+GGM8RJpq6glYJn9Wspr8uw7l55uyinNeg==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^4.3.4" + }, + "peerDependencies": { + "esbuild": ">=0.12 <1" + } + }, "node_modules/esbuild-wasm": { "version": "0.25.9", "resolved": "https://registry.npmjs.org/esbuild-wasm/-/esbuild-wasm-0.25.9.tgz", @@ -16629,6 +17502,363 @@ "node": ">=18" } }, + "node_modules/esbuild/node_modules/@esbuild/android-arm": { + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.5.tgz", + "integrity": "sha512-AdJKSPeEHgi7/ZhuIPtcQKr5RQdo6OO2IL87JkianiMYMPbCtot9fxPbrMiBADOWWm3T2si9stAiVsGbTQFkbA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/esbuild/node_modules/@esbuild/android-arm64": { + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.5.tgz", + "integrity": "sha512-VGzGhj4lJO+TVGV1v8ntCZWJktV7SGCs3Pn1GRWI1SBFtRALoomm8k5E9Pmwg3HOAal2VDc2F9+PM/rEY6oIDg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/esbuild/node_modules/@esbuild/android-x64": { + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.5.tgz", + "integrity": "sha512-D2GyJT1kjvO//drbRT3Hib9XPwQeWd9vZoBJn+bu/lVsOZ13cqNdDeqIF/xQ5/VmWvMduP6AmXvylO/PIc2isw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/esbuild/node_modules/@esbuild/darwin-x64": { + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.5.tgz", + "integrity": "sha512-1iT4FVL0dJ76/q1wd7XDsXrSW+oLoquptvh4CLR4kITDtqi2e/xwXwdCVH8hVHU43wgJdsq7Gxuzcs6Iq/7bxQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/esbuild/node_modules/@esbuild/freebsd-arm64": { + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.5.tgz", + "integrity": "sha512-nk4tGP3JThz4La38Uy/gzyXtpkPW8zSAmoUhK9xKKXdBCzKODMc2adkB2+8om9BDYugz+uGV7sLmpTYzvmz6Sw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/esbuild/node_modules/@esbuild/freebsd-x64": { + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.5.tgz", + "integrity": "sha512-PrikaNjiXdR2laW6OIjlbeuCPrPaAl0IwPIaRv+SMV8CiM8i2LqVUHFC1+8eORgWyY7yhQY+2U2fA55mBzReaw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/esbuild/node_modules/@esbuild/linux-arm": { + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.5.tgz", + "integrity": "sha512-cPzojwW2okgh7ZlRpcBEtsX7WBuqbLrNXqLU89GxWbNt6uIg78ET82qifUy3W6OVww6ZWobWub5oqZOVtwolfw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/esbuild/node_modules/@esbuild/linux-arm64": { + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.5.tgz", + "integrity": "sha512-Z9kfb1v6ZlGbWj8EJk9T6czVEjjq2ntSYLY2cw6pAZl4oKtfgQuS4HOq41M/BcoLPzrUbNd+R4BXFyH//nHxVg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/esbuild/node_modules/@esbuild/linux-ia32": { + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.5.tgz", + "integrity": "sha512-sQ7l00M8bSv36GLV95BVAdhJ2QsIbCuCjh/uYrWiMQSUuV+LpXwIqhgJDcvMTj+VsQmqAHL2yYaasENvJ7CDKA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/esbuild/node_modules/@esbuild/linux-loong64": { + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.5.tgz", + "integrity": "sha512-0ur7ae16hDUC4OL5iEnDb0tZHDxYmuQyhKhsPBV8f99f6Z9KQM02g33f93rNH5A30agMS46u2HP6qTdEt6Q1kg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/esbuild/node_modules/@esbuild/linux-mips64el": { + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.5.tgz", + "integrity": "sha512-kB/66P1OsHO5zLz0i6X0RxlQ+3cu0mkxS3TKFvkb5lin6uwZ/ttOkP3Z8lfR9mJOBk14ZwZ9182SIIWFGNmqmg==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/esbuild/node_modules/@esbuild/linux-ppc64": { + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.5.tgz", + "integrity": "sha512-UZCmJ7r9X2fe2D6jBmkLBMQetXPXIsZjQJCjgwpVDz+YMcS6oFR27alkgGv3Oqkv07bxdvw7fyB71/olceJhkQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/esbuild/node_modules/@esbuild/linux-riscv64": { + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.5.tgz", + "integrity": "sha512-kTxwu4mLyeOlsVIFPfQo+fQJAV9mh24xL+y+Bm6ej067sYANjyEw1dNHmvoqxJUCMnkBdKpvOn0Ahql6+4VyeA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/esbuild/node_modules/@esbuild/linux-s390x": { + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.5.tgz", + "integrity": "sha512-K2dSKTKfmdh78uJ3NcWFiqyRrimfdinS5ErLSn3vluHNeHVnBAFWC8a4X5N+7FgVE1EjXS1QDZbpqZBjfrqMTQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/esbuild/node_modules/@esbuild/linux-x64": { + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.5.tgz", + "integrity": "sha512-uhj8N2obKTE6pSZ+aMUbqq+1nXxNjZIIjCjGLfsWvVpy7gKCOL6rsY1MhRh9zLtUtAI7vpgLMK6DxjO8Qm9lJw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/esbuild/node_modules/@esbuild/netbsd-x64": { + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.5.tgz", + "integrity": "sha512-WOb5fKrvVTRMfWFNCroYWWklbnXH0Q5rZppjq0vQIdlsQKuw6mdSihwSo4RV/YdQ5UCKKvBy7/0ZZYLBZKIbwQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/esbuild/node_modules/@esbuild/openbsd-x64": { + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.5.tgz", + "integrity": "sha512-G4hE405ErTWraiZ8UiSoesH8DaCsMm0Cay4fsFWOOUcz8b8rC6uCvnagr+gnioEjWn0wC+o1/TAHt+It+MpIMg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/esbuild/node_modules/@esbuild/sunos-x64": { + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.5.tgz", + "integrity": "sha512-l+azKShMy7FxzY0Rj4RCt5VD/q8mG/e+mDivgspo+yL8zW7qEwctQ6YqKX34DTEleFAvCIUviCFX1SDZRSyMQA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/esbuild/node_modules/@esbuild/win32-arm64": { + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.5.tgz", + "integrity": "sha512-O2S7SNZzdcFG7eFKgvwUEZ2VG9D/sn/eIiz8XRZ1Q/DO5a3s76Xv0mdBzVM5j5R639lXQmPmSo0iRpHqUUrsxw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/esbuild/node_modules/@esbuild/win32-ia32": { + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.5.tgz", + "integrity": "sha512-onOJ02pqs9h1iMJ1PQphR+VZv8qBMQ77Klcsqv9CNW2w6yLqoURLcgERAIurY6QE63bbLuqgP9ATqajFLK5AMQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/esbuild/node_modules/@esbuild/win32-x64": { + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.5.tgz", + "integrity": "sha512-TXv6YnJ8ZMVdX+SXWVBo/0p8LTcrUYngpWjvm91TMjjBQii7Oz11Lw5lbDV5Y0TzuhSJHwiH4hEtC1I42mMS0g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, "node_modules/escalade": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", @@ -17639,6 +18869,29 @@ } } }, + "node_modules/fetch-blob": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/fetch-blob/-/fetch-blob-3.2.0.tgz", + "integrity": "sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/jimmywarting" + }, + { + "type": "paypal", + "url": "https://paypal.me/jimmywarting" + } + ], + "license": "MIT", + "dependencies": { + "node-domexception": "^1.0.0", + "web-streams-polyfill": "^3.0.3" + }, + "engines": { + "node": "^12.20 || >= 14.13" + } + }, "node_modules/figures": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/figures/-/figures-3.2.0.tgz", @@ -18163,6 +19416,18 @@ "node": ">= 6" } }, + "node_modules/formdata-polyfill": { + "version": "4.0.10", + "resolved": "https://registry.npmjs.org/formdata-polyfill/-/formdata-polyfill-4.0.10.tgz", + "integrity": "sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g==", + "license": "MIT", + "dependencies": { + "fetch-blob": "^3.1.2" + }, + "engines": { + "node": ">=12.20.0" + } + }, "node_modules/forwarded": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", @@ -18600,6 +19865,19 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/get-tsconfig": { + "version": "4.12.0", + "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.12.0.tgz", + "integrity": "sha512-LScr2aNr2FbjAjZh2C6X6BxRx1/x+aTDExct/xyq2XKbYOiG5c0aK7pMsSuyc0brz3ibr/lbQiHD9jzt4lccJw==", + "dev": true, + "license": "MIT", + "dependencies": { + "resolve-pkg-maps": "^1.0.0" + }, + "funding": { + "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" + } + }, "node_modules/git-log-parser": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/git-log-parser/-/git-log-parser-1.2.1.tgz", @@ -21962,6 +23240,12 @@ "jiti": "lib/jiti-cli.mjs" } }, + "node_modules/js-base64": { + "version": "3.7.8", + "resolved": "https://registry.npmjs.org/js-base64/-/js-base64-3.7.8.tgz", + "integrity": "sha512-hNngCeKxIUQiEUN3GPJOkz4wF/YvdUdbNL9hsBcMQTkKzboD7T/q3OYOuuPZLUE6dBxSGpwhk5mwuDud7JVAow==", + "license": "BSD-3-Clause" + }, "node_modules/js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", @@ -22532,6 +23816,47 @@ "node": ">= 0.8.0" } }, + "node_modules/libsql": { + "version": "0.5.22", + "resolved": "https://registry.npmjs.org/libsql/-/libsql-0.5.22.tgz", + "integrity": "sha512-NscWthMQt7fpU8lqd7LXMvT9pi+KhhmTHAJWUB/Lj6MWa0MKFv0F2V4C6WKKpjCVZl0VwcDz4nOI3CyaT1DDiA==", + "cpu": [ + "x64", + "arm64", + "wasm32", + "arm" + ], + "license": "MIT", + "os": [ + "darwin", + "linux", + "win32" + ], + "dependencies": { + "@neon-rs/load": "^0.0.4", + "detect-libc": "2.0.2" + }, + "optionalDependencies": { + "@libsql/darwin-arm64": "0.5.22", + "@libsql/darwin-x64": "0.5.22", + "@libsql/linux-arm-gnueabihf": "0.5.22", + "@libsql/linux-arm-musleabihf": "0.5.22", + "@libsql/linux-arm64-gnu": "0.5.22", + "@libsql/linux-arm64-musl": "0.5.22", + "@libsql/linux-x64-gnu": "0.5.22", + "@libsql/linux-x64-musl": "0.5.22", + "@libsql/win32-x64-msvc": "0.5.22" + } + }, + "node_modules/libsql/node_modules/detect-libc": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.0.2.tgz", + "integrity": "sha512-UX6sGumvvqSaXgdKGUsgZWqcUyIXZ/vZTrlRT/iobiKhGL0zL4d3osHj3uqllWJK+i+sixDS/3COVEOFbupFyw==", + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, "node_modules/license-webpack-plugin": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/license-webpack-plugin/-/license-webpack-plugin-4.0.2.tgz", @@ -24283,6 +25608,26 @@ "semver": "^7.3.5" } }, + "node_modules/node-domexception": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/node-domexception/-/node-domexception-1.0.0.tgz", + "integrity": "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==", + "deprecated": "Use your platform's native DOMException instead", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/jimmywarting" + }, + { + "type": "github", + "url": "https://paypal.me/jimmywarting" + } + ], + "license": "MIT", + "engines": { + "node": ">=10.5.0" + } + }, "node_modules/node-emoji": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/node-emoji/-/node-emoji-2.2.0.tgz", @@ -30395,6 +31740,12 @@ "dev": true, "license": "ISC" }, + "node_modules/promise-limit": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/promise-limit/-/promise-limit-2.7.0.tgz", + "integrity": "sha512-7nJ6v5lnJsXwGprnGXga4wx6d1POjvi5Qmf1ivTRxTjH4Z/9Czja/UCMLVmB9N93GeWOU93XaFaEt6jbuoagNw==", + "license": "ISC" + }, "node_modules/promise-retry": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/promise-retry/-/promise-retry-2.0.1.tgz", @@ -31215,6 +32566,16 @@ "node": ">=8" } }, + "node_modules/resolve-pkg-maps": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz", + "integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" + } + }, "node_modules/resolve.exports": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/resolve.exports/-/resolve.exports-2.0.3.tgz", @@ -35010,7 +36371,6 @@ "version": "6.21.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", - "dev": true, "license": "MIT" }, "node_modules/unicode-canonical-property-names-ecmascript": { @@ -35660,6 +37020,15 @@ "license": "MIT", "optional": true }, + "node_modules/web-streams-polyfill": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-3.3.3.tgz", + "integrity": "sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==", + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, "node_modules/webidl-conversions": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", @@ -36306,7 +37675,6 @@ "version": "8.18.0", "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.0.tgz", "integrity": "sha512-8VbfWfHLbbwu3+N6OKsOMpBdT4kXPDDB9cJk2bJ6mh9ucxdlnNvH1e+roYkKmN9Nxw2yjz7VzeO9oOz2zJ04Pw==", - "dev": true, "license": "MIT", "engines": { "node": ">=10.0.0" diff --git a/package.json b/package.json index 6324dcb77..711a22830 100644 --- a/package.json +++ b/package.json @@ -46,6 +46,7 @@ "@angular/platform-browser": "~20.1.0", "@angular/platform-browser-dynamic": "~20.1.0", "@angular/router": "~20.1.0", + "@libsql/client": "0.15.15", "@ngrx/component-store": "20.0.0", "@ngrx/effects": "20.0.0", "@ngrx/entity": "20.0.0", @@ -66,6 +67,7 @@ "artplayer": "5.3.0", "axios": "1.12.2", "date-fns": "2.29.3", + "drizzle-orm": "0.44.6", "electron-store": "11.0.2", "epg-parser": "0.1.6", "fix-path": "5.0.0", @@ -141,6 +143,7 @@ "@typescript-eslint/utils": "^8.29.0", "angular-eslint": "^20.0.0", "conventional-changelog-cli": "5.0.0", + "drizzle-kit": "0.31.5", "electron": "^34.5.6", "electron-builder": "26.0.12", "eslint": "^9.8.0", From 461e2e2e7e6cabcc2886e41b58a17060991eb37d Mon Sep 17 00:00:00 2001 From: 4gray Date: Sat, 18 Oct 2025 21:55:48 +0200 Subject: [PATCH 3/3] feat(xtream): Xtream playlist refresh workflow a dedicated refresh button and workflow for Xtream in the recent playlists UI. Show a refresh icon only when the playlist has a serverUrl. For Xtream playlists, prompt the user with a confirmation dialog, delete locally cached categories and streams, show a toast that the refresh started, and navigate to the playlist to trigger re-import. Keep existing refresh IPC logic for non-Xtream (M3U/local) playlists. Also add English translations for the Xtream refresh tooltip and dialog messages, and surface errors via snackbar on failure. This enables safe re-importing of remote Xtream playlists while preserving favorites and recent items. --- .../src/app/events/database.events.ts | 1 + .../search-results.component.ts | 5 +- apps/web/src/app/xtream-tauri/xtream.store.ts | 94 ++++++++++--------- apps/web/src/assets/i18n/en.json | 7 ++ .../src/lib/database-electron.service.ts | 20 ++-- libs/services/src/lib/playlists.service.ts | 3 + .../playlist-item.component.html | 18 +++- .../recent-playlists.component.ts | 72 +++++++++++++- 8 files changed, 160 insertions(+), 60 deletions(-) diff --git a/apps/electron-backend/src/app/events/database.events.ts b/apps/electron-backend/src/app/events/database.events.ts index 7492f6cc0..adfe89c01 100644 --- a/apps/electron-backend/src/app/events/database.events.ts +++ b/apps/electron-backend/src/app/events/database.events.ts @@ -85,6 +85,7 @@ ipcMain.handle( username?: string; password?: string; serverUrl?: string; + lastUpdated?: string; } ) => { try { 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/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/libs/services/src/lib/database-electron.service.ts b/libs/services/src/lib/database-electron.service.ts index 29f66709b..a35591a8d 100644 --- a/libs/services/src/lib/database-electron.service.ts +++ b/libs/services/src/lib/database-electron.service.ts @@ -92,18 +92,24 @@ export class DatabaseService { */ async updateXtreamPlaylistDetails(playlist: { id: string; - title: string; + title?: string; username?: string; password?: string; serverUrl?: string; + updateDate?: number; }): Promise { try { - await window.electron.dbUpdatePlaylist(playlist.id, { - name: playlist.title, - username: playlist.username, - password: playlist.password, - serverUrl: playlist.serverUrl, - }); + 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); 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 +