mirror of
https://github.com/R0m1k3/xtremflow.git
synced 2026-10-12 01:36:20 +02:00
796 lines
24 KiB
Dart
796 lines
24 KiB
Dart
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<void> 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 = <int, void Function()>{
|
|
// 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<void> _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<void> 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<User> 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<Playlist> 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<Recording> 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<Recording> 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<Recording> 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<Recording> 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<String, dynamic> 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<Map<String, dynamic>> getAllSeasonPasses() {
|
|
final result = _db.select(
|
|
'SELECT * FROM season_passes WHERE enabled = 1 ORDER BY created_at DESC',
|
|
);
|
|
return result.map((r) => Map<String, dynamic>.from(r)).toList();
|
|
}
|
|
|
|
/// Lister les Season Passes d'un utilisateur (actifs ou non, pour l'API)
|
|
List<Map<String, dynamic>> 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<String, dynamic>.from(r)).toList();
|
|
}
|
|
|
|
/// Récupérer un Season Pass par id (contrôle de propriété côté API)
|
|
Map<String, dynamic>? getSeasonPassById(String id) {
|
|
final result =
|
|
_db.select('SELECT * FROM season_passes WHERE id = ?', [id]);
|
|
if (result.isEmpty) return null;
|
|
return Map<String, dynamic>.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();
|
|
}
|
|
}
|