feat(db): add shared database library with sql config

Introduce a shared database schema library for Drizzle ORM and
configure its TypeScript build settings. Move the canonical schema
into libs/shared/database/src/lib/schema.ts and expose strong
typing and table definitions for playlists, categories, content,
recently_viewed and favorites. Add tsconfig.lib.json to set
outDir, declaration output and node types for the shared lib.

Keep apps/electron-backend compatibility by re-exporting the
schema and types from the new shared library (apps/electron-backend
now imports the shared definitions rather than duplicating them).

These changes centralize the database schema as a single source
of truth, prevent duplication, and enable read-only consumers
(e.g. agent-backend) to depend on compiled type declarations.
This commit is contained in:
4gray committed 2025-12-26 00:35:07 +01:00
1 parent 65b21aa0f2
commit 815279f49f
10 files changed
+457 -249

No files matched your search

+8
View File
@@ -0,0 +1,8 @@
/**
* Shared Database module for IPTVnator
* Provides Drizzle ORM schema and connection utilities for SQLite database
* Used by both electron-backend (read-write) and agent-backend (read-only)
*/
export * from './lib/schema';
export * from './lib/connection';
+189
View File
@@ -0,0 +1,189 @@
/**
* Database connection and initialization for IPTVnator
* Uses Drizzle ORM with better-sqlite3
* Stores database file under ~/.iptvnator/databases/
*
* Provides two connection modes:
* - Full access (for electron-backend): creates tables, read-write
* - Read-only access (for agent-backend): no table creation, read-only queries
*/
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';
export type DatabaseInstance = BetterSQLite3Database<typeof schema>;
let db: DatabaseInstance | null = null;
let sqlite: Database.Database | null = null;
let initPromise: Promise<DatabaseInstance> | null = null;
/**
* Get the database file path
*/
export function getDatabasePath(): string {
const dbDir = join(homedir(), '.iptvnator', 'databases');
// Ensure the directory exists
if (!existsSync(dbDir)) {
mkdirSync(dbDir, { recursive: true });
}
return join(dbDir, 'iptvnator.db');
}
/**
* SQL statements for creating all tables
*/
const CREATE_TABLE_STATEMENTS = [
`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)`,
];
/**
* Create tables if they don't exist
*/
function createTables(sqliteDb: Database.Database): void {
for (const stmt of CREATE_TABLE_STATEMENTS) {
sqliteDb.exec(stmt);
}
}
export interface DatabaseOptions {
/** Open database in read-only mode (for agent-backend) */
readonly?: boolean;
/** Skip table creation (useful for read-only connections) */
skipTableCreation?: boolean;
}
/**
* Initialize the database connection with full access (read-write)
* Creates tables if they don't exist
* Used by electron-backend
*/
export async function initDatabase(
options: DatabaseOptions = {}
): Promise<DatabaseInstance> {
const { readonly = false, skipTableCreation = false } = options;
if (db) return db;
if (initPromise) return initPromise;
initPromise = (async () => {
const filePath = getDatabasePath();
sqlite = new Database(filePath, { readonly });
// Enable foreign keys
sqlite.pragma('foreign_keys = ON');
// Create tables only for read-write connections
if (!readonly && !skipTableCreation) {
createTables(sqlite);
}
const database = drizzle(sqlite, { schema });
db = database;
return database;
})();
return initPromise;
}
/**
* Get the database instance
* Initializes the database if not already initialized (with defaults for full access)
* Used by electron-backend
*/
export async function getDatabase(
options?: DatabaseOptions
): Promise<DatabaseInstance> {
if (db) return db;
return initDatabase(options);
}
/**
* Get a read-only database connection
* Used by agent-backend for safe read-only queries
*/
export async function getReadOnlyDatabase(): Promise<DatabaseInstance> {
return getDatabase({ readonly: true, skipTableCreation: true });
}
/**
* Close the database connection
*/
export function closeDatabase(): void {
if (sqlite) {
sqlite.close();
sqlite = null;
db = null;
initPromise = null;
}
}
+140
View File
@@ -0,0 +1,140 @@
/**
* Drizzle ORM schema for IPTVnator database
* This schema defines the structure for Xtream Codes API data storage
*
* Single source of truth for database schema - used by:
* - electron-backend (full read-write access)
* - agent-backend (read-only access for AI queries)
*/
import { sql } from 'drizzle-orm';
import {
index,
integer,
sqliteTable,
text,
uniqueIndex,
} 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`),
},
(table) => ({
contentPlaylistUnique: uniqueIndex(
'recently_viewed_content_playlist_unique'
).on(table.contentId, table.playlistId),
playlistIdx: index('recently_viewed_playlist_idx').on(table.playlistId),
viewedAtIdx: index('recently_viewed_viewed_at_idx').on(table.viewedAt),
})
);
// 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`),
},
(table) => ({
contentPlaylistUnique: uniqueIndex(
'favorites_content_playlist_unique'
).on(table.contentId, table.playlistId),
playlistIdx: index('favorites_playlist_idx').on(table.playlistId),
contentIdx: index('favorites_content_idx').on(table.contentId),
})
);
// 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;