import 'dart:io'; import 'dart:math'; import 'package:sqlite3/sqlite3.dart'; import 'package:uuid/uuid.dart'; import '../models/user.dart'; import '../models/playlist.dart'; import '../models/session.dart' as models; import '../models/recording.dart'; import '../utils/password_hasher.dart'; class AppDatabase { late final Database _db; final _uuid = const Uuid(); /// Initialize database and create tables Future init() async { const dbPath = '/app/data/xtremflow.db'; // Ensure data directory exists final dir = Directory('/app/data'); if (!await dir.exists()) { await dir.create(recursive: true); } _db = sqlite3.open(dbPath); // Sans foreign_keys, les ON DELETE CASCADE déclarés dans le schéma sont // ignorés par SQLite : supprimer un utilisateur laissait ses sessions // (donc des jetons valides), playlists et enregistrements orphelins. _db.execute('PRAGMA foreign_keys = ON'); _db.execute('PRAGMA journal_mode = WAL'); _db.execute('PRAGMA busy_timeout = 5000'); await _createTables(); _runMigrations(); print('Database initialized: $dbPath'); } /// Migrations de schéma pour les bases créées par une version antérieure. /// /// `CREATE TABLE IF NOT EXISTS` n'ajoute jamais de colonne à une table /// existante : chaque colonne introduite après coup doit avoir sa migration. /// Les migrations sont numérotées et rejouées uniquement si nécessaire. void _runMigrations() { _db.execute(''' CREATE TABLE IF NOT EXISTS schema_version ( version INTEGER PRIMARY KEY ) '''); final result = _db.select( 'SELECT COALESCE(MAX(version), 0) AS v FROM schema_version', ); var current = result.first['v'] as int; final migrations = { // v1 : colonne error_reason absente des bases d'avant son introduction. 1: () => _addColumnIfMissing('tv_recordings', 'error_reason', 'TEXT'), // v2 : mode de correspondance des season passes. 'contains' pour les // lignes existantes (comportement historique) ; les nouvelles créations // passent par l'API qui choisit 'exact' par défaut. 2: () => _addColumnIfMissing( 'season_passes', 'match_mode', "TEXT NOT NULL DEFAULT 'contains'", ), }; for (final entry in migrations.entries) { if (entry.key <= current) continue; entry.value(); _db.execute( 'INSERT INTO schema_version (version) VALUES (?)', [entry.key], ); current = entry.key; print('[DB] Migration v${entry.key} appliquée'); } } void _addColumnIfMissing(String table, String column, String definition) { final columns = _db.select('PRAGMA table_info($table)'); final exists = columns.any((row) => row['name'] == column); if (!exists) { _db.execute('ALTER TABLE $table ADD COLUMN $column $definition'); } } /// Create database tables Future _createTables() async { // Users table _db.execute(''' CREATE TABLE IF NOT EXISTS users ( id TEXT PRIMARY KEY, username TEXT UNIQUE NOT NULL, password_hash TEXT NOT NULL, is_admin INTEGER DEFAULT 0, created_at TEXT DEFAULT CURRENT_TIMESTAMP, updated_at TEXT DEFAULT CURRENT_TIMESTAMP ) '''); // Playlists table _db.execute(''' CREATE TABLE IF NOT EXISTS playlists ( id TEXT PRIMARY KEY, user_id TEXT NOT NULL, name TEXT NOT NULL, server_url TEXT NOT NULL, username TEXT NOT NULL, password TEXT NOT NULL, dns TEXT, created_at TEXT DEFAULT CURRENT_TIMESTAMP, updated_at TEXT DEFAULT CURRENT_TIMESTAMP, FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE ) '''); // Sessions table _db.execute(''' CREATE TABLE IF NOT EXISTS sessions ( id TEXT PRIMARY KEY, user_id TEXT NOT NULL, token TEXT UNIQUE NOT NULL, expires_at TEXT NOT NULL, created_at TEXT DEFAULT CURRENT_TIMESTAMP, FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE ) '''); // User Settings table _db.execute(''' CREATE TABLE IF NOT EXISTS user_settings ( user_id TEXT PRIMARY KEY, settings_json TEXT NOT NULL, updated_at TEXT DEFAULT CURRENT_TIMESTAMP, FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE ) '''); // TV Recordings table _db.execute(''' CREATE TABLE IF NOT EXISTS tv_recordings ( id TEXT PRIMARY KEY, user_id TEXT NOT NULL, channel_id TEXT NOT NULL, stream_url TEXT NOT NULL, title TEXT NOT NULL, start_time TEXT NOT NULL, end_time TEXT NOT NULL, status TEXT NOT NULL DEFAULT 'scheduled', file_path TEXT, error_reason TEXT, created_at TEXT DEFAULT CURRENT_TIMESTAMP, updated_at TEXT DEFAULT CURRENT_TIMESTAMP, FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE ) '''); // Season Passes table (enregistrements répétés intelligents) _db.execute(''' CREATE TABLE IF NOT EXISTS season_passes ( id TEXT PRIMARY KEY, user_id TEXT NOT NULL, show_title TEXT NOT NULL, channel_id TEXT NOT NULL, stream_url TEXT NOT NULL, enabled INTEGER DEFAULT 1, match_mode TEXT NOT NULL DEFAULT 'exact', created_at TEXT DEFAULT CURRENT_TIMESTAMP, FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE ) '''); // Indexes _db.execute( 'CREATE INDEX IF NOT EXISTS idx_sessions_token ON sessions(token)', ); _db.execute( 'CREATE INDEX IF NOT EXISTS idx_sessions_expires ON sessions(expires_at)', ); _db.execute( 'CREATE INDEX IF NOT EXISTS idx_playlists_user ON playlists(user_id)', ); _db.execute( 'CREATE INDEX IF NOT EXISTS idx_recordings_status ON tv_recordings(status)', ); _db.execute( 'CREATE INDEX IF NOT EXISTS idx_recordings_user ON tv_recordings(user_id)', ); _db.execute( 'CREATE INDEX IF NOT EXISTS idx_recordings_start ON tv_recordings(start_time)', ); _db.execute( 'CREATE INDEX IF NOT EXISTS idx_season_passes_user ON season_passes(user_id)', ); } /// Seed default admin user if no users exist. /// /// Le mot de passe initial vient de ADMIN_INITIAL_PASSWORD, ou est généré /// aléatoirement et affiché UNE FOIS dans les logs de démarrage. L'ancien /// couple admin/admin restait souvent en place sur les instances exposées. Future seedAdmin() async { final result = _db.select('SELECT COUNT(*) as count FROM users'); final count = result.first['count'] as int; if (count == 0) { final adminId = _uuid.v4(); final envPassword = Platform.environment['ADMIN_INITIAL_PASSWORD']; final generated = envPassword == null || envPassword.isEmpty; final password = generated ? _generatePassword() : envPassword; final passwordHash = PasswordHasher.hash(password); _db.execute( ''' INSERT INTO users (id, username, password_hash, is_admin) VALUES (?, ?, ?, 1) ''', [adminId, 'admin', passwordHash], ); if (generated) { print('╔══════════════════════════════════════════════════════════╗'); print(' Compte admin créé — mot de passe initial (affiché une'); print(' seule fois, changez-le après la première connexion) :'); print(' utilisateur: admin'); print(' mot de passe: $password'); print('╚══════════════════════════════════════════════════════════╝'); } else { print( 'Default admin user created (username: admin, ' 'password: ADMIN_INITIAL_PASSWORD)', ); } } } static String _generatePassword({int length = 16}) { // Sans caractères ambigus (0/O, 1/l/I) : le mot de passe est recopié // depuis les logs du conteneur. const chars = 'abcdefghijkmnopqrstuvwxyzABCDEFGHJKLMNPQRSTUVWXYZ23456789'; final random = Random.secure(); return List.generate( length, (_) => chars[random.nextInt(chars.length)], ).join(); } // ==================== Users ==================== /// Find user by username User? findUserByUsername(String username) { final result = _db.select( 'SELECT * FROM users WHERE username = ?', [username], ); if (result.isEmpty) return null; return User.fromMap(result.first); } /// Find user by ID User? findUserById(String userId) { final result = _db.select( 'SELECT * FROM users WHERE id = ?', [userId], ); if (result.isEmpty) return null; return User.fromMap(result.first); } /// Verify user credentials User? verifyCredentials(String username, String password) { final result = _db.select( 'SELECT * FROM users WHERE username = ?', [username], ); if (result.isEmpty) return null; final passwordHash = result.first['password_hash'] as String; if (!PasswordHasher.verify(password, passwordHash)) { return null; } // Lazy migration: rehash legacy SHA-256 hashes with bcrypt on // successful login (the only moment the plaintext is available). if (PasswordHasher.isLegacy(passwordHash)) { updatePasswordHash( result.first['id'] as String, PasswordHasher.hash(password), ); print('[Auth] Migrated legacy password hash for user: $username'); } return User.fromMap(result.first); } /// Replace a user's stored password hash (used by lazy bcrypt migration) void updatePasswordHash(String userId, String newHash) { _db.execute( 'UPDATE users SET password_hash = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?', [newHash, userId], ); } /// Create new user User createUser(String username, String password, {bool isAdmin = false}) { final userId = _uuid.v4(); final passwordHash = PasswordHasher.hash(password); _db.execute( ''' INSERT INTO users (id, username, password_hash, is_admin) VALUES (?, ?, ?, ?) ''', [userId, username, passwordHash, isAdmin ? 1 : 0], ); return User( id: userId, username: username, isAdmin: isAdmin, createdAt: DateTime.now(), ); } /// Get all users List getAllUsers() { final result = _db.select('SELECT * FROM users ORDER BY username ASC'); return result.map((row) => User.fromMap(row)).toList(); } /// Update user password void updateUserPassword(String userId, String newPassword) { final passwordHash = PasswordHasher.hash(newPassword); _db.execute( 'UPDATE users SET password_hash = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?', [passwordHash, userId], ); } /// Update user administration status void updateUserAdminStatus(String userId, bool isAdmin) { _db.execute( 'UPDATE users SET is_admin = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?', [isAdmin ? 1 : 0, userId], ); } /// Delete user void deleteUser(String userId) { _db.execute('DELETE FROM users WHERE id = ?', [userId]); } // ==================== Sessions ==================== /// Create new session models.Session createSession(String userId, {Duration? duration}) { final sessionId = _uuid.v4(); final token = _uuid.v4(); final expiresAt = DateTime.now().add(duration ?? const Duration(days: 7)); _db.execute( ''' INSERT INTO sessions (id, user_id, token, expires_at) VALUES (?, ?, ?, ?) ''', [sessionId, userId, token, expiresAt.toIso8601String()], ); return models.Session( id: sessionId, userId: userId, token: token, expiresAt: expiresAt, createdAt: DateTime.now(), ); } /// Find session by token models.Session? findSessionByToken(String token) { final result = _db.select( 'SELECT * FROM sessions WHERE token = ?', [token], ); if (result.isEmpty) return null; final session = models.Session.fromMap(result.first); // Check if expired if (session.isExpired) { deleteSession(token); return null; } return session; } /// Delete session (logout) void deleteSession(String token) { _db.execute('DELETE FROM sessions WHERE token = ?', [token]); } /// Clean expired sessions void cleanExpiredSessions() { _db.execute( 'DELETE FROM sessions WHERE expires_at < ?', [DateTime.now().toIso8601String()], ); } // ==================== Playlists ==================== /// Get all playlists for a user List getPlaylists(String userId) { final result = _db.select( 'SELECT * FROM playlists WHERE user_id = ? ORDER BY created_at DESC', [userId], ); return result.map((row) => Playlist.fromMap(row)).toList(); } /// Get playlist by ID Playlist? getPlaylistById(String playlistId) { final result = _db.select( 'SELECT * FROM playlists WHERE id = ?', [playlistId], ); if (result.isEmpty) return null; return Playlist.fromMap(result.first); } /// Create new playlist Playlist createPlaylist({ required String userId, required String name, required String serverUrl, required String username, required String password, String? dns, }) { final playlistId = _uuid.v4(); final now = DateTime.now().toIso8601String(); _db.execute( ''' INSERT INTO playlists (id, user_id, name, server_url, username, password, dns, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) ''', [playlistId, userId, name, serverUrl, username, password, dns, now, now], ); return Playlist( id: playlistId, userId: userId, name: name, serverUrl: serverUrl, username: username, password: password, dns: dns, createdAt: DateTime.parse(now), updatedAt: DateTime.parse(now), ); } /// Update playlist Playlist updatePlaylist({ required String playlistId, required String name, required String serverUrl, required String username, required String password, String? dns, }) { final now = DateTime.now().toIso8601String(); _db.execute( ''' UPDATE playlists SET name = ?, server_url = ?, username = ?, password = ?, dns = ?, updated_at = ? WHERE id = ? ''', [name, serverUrl, username, password, dns, now, playlistId], ); return getPlaylistById(playlistId)!; } /// Delete playlist void deletePlaylist(String playlistId) { _db.execute('DELETE FROM playlists WHERE id = ?', [playlistId]); } // ==================== User Settings ==================== /// Get user settings as JSON string String? getUserSettings(String userId) { final result = _db.select( 'SELECT settings_json FROM user_settings WHERE user_id = ?', [userId], ); if (result.isEmpty) return null; return result.first['settings_json'] as String; } /// Update user settings void updateUserSettings(String userId, String settingsJson) { final now = DateTime.now().toIso8601String(); // UPSERT (Insert or Replace) _db.execute( ''' INSERT INTO user_settings (user_id, settings_json, updated_at) VALUES (?, ?, ?) ON CONFLICT(user_id) DO UPDATE SET settings_json = excluded.settings_json, updated_at = excluded.updated_at ''', [userId, settingsJson, now], ); } /// Check if NVIDIA GPU is enabled in any user's settings /// This is a server-wide setting that applies to all streaming bool isNvidiaGpuEnabled() { try { // Check all user settings for enable_nvidia_gpu final result = _db.select( 'SELECT settings_json FROM user_settings ORDER BY updated_at DESC LIMIT 1', ); if (result.isEmpty) { print('[GPU] No user settings found in database'); return false; } final settingsJson = result.first['settings_json'] as String; print('[GPU] Settings found: $settingsJson'); // Parse JSON properly to check the setting // Handle both "enable_nvidia_gpu":true and "enable_nvidia_gpu": true final RegExp gpuRegex = RegExp(r'"enable_nvidia_gpu"\s*:\s*true', caseSensitive: false); final isEnabled = gpuRegex.hasMatch(settingsJson); print('[GPU] NVIDIA GPU enabled: $isEnabled'); return isEnabled; } catch (e) { print('[GPU] Error checking GPU setting: $e'); } return false; } // ==================== TV Recordings ==================== /// Créer un nouvel enregistrement Recording createRecording({ required String userId, required String channelId, required String streamUrl, required String title, required DateTime startTime, required DateTime endTime, }) { final recordingId = _uuid.v4(); final now = DateTime.now().toIso8601String(); // Toujours stocker en UTC avec suffixe 'Z' : les comparaisons du scheduler // et la déduplication des season passes reposent sur ce format unique. final startUtc = startTime.toUtc(); final endUtc = endTime.toUtc(); _db.execute( ''' INSERT INTO tv_recordings (id, user_id, channel_id, stream_url, title, start_time, end_time, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) ''', [ recordingId, userId, channelId, streamUrl, title, startUtc.toIso8601String(), endUtc.toIso8601String(), now, now, ], ); return Recording( id: recordingId, userId: userId, channelId: channelId, streamUrl: streamUrl, title: title, startTime: startUtc, endTime: endUtc, status: 'scheduled', createdAt: DateTime.parse(now), updatedAt: DateTime.parse(now), ); } /// Lister tous les enregistrements (pour l'admin) List getAllRecordings() { final result = _db.select('SELECT * FROM tv_recordings ORDER BY start_time ASC'); return result.map((row) => Recording.fromMap(row)).toList(); } /// Enregistrements qui intéressent le scheduler : à lancer ou en cours. /// Évite de désérialiser tout l'historique toutes les 10 secondes. List getPendingRecordings() { final result = _db.select( "SELECT * FROM tv_recordings WHERE status IN ('scheduled', 'recording') " 'ORDER BY start_time ASC', ); return result.map((row) => Recording.fromMap(row)).toList(); } /// Enregistrements terminés (completed/failed/cancelled) du plus ancien au /// plus récent — utilisé par la rotation disque. List getFinishedRecordingsOldestFirst() { final result = _db.select( "SELECT * FROM tv_recordings WHERE status IN ('completed', 'failed', 'cancelled') " 'ORDER BY start_time ASC', ); return result.map((row) => Recording.fromMap(row)).toList(); } /// Lister les enregistrements d'un utilisateur spécifique List getUserRecordings(String userId) { final result = _db.select( 'SELECT * FROM tv_recordings WHERE user_id = ? ORDER BY start_time ASC', [userId], ); return result.map((row) => Recording.fromMap(row)).toList(); } /// Récupérer un enregistrement par ID Recording? getRecordingById(String id) { final result = _db.select('SELECT * FROM tv_recordings WHERE id = ?', [id]); if (result.isEmpty) return null; return Recording.fromMap(result.first); } /// Mettre à jour le statut et éventuellement le chemin d'un enregistrement /// /// [clearError] efface le motif d'erreur au lieu de conserver l'ancien : /// utile quand un enregistrement précédemment en échec est relancé. void updateRecordingStatus( String id, String status, { String? filePath, String? errorReason, bool clearError = false, }) { final now = DateTime.now().toIso8601String(); final errorExpr = clearError ? '?' : 'COALESCE(?, error_reason)'; _db.execute( ''' UPDATE tv_recordings SET status = ?, file_path = COALESCE(?, file_path), error_reason = $errorExpr, updated_at = ? WHERE id = ? ''', [status, filePath, errorReason, now, id], ); } /// Réaligner la fenêtre d'un enregistrement planifié (programme déplacé /// dans l'EPG depuis sa création par un season pass). void updateRecordingWindow(String id, DateTime start, DateTime end) { _db.execute( ''' UPDATE tv_recordings SET start_time = ?, end_time = ?, updated_at = ? WHERE id = ? ''', [ start.toUtc().toIso8601String(), end.toUtc().toIso8601String(), DateTime.now().toIso8601String(), id, ], ); } /// Supprimer un enregistrement depuis la BDD (ne supprime pas le fichier) void deleteRecording(String id) { _db.execute('DELETE FROM tv_recordings WHERE id = ?', [id]); } // ==================== Season Passes ==================== /// Créer un Season Pass (enregistrement répété par titre d'émission) Map createSeasonPass({ required String userId, required String showTitle, required String channelId, required String streamUrl, String matchMode = 'exact', }) { final id = _uuid.v4(); final now = DateTime.now().toIso8601String(); _db.execute( 'INSERT INTO season_passes (id, user_id, show_title, channel_id, stream_url, match_mode, created_at) VALUES (?, ?, ?, ?, ?, ?, ?)', [id, userId, showTitle, channelId, streamUrl, matchMode, now], ); return { 'id': id, 'user_id': userId, 'show_title': showTitle, 'channel_id': channelId, 'stream_url': streamUrl, 'match_mode': matchMode, 'enabled': 1, 'created_at': now, }; } /// Lister les Season Passes actifs (pour le scheduler) List> getAllSeasonPasses() { final result = _db.select( 'SELECT * FROM season_passes WHERE enabled = 1 ORDER BY created_at DESC', ); return result.map((r) => Map.from(r)).toList(); } /// Lister les Season Passes d'un utilisateur (actifs ou non, pour l'API) List> getSeasonPassesForUser(String userId) { final result = _db.select( 'SELECT * FROM season_passes WHERE user_id = ? ORDER BY created_at DESC', [userId], ); return result.map((r) => Map.from(r)).toList(); } /// Récupérer un Season Pass par id (contrôle de propriété côté API) Map? getSeasonPassById(String id) { final result = _db.select('SELECT * FROM season_passes WHERE id = ?', [id]); if (result.isEmpty) return null; return Map.from(result.first); } /// Supprimer un Season Pass void deleteSeasonPass(String id) { _db.execute('DELETE FROM season_passes WHERE id = ?', [id]); } /// Cherche un enregistrement existant pour cet épisode (déduplication des /// season passes) : même titre, début à ±[tolerance] près. /// /// La comparaison par plage remplace l'ancienne égalité de chaîne, qui /// échouait dès que le format stocké différait (avec/sans 'Z') ou que le /// panneau décalait le programme de quelques secondes — l'épisode était /// alors réenregistré en double. Recording? findRecordingForEpisode( String title, DateTime startTime, { Duration tolerance = const Duration(minutes: 2), }) { final startUtc = startTime.toUtc(); // Les dates sont stockées en ISO-8601 UTC : l'ordre lexicographique // correspond à l'ordre chronologique, un BETWEEN sur chaînes suffit. final result = _db.select( '''SELECT * FROM tv_recordings WHERE LOWER(title) = LOWER(?) AND start_time BETWEEN ? AND ? AND status NOT IN ('failed', 'cancelled') LIMIT 1''', [ title, startUtc.subtract(tolerance).toIso8601String(), startUtc.add(tolerance).toIso8601String(), ], ); if (result.isEmpty) return null; return Recording.fromMap(result.first); } /// Close database connection void close() { _db.dispose(); } }