mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-08 09:01:03 -08:00
refactor(electron): migrate from tauri/plugin-sql to libsql
This commit is contained in:
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),
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
);
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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,
|
||||
],
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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,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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
Generated
+1372
-4
File diff suppressed because it is too large.
Load diff
@@ -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",
|
||||
|
||||
Reference in new issue
Block a user