refactor(electron): migrate from tauri/plugin-sql to libsql

This commit is contained in:
4gray committed 2025-10-18 21:31:29 +02:00
1 parent 6dcbbadda8
commit 0a934d2ac6
31 files changed
+3458 -752

No files matched your search

@@ -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<string, string>;
}) => ipcRenderer.invoke('STALKER_REQUEST', payload),
xtreamRequest: (payload: { url: string; params: Record<string, string> }) =>
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),
});
+1
View File
@@ -68,6 +68,7 @@ export default class App {
// Create the browser window.
App.mainWindow = new BrowserWindow({
title: 'IPTVnator',
width: width,
height: height,
show: false,
@@ -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<typeof schema> | null = null;
let client: ReturnType<typeof createClient> | null = null;
let initPromise: Promise<LibSQLDatabase<typeof schema>> | 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<LibSQLDatabase<typeof schema>> {
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<LibSQLDatabase<typeof schema>> {
if (db) return db;
return initDatabase();
}
@@ -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;
@@ -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<number>`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<number>`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<Record<string, unknown>>,
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<number>`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;
}
}
);
@@ -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<string, string>;
}
) => {
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,
};
}
}
}
);
@@ -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<string, string>;
}
) => {
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,
};
}
}
}
);
+18 -2
View File
@@ -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();
});
+28 -79
View File
@@ -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<string, string>;
}) {
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<Playlist>) {
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<string, string>;
}) {
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) {
@@ -63,7 +63,8 @@
'CHANNELS.REMOVE_FAVORITE' | translate
"
(click)="
$event.stopPropagation(); removeFavorite(item)
$event.stopPropagation();
removeFromFavorites(item)
"
>
<mat-icon>delete</mat-icon>
@@ -78,5 +79,5 @@
</div>
}
</div>
<app-mpv-player-bar />
<!-- <app-mpv-player-bar /> -->
</div>
@@ -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<string>('CHANNELS.FAVORITES');
readonly categoryClicked = output<any>();
readonly removeFromFavorites = output<any>();
readonly removeFavorite = output<any>();
readonly openItem = output<any>();
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) {
@@ -117,5 +117,5 @@
<div class="scrollable-content">
<router-outlet />
</div>
<app-mpv-player-bar />
<!-- <app-mpv-player-bar /> -->
</div>
@@ -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,
@@ -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
@@ -1,4 +1,5 @@
@let isEmbeddedPlayer = player() === 'videojs' || player() === 'html5';
@let isEmbeddedPlayer =
player() === 'videojs' || player() === 'html5' || player() === 'artplayer';
<div class="sidebar">
<div class="sidebar-header">
@@ -42,5 +43,5 @@
{{ 'PORTALS.SELECT_CATEGORY' | translate }}
</div>
}
<app-mpv-player-bar />
<!-- <app-mpv-player-bar /> -->
</div>
@@ -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;
}
}
@@ -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,
@@ -6,4 +6,6 @@ export interface FavoriteItem {
stream_icon?: string;
poster_url?: string;
added_at?: string;
category_id: number;
xtream_id: number;
}
@@ -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<void> {
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<void> {
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<boolean> {
const db = await this.dbService.getConnection();
const result = await db.select<any[]>(
`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<FavoriteItem[]> {
return from(this.dbService.getConnection()).pipe(
mergeMap(async (db) => {
return await db.select<FavoriteItem[]>(
`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,
}))
)
);
}
}
@@ -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
);
@@ -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<RecentlyViewedItem[]>(
`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 });
})
)
),
@@ -30,5 +30,5 @@
<div class="scrollable-content">
<router-outlet />
</div>
<app-mpv-player-bar />
<!-- <app-mpv-player-bar /> -->
</div>
@@ -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,
],
+22
View File
@@ -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;
Vendored
+87
View File
@@ -44,6 +44,93 @@ declare global {
updateSettings: (settings: any) => Promise<void>;
setMpvPlayerPath: (mpvPlayerPath: string) => Promise<void>;
setVlcPlayerPath: (vlcPlayerPath: string) => Promise<void>;
stalkerRequest: (payload: {
url: string;
macAddress: string;
params: Record<string, string>;
}) => Promise<any>;
xtreamRequest: (payload: {
url: string;
params: Record<string, string>;
}) => Promise<{ payload: any; action: string }>;
// Database operations
dbCreatePlaylist: (playlist: any) => Promise<{ success: boolean }>;
dbGetPlaylist: (playlistId: string) => Promise<any>;
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<boolean>;
dbGetCategories: (
playlistId: string,
type: string
) => Promise<any[]>;
dbSaveCategories: (
playlistId: string,
categories: any[],
type: string
) => Promise<{ success: boolean }>;
dbHasContent: (
playlistId: string,
type: string
) => Promise<boolean>;
dbGetContent: (playlistId: string, type: string) => Promise<any[]>;
dbSaveContent: (
playlistId: string,
streams: any[],
type: string
) => Promise<{ success: boolean; count: number }>;
dbSearchContent: (
playlistId: string,
searchTerm: string,
types: string[]
) => Promise<any[]>;
dbGlobalSearch: (
searchTerm: string,
types: string[]
) => Promise<any[]>;
dbGetRecentlyViewed: () => Promise<any[]>;
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<boolean>;
dbGetFavorites: (playlistId: string) => Promise<any[]>;
// Recently viewed (playlist-specific)
dbGetRecentItems: (playlistId: string) => Promise<any[]>;
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<any | null>;
};
process: NodeJS.Process;
require: NodeRequire;
+10 -15
View File
@@ -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]);
}
})
+1 -1
View File
@@ -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';
@@ -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<boolean> {
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<void> {
await window.electron.dbDeleteXtreamContent(playlistId);
}
/**
* Update playlist basic info
*/
async updateXtreamPlaylist(playlist: any): Promise<boolean> {
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<boolean> {
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<boolean> {
return await window.electron.dbHasCategories(playlistId, type);
}
/**
* Get categories for a playlist
*/
async getXtreamCategories(
playlistId: string,
type: 'live' | 'movies' | 'series'
): Promise<XCategoryFromDb[]> {
return await window.electron.dbGetCategories(playlistId, type);
}
/**
* Save categories in bulk
*/
async saveXtreamCategories(
playlistId: string,
categories: any[],
type: 'live' | 'movies' | 'series'
): Promise<void> {
await window.electron.dbSaveCategories(playlistId, categories, type);
}
/**
* Check if content exists
*/
async hasXtreamContent(
playlistId: string,
type: 'live' | 'movie' | 'series'
): Promise<boolean> {
return await window.electron.dbHasContent(playlistId, type);
}
/**
* Get content for a playlist
*/
async getXtreamContent(
playlistId: string,
type: 'live' | 'movie' | 'series'
): Promise<XtreamContent[]> {
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<number> {
// 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<XtreamContent[]> {
return await window.electron.dbSearchContent(
playlistId,
searchTerm,
types
);
}
/**
* Global search across all playlists
*/
async globalSearchContent(
searchTerm: string,
types: string[]
): Promise<GlobalSearchResult[]> {
return await window.electron.dbGlobalSearch(searchTerm, types);
}
/**
* Get recently viewed items
*/
async getGlobalRecentlyViewed(): Promise<GlobalRecentItem[]> {
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<void> {
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<XtreamPlaylist | null> {
return await window.electron.dbGetPlaylist(playlistId);
}
/**
* Create a new playlist
*/
async createPlaylist(playlist: PlaylistMeta): Promise<void> {
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<boolean> {
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<boolean> {
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<boolean> {
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<XtreamContent[]> {
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<XtreamContent[]> {
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<boolean> {
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<boolean> {
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<boolean> {
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<XtreamContent | null> {
try {
return await window.electron.dbGetContentByXtreamId(
xtreamId,
playlistId
);
} catch (error) {
console.error('Error getting content by xtream ID:', error);
return null;
}
}
}
-484
View File
@@ -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<Database> {
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<boolean> {
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<boolean> {
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<boolean> {
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<boolean> {
const db = await this.getConnection();
const result = await db.select<XCategoryFromDb[]>(
'SELECT * FROM categories WHERE playlist_id = ? AND type = ?',
[playlistId, type]
);
return result.length > 0;
}
async getXtreamCategories(
playlistId: string,
type: 'live' | 'movies' | 'series'
): Promise<XCategoryFromDb[]> {
const db = await this.getConnection();
return await db.select<XCategoryFromDb[]>(
'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<void> {
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<boolean> {
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<XtreamContent[]> {
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<number> {
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<XtreamContent[]> {
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<GlobalSearchResult[]> {
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<GlobalRecentItem[]> {
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<GlobalRecentItem[]>(`
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<void> {
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<XtreamPlaylist | null> {
const db = await this.getConnection();
const results = await db.select<XtreamPlaylist[]>(
'SELECT * FROM playlists WHERE id = ?',
[playlistId]
);
return results[0] || null;
}
async createPlaylist(playlist: PlaylistMeta): Promise<void> {
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<number, number>
): 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<number> {
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;
}
}
+1372 -4
View File
File diff suppressed because it is too large. Load diff
+3
View File
@@ -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",