Files
Nodecast/server/db/sqlite.js
blomios ce11ecc757 feat: add optional Laya engine mode for grouping and language detection (#12)
Add a languageDetectionMode setting (algo | laya | laya+algo) exposed
in Settings with three engine choices. When Laya is enabled, a laya
service (Dockerfile.laya, optional compose profile, models stored
outside the repo under ./data/models/laya) can be started, with model
download and deletion from the UI via /laya routes to manage disk
space. Add a channelLanguageFusion service combining Laya results
with the existing algorithm, and wire detection mode through
grouping, channel list and player. Cover engine selection, fusion
and laya service behavior with tests.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-02 11:13:34 +02:00

253 lines
8.7 KiB
JavaScript

const Database = require('better-sqlite3');
const path = require('path');
const fs = require('fs');
const dataDir = path.join(__dirname, '..', '..', 'data');
const dbPath = path.join(dataDir, 'content.db');
// Ensure data directory exists
if (!fs.existsSync(dataDir)) {
fs.mkdirSync(dataDir, { recursive: true });
}
let db;
function getDb() {
if (!db) {
console.log('[SQLite] Opening database at', dbPath);
db = new Database(dbPath);
// Optimize performance
db.pragma('journal_mode = WAL');
db.pragma('synchronous = NORMAL');
initSchema();
}
return db;
}
function initSchema() {
if (!db) throw new Error('Database not initialized');
// Categories (Groups)
db.exec(`
CREATE TABLE IF NOT EXISTS categories (
id TEXT PRIMARY KEY, -- Composite key: sourceId:categoryId
source_id INTEGER NOT NULL,
category_id TEXT NOT NULL,
type TEXT NOT NULL, -- 'live', 'movie', 'series'
name TEXT NOT NULL,
parent_id TEXT, -- For nested categories
is_hidden INTEGER DEFAULT 0,
data JSON -- Extra provider data
);
CREATE INDEX IF NOT EXISTS idx_categories_source_type ON categories(source_id, type);
`);
// Playlist Items (Channels, Movies, Series, Episodes)
db.exec(`
CREATE TABLE IF NOT EXISTS playlist_items (
id TEXT PRIMARY KEY, -- Composite key: sourceId:itemId
source_id INTEGER NOT NULL,
item_id TEXT NOT NULL, -- Original ID from provider
type TEXT NOT NULL, -- 'live', 'movie', 'series', 'episode'
name TEXT NOT NULL,
category_id TEXT, -- maps to categories.category_id (not our composite id)
parent_id TEXT, -- For episodes -> series_id
-- Common Media Fields
stream_icon TEXT,
stream_url TEXT, -- Direct link if available
container_extension TEXT,
-- VOD/Series Specific
rating REAL,
year TEXT,
added_at TEXT,
-- App State
is_hidden INTEGER DEFAULT 0,
is_favorite INTEGER DEFAULT 0,
data JSON -- Full original JSON object
);
CREATE INDEX IF NOT EXISTS idx_items_source_type ON playlist_items(source_id, type);
CREATE INDEX IF NOT EXISTS idx_items_category ON playlist_items(source_id, category_id);
`);
// EPG Programs
// Optimized for range queries
db.exec(`
CREATE TABLE IF NOT EXISTS epg_programs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
channel_id TEXT NOT NULL, -- matches playlist_items.id if possible, or mapping key
source_id INTEGER NOT NULL,
start_time INTEGER NOT NULL, -- Unix timestamp (ms)
end_time INTEGER NOT NULL, -- Unix timestamp (ms)
title TEXT,
description TEXT,
data JSON
);
CREATE INDEX IF NOT EXISTS idx_epg_channel_time ON epg_programs(channel_id, start_time, end_time);
CREATE INDEX IF NOT EXISTS idx_epg_cleanup ON epg_programs(end_time); -- For deleting old programs
`);
// Sync Status
db.exec(`
CREATE TABLE IF NOT EXISTS sync_status (
source_id INTEGER NOT NULL,
type TEXT NOT NULL, -- 'live', 'vod', 'series', 'epg'
last_sync INTEGER NOT NULL,
status TEXT, -- 'success', 'error', 'syncing'
error TEXT,
PRIMARY KEY (source_id, type)
);
`);
// User Favorites (per-user)
db.exec(`
CREATE TABLE IF NOT EXISTS favorites (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
source_id INTEGER NOT NULL,
item_id TEXT NOT NULL,
item_type TEXT NOT NULL, -- 'channel', 'movie', 'series'
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
UNIQUE(user_id, source_id, item_id, item_type)
);
CREATE INDEX IF NOT EXISTS idx_favorites_user ON favorites(user_id);
CREATE INDEX IF NOT EXISTS idx_favorites_user_type ON favorites(user_id, item_type);
`);
// Watch History (per-user)
db.exec(`
CREATE TABLE IF NOT EXISTS watch_history (
id TEXT PRIMARY KEY, -- Composite key: user_id:item_id
user_id INTEGER NOT NULL,
source_id INTEGER, -- Source ID for Xtream/M3U
item_type TEXT NOT NULL, -- 'movie', 'episode'
item_id TEXT NOT NULL, -- The original item ID (stream_id or composite)
parent_id TEXT, -- For episodes (series ID)
progress INTEGER DEFAULT 0, -- Current position in seconds
duration INTEGER DEFAULT 0, -- Total duration in seconds
updated_at INTEGER NOT NULL, -- Timestamp
data JSON -- Snapshot of item data (title, poster, etc)
);
CREATE INDEX IF NOT EXISTS idx_history_user_updated ON watch_history(user_id, updated_at DESC);
CREATE INDEX IF NOT EXISTS idx_history_user_item ON watch_history(user_id, item_id);
`);
db.exec(`
CREATE TABLE IF NOT EXISTS laya_language_cache (
cache_key TEXT PRIMARY KEY,
mode TEXT NOT NULL,
model TEXT NOT NULL,
response TEXT NOT NULL,
created_at INTEGER NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_laya_cache_mode_model
ON laya_language_cache(mode, model);
`);
// Migration: Add source_id column if missing (for existing databases)
try {
db.exec(`ALTER TABLE watch_history ADD COLUMN source_id INTEGER`);
console.log('[SQLite] Added source_id column to watch_history');
} catch (e) {
// Column already exists, ignore
}
console.log('[SQLite] Schema initialized');
}
// ============================================================
// Favorites CRUD Operations
// ============================================================
const favorites = {
getAll(userId, sourceId = null, itemType = null) {
const db = getDb();
let sql = 'SELECT * FROM favorites WHERE user_id = ?';
const params = [userId];
if (sourceId) {
sql += ' AND source_id = ?';
params.push(sourceId);
}
if (itemType) {
sql += ' AND item_type = ?';
params.push(itemType);
}
sql += ' ORDER BY created_at DESC';
return db.prepare(sql).all(...params);
},
add(userId, sourceId, itemId, itemType = 'channel') {
const db = getDb();
const stmt = db.prepare(`
INSERT OR IGNORE INTO favorites (user_id, source_id, item_id, item_type)
VALUES (?, ?, ?, ?)
`);
const result = stmt.run(userId, sourceId, itemId, itemType);
return result.changes > 0;
},
remove(userId, sourceId, itemId, itemType = 'channel') {
const db = getDb();
const stmt = db.prepare(`
DELETE FROM favorites
WHERE user_id = ? AND source_id = ? AND item_id = ? AND item_type = ?
`);
const result = stmt.run(userId, sourceId, itemId, itemType);
return result.changes > 0;
},
isFavorite(userId, sourceId, itemId, itemType = 'channel') {
const db = getDb();
const row = db.prepare(`
SELECT 1 FROM favorites
WHERE user_id = ? AND source_id = ? AND item_id = ? AND item_type = ?
`).get(userId, sourceId, itemId, itemType);
return !!row;
},
// Get all favorites for a user, grouped by type (for bulk checks)
getAllAsSet(userId) {
const db = getDb();
const rows = db.prepare('SELECT source_id, item_id, item_type FROM favorites WHERE user_id = ?').all(userId);
const set = new Set();
for (const row of rows) {
set.add(`${row.source_id}:${row.item_id}:${row.item_type}`);
}
return set;
}
};
const layaLanguageCache = {
get(cacheKey) {
const row = getDb().prepare(
'SELECT response FROM laya_language_cache WHERE cache_key = ?'
).get(cacheKey);
return row ? JSON.parse(row.response) : null;
},
set(cacheKey, mode, model, response) {
getDb().prepare(`
INSERT INTO laya_language_cache (cache_key, mode, model, response, created_at)
VALUES (?, ?, ?, ?, ?)
ON CONFLICT(cache_key) DO UPDATE SET
response = excluded.response,
created_at = excluded.created_at
`).run(cacheKey, mode, model, JSON.stringify(response), Date.now());
},
clear() {
getDb().prepare('DELETE FROM laya_language_cache').run();
}
};
module.exports = {
getDb,
initSchema,
favorites,
layaLanguageCache
};