Add a Settings action to manually rerun Live TV stream analysis: it triggers an admin-guarded projection rebuild endpoint that groups streams into logical channels (covering channels present before the projection existed) and reruns language/Laya detection, with progress presented via liveAnalysisPresentation. Harden the Laya batch path that failed with 'Local AI language matching failed' by surfacing the underlying cause and falling back to standard matching cleanly. Guard admin-only rebuild through a new authz helper and cover routes, service and presentation with tests. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
265 lines
9.1 KiB
JavaScript
265 lines
9.1 KiB
JavaScript
const Database = require('better-sqlite3');
|
|
const path = require('path');
|
|
const fs = require('fs');
|
|
const {
|
|
CATALOG_PROJECTION_STATE_COLUMNS,
|
|
ensureColumns,
|
|
LIVE_PROJECTION_COLUMNS,
|
|
LIVE_PROJECTION_SCHEMA_SQL
|
|
} = require('./liveProjectionSchema');
|
|
|
|
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);
|
|
`);
|
|
|
|
ensureColumns(db, 'playlist_items', LIVE_PROJECTION_COLUMNS);
|
|
db.exec(LIVE_PROJECTION_SCHEMA_SQL);
|
|
ensureColumns(db, 'catalog_projection_state', CATALOG_PROJECTION_STATE_COLUMNS);
|
|
|
|
// 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,
|
|
ensureColumns,
|
|
LIVE_PROJECTION_COLUMNS,
|
|
favorites,
|
|
layaLanguageCache
|
|
};
|