Merge pull request #673 from 4gray/build/unpack-libsql-native-bindings-win

build(win): unpack libsql native bindings in ASAR
This commit is contained in:
4gray authored and GitHub committed 2025-12-06 21:44:04 +01:00
commit a08a4e4122
6 files changed
+313 -590

No files matched your search

@@ -136,4 +136,5 @@ contextBridge.exposeInMainWorld('electron', {
ipcRenderer.invoke('DB_REMOVE_RECENT_ITEM', contentId, playlistId),
dbGetContentByXtreamId: (xtreamId: number, playlistId: string) =>
ipcRenderer.invoke('DB_GET_CONTENT_BY_XTREAM_ID', xtreamId, playlistId),
dbDeleteAllPlaylists: () => ipcRenderer.invoke('DB_DELETE_ALL_PLAYLISTS'),
});
@@ -1,29 +1,25 @@
/**
* 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)
* Uses Drizzle ORM with better-sqlite3
* Stores database file under ~/.iptvnator/databases/
*/
import { createClient } from '@libsql/client';
import type { LibSQLDatabase } from 'drizzle-orm/libsql';
import { drizzle } from 'drizzle-orm/libsql';
import Database from 'better-sqlite3';
import type { BetterSQLite3Database } from 'drizzle-orm/better-sqlite3';
import { drizzle } from 'drizzle-orm/better-sqlite3';
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;
let db: BetterSQLite3Database<typeof schema> | null = null;
let sqlite: Database.Database | null = null;
let initPromise: Promise<BetterSQLite3Database<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
@@ -33,37 +29,26 @@ function getDatabasePath(): string {
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)
* Initialize the database connection
* Creates tables if they don't exist
*/
export async function initDatabase(): Promise<LibSQLDatabase<typeof schema>> {
export async function initDatabase(): Promise<BetterSQLite3Database<typeof schema>> {
if (db) return db;
if (initPromise) return initPromise;
initPromise = (async () => {
const cfg = getLibsqlConfig();
client = createClient(cfg);
const database = drizzle(client, { schema });
const filePath = getDatabasePath();
sqlite = new Database(filePath);
// Enable foreign keys
sqlite.pragma('foreign_keys = ON');
const database = drizzle(sqlite, { schema });
// Create tables if they don't exist
await createTables();
createTables();
db = database;
return database;
@@ -75,11 +60,11 @@ export async function initDatabase(): Promise<LibSQLDatabase<typeof schema>> {
/**
* Create tables if they don't exist
*/
async function createTables() {
if (!client) return;
function createTables() {
if (!sqlite) return;
const createTablesSQL = `
CREATE TABLE IF NOT EXISTS playlists (
const statements = [
`CREATE TABLE IF NOT EXISTS playlists (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
serverUrl TEXT,
@@ -96,18 +81,16 @@ async function createTables() {
macAddress TEXT,
url TEXT,
last_usage TEXT
);
CREATE TABLE IF NOT EXISTS categories (
)`,
`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 (
)`,
`CREATE TABLE IF NOT EXISTS content (
id INTEGER PRIMARY KEY AUTOINCREMENT,
category_id INTEGER NOT NULL,
title TEXT NOT NULL,
@@ -117,52 +100,39 @@ async function createTables() {
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 (
)`,
`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 (
)`,
`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);
)`,
`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)`,
];
for (const stmt of statements) {
// Add back the semicolon when helpful for parser tolerance
await client.execute(stmt);
sqlite.exec(stmt);
}
}
@@ -170,7 +140,7 @@ async function createTables() {
* Get the database instance
* Initializes the database if not already initialized
*/
export async function getDatabase(): Promise<LibSQLDatabase<typeof schema>> {
export async function getDatabase(): Promise<BetterSQLite3Database<typeof schema>> {
if (db) return db;
return initDatabase();
}
@@ -985,3 +985,31 @@ ipcMain.handle(
}
}
);
/**
* Delete all playlists and related data from SQLite
*/
ipcMain.handle('DB_DELETE_ALL_PLAYLISTS', async () => {
try {
const db = await getDatabase();
// Delete in order respecting foreign key constraints
// First delete favorites and recently_viewed (they reference content)
await db.delete(schema.favorites);
await db.delete(schema.recentlyViewed);
// Then delete content (references categories)
await db.delete(schema.content);
// Then delete categories (references playlists)
await db.delete(schema.categories);
// Finally delete playlists
await db.delete(schema.playlists);
return { success: true };
} catch (error) {
console.error('Error deleting all playlists:', error);
throw error;
}
});
+38 -5
View File
@@ -17,6 +17,7 @@ import {
} from 'rxjs';
import { DataService, EpgService, PlaylistsService } from 'services';
import {
GLOBAL_FAVORITES_PLAYLIST_ID,
OPEN_MPV_PLAYER,
OPEN_VLC_PLAYER,
Playlist,
@@ -71,6 +72,10 @@ export class PlaylistEffects {
return this.actions$.pipe(
ofType(PlaylistActions.setFavorites),
combineLatestWith(this.store.select(selectActivePlaylistId)),
filter(
([, playlistId]) =>
playlistId !== GLOBAL_FAVORITES_PLAYLIST_ID
),
switchMap(([action, playlistId]) =>
this.playlistsService.setFavorites(
playlistId,
@@ -207,9 +212,25 @@ export class PlaylistEffects {
() => {
return this.actions$.pipe(
ofType(PlaylistActions.removePlaylist),
switchMap((action) =>
this.playlistsService.deletePlaylist(action.playlistId)
)
switchMap(async (action) => {
// Delete from IndexedDB
await firstValueFrom(
this.playlistsService.deletePlaylist(action.playlistId)
);
// Also delete from SQLite if running in Electron
if ((window as any).electron?.dbDeletePlaylist) {
try {
await (window as any).electron.dbDeletePlaylist(
action.playlistId
);
} catch (error) {
console.error(
'Error deleting playlist from SQLite:',
error
);
}
}
})
);
},
{ dispatch: false }
@@ -350,8 +371,20 @@ export class PlaylistEffects {
() => {
return this.actions$.pipe(
ofType(PlaylistActions.removeAllPlaylists),
switchMap(() => this.playlistsService.removeAll()),
tap(() => {
switchMap(async () => {
// Delete from IndexedDB
await firstValueFrom(this.playlistsService.removeAll());
// Also delete from SQLite if running in Electron
if ((window as any).electron?.dbDeleteAllPlaylists) {
try {
await (window as any).electron.dbDeleteAllPlaylists();
} catch (error) {
console.error(
'Error deleting playlists from SQLite:',
error
);
}
}
this.snackBar.open(
this.translate.instant('SETTINGS.PLAYLISTS_REMOVED'),
undefined,
+194 -506
View File
File diff suppressed because it is too large. Load diff
+6 -3
View File
@@ -33,6 +33,9 @@
"remote-control-web/**/*",
"!**/*.map"
],
"asarUnpack": [
"**/better-sqlite3/**"
],
"extraMetadata": {
"main": "electron-backend/main.js"
},
@@ -92,7 +95,6 @@
{
"target": "nsis",
"arch": [
"ia32",
"x64"
]
}
@@ -129,7 +131,7 @@
"@angular/platform-browser": "~20.3.10",
"@angular/platform-browser-dynamic": "~20.3.10",
"@angular/router": "~20.3.10",
"@libsql/client": "0.15.15",
"better-sqlite3": "^12.5.0",
"@ngrx/component-store": "20.0.0",
"@ngrx/effects": "20.0.0",
"@ngrx/entity": "20.0.0",
@@ -147,7 +149,6 @@
"date-fns": "4.1.0",
"drizzle-orm": "0.44.6",
"electron-conf": "1.3.0",
"electron-store": "11.0.2",
"epg-parser": "0.4.0",
"fix-path": "5.0.0",
"hls.js": "1.6.13",
@@ -206,6 +207,7 @@
"@swc-node/register": "~1.9.1",
"@swc/core": "~1.5.7",
"@swc/helpers": "~0.5.11",
"@types/better-sqlite3": "^7.6.12",
"@types/jest": "^30.0.0",
"@types/lodash": "4.17.20",
"@types/mocha": "9.0.0",
@@ -221,6 +223,7 @@
"drizzle-kit": "0.31.5",
"electron": "^39.0.0",
"electron-builder": "^26.0.12",
"electron-playwright-helpers": "1.8.2",
"esbuild": "0.27.0",
"eslint": "^9.8.0",
"eslint-config-prettier": "^10.1.8",