This commit is contained in:
@@ -0,0 +1,216 @@
|
||||
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);
|
||||
`);
|
||||
|
||||
// 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;
|
||||
}
|
||||
};
|
||||
|
||||
module.exports = {
|
||||
getDb,
|
||||
initSchema,
|
||||
favorites
|
||||
};
|
||||
Reference in New Issue
Block a user