mirror of
https://github.com/R0m1k3/xtremflow.git
synced 2026-10-11 17:30:00 +02:00
Fiabilise le système d'enregistrement de bout en bout
Fuseaux horaires : - POST /api/recordings rejette en 400 les dates sans fuseau et normalise tout en UTC à l'écriture (l'interprétation des dates naïves dans le TZ du conteneur décalait les enregistrements de 1-2 h) - Helper frontend unique postRecording() : les 2 points de création (modal, guide EPG) envoient la même convention UTC Contrôle d'accès : - stop/delete/logs d'un enregistrement et delete d'un season pass vérifient la propriété (userId ou admin), comme playlists_handler - Suppression des replis 'dev_user_id' et 'admin' (401 sans session) SQLite : - PRAGMA foreign_keys/WAL/busy_timeout (les ON DELETE CASCADE déclarés ne s'appliquaient pas : sessions et playlists orphelines) - Migrations de schéma versionnées (schema_version) + index user_id, start_time, season_passes(user_id) Gestion disque : - Refus explicite de capture sous MIN_FREE_DISK_MB (défaut 500 Mo) - Rotation par quota d'octets (RECORDINGS_QUOTA_GB, opt-in) qui ne touche jamais un enregistrement actif et supprime fichiers + ligne ensemble ; l'ancienne rotation « 50 fichiers » pouvait effacer une capture en cours - DELETE /api/recordings/<id> supprime aussi .mkv/.log/parties (SafePath) Scheduler : - Arrêt gracieux orchestré par server.dart : clôture des enregistrements (fusion des parties, statut) avant killAll des sessions de streaming ; l'ancien handler SIGTERM de FfmpegSessionManager faisait exit(0) direct - Noms de fichiers uniques par fragment d'id (deux enregistrements du même programme s'écrasaient mutuellement avec -y) - Requête filtrée scheduled/recording au lieu de toute la table / 10 s - Statut cancelled pour un scheduled arrêté (completed sans fichier cassait la lecture) Season passes : - Playlist du propriétaire du pass résolue à chaque scan (l'injection figée du 1er utilisateur rendait les passes muets après ajout de playlist, et mélangeait les credentials en multi-utilisateurs) - Correspondance exacte par défaut (match_mode, migration en 'contains' pour l'existant), plafond de créations par scan, réalignement des horaires déplacés dans l'EPG, déduplication tolérante ±2 min - Redaction des erreurs de scan (ClientException contient l'URL amont) API de suivi (polling conservé) : - GET /api/recordings enrichi : progress_pct, file_size_bytes, retry_count, is_active ; barre de progression + taille dans la liste Validé : dart analyze (0 issue) et dart test (48/48) sur bin/. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015oEu9QayWsw7hCKhenxgVa
This commit is contained in:
10 files changed
+740
-187
No files matched your search
+168
-60
@@ -3,6 +3,7 @@ import 'dart:convert';
|
||||
import 'package:path/path.dart' as p;
|
||||
import 'package:shelf/shelf.dart';
|
||||
import '../database/database.dart';
|
||||
import '../models/recording.dart';
|
||||
import '../models/user.dart';
|
||||
import '../services/recording_scheduler.dart';
|
||||
import '../utils/safe_path.dart';
|
||||
@@ -11,25 +12,43 @@ class RecordingsApi {
|
||||
final AppDatabase _db;
|
||||
final RecordingScheduler _scheduler;
|
||||
|
||||
/// Durée maximale d'un enregistrement (env MAX_RECORDING_HOURS).
|
||||
final int maxRecordingHours = int.tryParse(
|
||||
Platform.environment['MAX_RECORDING_HOURS'] ?? '',
|
||||
) ??
|
||||
12;
|
||||
|
||||
RecordingsApi(this._db, this._scheduler);
|
||||
|
||||
Response _json(int status, Map<String, dynamic> body) => Response(
|
||||
status,
|
||||
body: json.encode(body),
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
);
|
||||
|
||||
/// L'utilisateur courant peut-il agir sur cet enregistrement ?
|
||||
/// (même patron de contrôle de propriété que playlists_handler)
|
||||
bool _canAccess(User? user, Recording recording) {
|
||||
if (user == null) return false;
|
||||
return user.isAdmin || recording.userId == user.id;
|
||||
}
|
||||
|
||||
/// Handler pour GET /api/recordings/logs/<id>
|
||||
/// Exposé séparément car shelf_router a un conflit entre DELETE /<id> et GET /logs/<id>
|
||||
Future<Response> getLogHandler(Request request, String id) async {
|
||||
final recording = _db.getRecordingById(id);
|
||||
|
||||
|
||||
if (recording == null) {
|
||||
return Response.notFound(
|
||||
json.encode({'error': 'Enregistrement non trouvé'}),
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
);
|
||||
return _json(404, {'error': 'Enregistrement non trouvé'});
|
||||
}
|
||||
|
||||
final user = request.context['user'] as User?;
|
||||
if (!_canAccess(user, recording)) {
|
||||
return _json(403, {'error': 'Accès refusé'});
|
||||
}
|
||||
|
||||
if (recording.filePath == null) {
|
||||
return Response.notFound(
|
||||
json.encode({'error': 'Aucun fichier ni log associé pour le moment.'}),
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
);
|
||||
return _json(404, {'error': 'Aucun fichier ni log associé pour le moment.'});
|
||||
}
|
||||
|
||||
// Les enregistrements sont écrits en .mkv avec un .log à côté
|
||||
@@ -39,55 +58,115 @@ class RecordingsApi {
|
||||
// Anti path-traversal : le log doit rester dans le dossier des enregistrements
|
||||
final safeLogPath = SafePath.resolveWithin(recordingsDirPath, logFilePath);
|
||||
if (safeLogPath == null) {
|
||||
return Response.forbidden(
|
||||
json.encode({'error': 'Chemin de log invalide'}),
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
);
|
||||
return _json(403, {'error': 'Chemin de log invalide'});
|
||||
}
|
||||
|
||||
final logFile = File(safeLogPath);
|
||||
|
||||
if (!await logFile.exists()) {
|
||||
return Response.notFound(
|
||||
json.encode({'error': 'Le fichier de log est introuvable.'}),
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
);
|
||||
return _json(404, {'error': 'Le fichier de log est introuvable.'});
|
||||
}
|
||||
|
||||
final logs = await logFile.readAsString();
|
||||
return Response.ok(
|
||||
json.encode({'logs': logs}),
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
);
|
||||
return _json(200, {'logs': logs});
|
||||
}
|
||||
|
||||
/// GET /api/recordings — Liste les enregistrements de l'utilisateur
|
||||
/// (tous les enregistrements pour un admin)
|
||||
/// (tous les enregistrements pour un admin), enrichis des informations de
|
||||
/// suivi : taille du fichier, progression, relances FFmpeg.
|
||||
Response handleGetAll(Request request) {
|
||||
final user = request.context['user'] as User?;
|
||||
final recordings = (user != null && !user.isAdmin)
|
||||
? _db.getUserRecordings(user.id)
|
||||
: _db.getAllRecordings();
|
||||
final now = DateTime.now().toUtc();
|
||||
return Response.ok(
|
||||
json.encode(recordings.map((r) => r.toMap()).toList()),
|
||||
json.encode(recordings.map((r) => _enrich(r, now)).toList()),
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> _enrich(Recording r, DateTime now) {
|
||||
final map = r.toMap();
|
||||
|
||||
if (r.status == 'recording') {
|
||||
final start = r.startTime.toUtc();
|
||||
final end = r.endTime.toUtc();
|
||||
final total = end.difference(start).inSeconds;
|
||||
if (total > 0) {
|
||||
final elapsed = now.difference(start).inSeconds;
|
||||
map['progress_pct'] =
|
||||
(elapsed * 100 / total).clamp(0, 100).round();
|
||||
}
|
||||
map['is_active'] = _scheduler.isCapturing(r.id);
|
||||
final retries = _scheduler.retryCountOf(r.id);
|
||||
if (retries != null) map['retry_count'] = retries;
|
||||
}
|
||||
|
||||
final path = r.filePath;
|
||||
if (path != null) {
|
||||
try {
|
||||
final file = File(path);
|
||||
if (file.existsSync()) map['file_size_bytes'] = file.lengthSync();
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
return map;
|
||||
}
|
||||
|
||||
/// POST /api/recordings — Planifie un nouvel enregistrement
|
||||
Future<Response> handlePost(Request request) async {
|
||||
try {
|
||||
final payload = await request.readAsString();
|
||||
final data = json.decode(payload);
|
||||
final user = request.context['user'] as User?;
|
||||
final userId = user?.id ?? request.context['userId'] as String?;
|
||||
if (userId == null) {
|
||||
return _json(401, {'error': 'Authentification requise'});
|
||||
}
|
||||
|
||||
final userId = request.context['userId'] as String? ?? 'dev_user_id';
|
||||
Map<String, dynamic> data;
|
||||
try {
|
||||
data = json.decode(await request.readAsString()) as Map<String, dynamic>;
|
||||
} catch (_) {
|
||||
return _json(400, {'error': 'Corps JSON invalide'});
|
||||
}
|
||||
|
||||
final channelId = data['channel_id']?.toString() ?? '';
|
||||
final streamUrl = data['stream_url']?.toString() ?? '';
|
||||
if (channelId.isEmpty) {
|
||||
return _json(400, {'error': 'channel_id est requis'});
|
||||
}
|
||||
if (streamUrl.isEmpty) {
|
||||
return _json(400, {'error': 'stream_url est requis'});
|
||||
}
|
||||
|
||||
final startTime = _parseZonedDate(data['start_time']);
|
||||
final endTime = _parseZonedDate(data['end_time']);
|
||||
if (startTime == null || endTime == null) {
|
||||
// Une date sans indicateur de fuseau ('Z' ou ±hh:mm) est ambiguë :
|
||||
// l'interpréter dans le fuseau du serveur décale l'enregistrement
|
||||
// de plusieurs heures selon le TZ du conteneur.
|
||||
return _json(400, {
|
||||
'error':
|
||||
'start_time et end_time doivent être des dates ISO-8601 avec fuseau '
|
||||
'(ex: 2026-08-27T21:00:00Z)',
|
||||
});
|
||||
}
|
||||
if (!endTime.isAfter(startTime)) {
|
||||
return _json(400, {'error': 'end_time doit être après start_time'});
|
||||
}
|
||||
if (endTime.difference(startTime) > Duration(hours: maxRecordingHours)) {
|
||||
return _json(400, {
|
||||
'error': 'Durée maximale dépassée ($maxRecordingHours h)',
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
final recording = _db.createRecording(
|
||||
userId: userId,
|
||||
channelId: data['channel_id'],
|
||||
streamUrl: data['stream_url'],
|
||||
title: data['title'] ?? 'Sans Titre',
|
||||
startTime: DateTime.parse(data['start_time']),
|
||||
endTime: DateTime.parse(data['end_time']),
|
||||
channelId: channelId,
|
||||
streamUrl: streamUrl,
|
||||
title: data['title']?.toString() ?? 'Sans Titre',
|
||||
startTime: startTime,
|
||||
endTime: endTime,
|
||||
);
|
||||
|
||||
return Response.ok(
|
||||
@@ -95,58 +174,87 @@ class RecordingsApi {
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
);
|
||||
} catch (e) {
|
||||
return Response.internalServerError(
|
||||
body: json.encode({'error': 'Erreur lors de la programmation: $e'}),
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
);
|
||||
print('[RecordingsApi] Erreur à la création: $e');
|
||||
return _json(500, {'error': 'Erreur lors de la programmation'});
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse une date ISO-8601 en exigeant un indicateur de fuseau, et la
|
||||
/// normalise en UTC. Retourne null si absente, invalide ou naïve.
|
||||
DateTime? _parseZonedDate(dynamic raw) {
|
||||
final str = raw?.toString() ?? '';
|
||||
if (str.isEmpty) return null;
|
||||
// 'Z' final ou offset ±hh[:mm] après l'heure
|
||||
final hasZone =
|
||||
str.endsWith('Z') || RegExp(r'[+-]\d{2}:?\d{2}$').hasMatch(str);
|
||||
if (!hasZone) return null;
|
||||
return DateTime.tryParse(str)?.toUtc();
|
||||
}
|
||||
|
||||
/// DELETE /api/recordings/<id> — Annule ou supprime un enregistrement
|
||||
/// Si un enregistrement FFmpeg est actif, il est arrêté avant la suppression
|
||||
/// Si un enregistrement FFmpeg est actif, il est arrêté avant la suppression.
|
||||
/// Les fichiers associés (.mkv, .log, parties) sont supprimés avec la ligne.
|
||||
Future<Response> handleDelete(Request request, String id) async {
|
||||
final recording = _db.getRecordingById(id);
|
||||
|
||||
if (recording == null) {
|
||||
return Response.notFound(
|
||||
json.encode({'error': 'Enregistrement non trouvé'}),
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
);
|
||||
return _json(404, {'error': 'Enregistrement non trouvé'});
|
||||
}
|
||||
|
||||
final user = request.context['user'] as User?;
|
||||
if (!_canAccess(user, recording)) {
|
||||
return _json(403, {'error': 'Accès refusé'});
|
||||
}
|
||||
|
||||
// Tuer FFmpeg si cet enregistrement est en cours AVANT de supprimer de la DB
|
||||
await _scheduler.stopRecording(id);
|
||||
|
||||
// Supprimer les fichiers pour ne pas laisser d'orphelins sur le volume,
|
||||
// en restant confiné au dossier des enregistrements.
|
||||
final path = recording.filePath;
|
||||
if (path != null) {
|
||||
final safePath = SafePath.resolveWithin(recordingsDirPath, path);
|
||||
if (safePath != null) {
|
||||
await _scheduler.deleteRecordingFiles(safePath);
|
||||
}
|
||||
}
|
||||
|
||||
_db.deleteRecording(id);
|
||||
|
||||
return Response.ok(
|
||||
json.encode({'message': 'Enregistrement supprimé avec succès'}),
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
);
|
||||
return _json(200, {'message': 'Enregistrement supprimé avec succès'});
|
||||
}
|
||||
|
||||
/// POST /api/recordings/stop/<id> — Arrête un enregistrement FFmpeg en cours
|
||||
Future<Response> handleStop(Request request, String id) async {
|
||||
final recording = _db.getRecordingById(id);
|
||||
if (recording == null) {
|
||||
return Response.notFound(
|
||||
json.encode({'error': 'Enregistrement non trouvé'}),
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
);
|
||||
return _json(404, {'error': 'Enregistrement non trouvé'});
|
||||
}
|
||||
|
||||
final user = request.context['user'] as User?;
|
||||
if (!_canAccess(user, recording)) {
|
||||
return _json(403, {'error': 'Accès refusé'});
|
||||
}
|
||||
|
||||
final stopped = await _scheduler.stopRecording(id);
|
||||
if (stopped) {
|
||||
return Response.ok(
|
||||
json.encode({'message': 'Enregistrement arrêté'}),
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
);
|
||||
} else {
|
||||
// Pas de processus FFmpeg actif pour cet ID → marquer comme complété quand même
|
||||
_db.updateRecordingStatus(id, 'completed');
|
||||
return Response.ok(
|
||||
json.encode({'message': 'Enregistrement marqué comme terminé'}),
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
);
|
||||
return _json(200, {'message': 'Enregistrement arrêté'});
|
||||
}
|
||||
|
||||
if (recording.status == 'scheduled') {
|
||||
// Rien n'a encore été capturé : annulé, pas « terminé ». Marquer
|
||||
// completed sans fichier faisait ensuite échouer la lecture.
|
||||
_db.updateRecordingStatus(id, 'cancelled');
|
||||
return _json(200, {'message': 'Enregistrement annulé'});
|
||||
}
|
||||
|
||||
if (recording.status == 'recording') {
|
||||
// Statut « recording » sans processus actif (orphelin) : clôturer.
|
||||
_db.updateRecordingStatus(id, 'completed');
|
||||
return _json(200, {'message': 'Enregistrement marqué comme terminé'});
|
||||
}
|
||||
|
||||
// Déjà completed/failed/cancelled : ne pas écraser le statut final.
|
||||
return _json(200, {'message': 'Enregistrement déjà clôturé'});
|
||||
}
|
||||
}
|
||||
@@ -9,17 +9,29 @@ class SeasonPassesApi {
|
||||
|
||||
SeasonPassesApi(this._db);
|
||||
|
||||
/// GET /api/season-passes — liste tous les season passes
|
||||
/// GET /api/season-passes — liste les season passes de l'utilisateur
|
||||
/// (tous les passes pour un admin)
|
||||
Response handleGetAll(Request request) {
|
||||
try {
|
||||
final passes = _db.getAllSeasonPasses();
|
||||
final user = request.context['user'] as User?;
|
||||
if (user == null) {
|
||||
return Response(
|
||||
401,
|
||||
body: json.encode({'error': 'Authentification requise'}),
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
);
|
||||
}
|
||||
final passes = user.isAdmin
|
||||
? _db.getAllSeasonPasses()
|
||||
: _db.getSeasonPassesForUser(user.id);
|
||||
return Response.ok(
|
||||
json.encode(passes),
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
);
|
||||
} catch (e) {
|
||||
print('[SeasonPass] Erreur au listage: $e');
|
||||
return Response.internalServerError(
|
||||
body: json.encode({'error': 'Erreur: $e'}),
|
||||
body: json.encode({'error': 'Erreur interne'}),
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
);
|
||||
}
|
||||
@@ -59,10 +71,20 @@ class SeasonPassesApi {
|
||||
|
||||
// Récupérer l'utilisateur depuis le contexte
|
||||
final user = request.context['user'] as User?;
|
||||
final userId = user?.id ?? 'admin'; // fallback
|
||||
if (user == null) {
|
||||
return Response(
|
||||
401,
|
||||
body: json.encode({'error': 'Authentification requise'}),
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
);
|
||||
}
|
||||
|
||||
// Vérifier si un season pass identique existe déjà
|
||||
final existing = _db.getAllSeasonPasses();
|
||||
// 'exact' par défaut : « Journal » ne doit pas capturer tous les
|
||||
// programmes qui contiennent le mot. 'contains' reste disponible.
|
||||
final matchMode = data['match_mode'] == 'contains' ? 'contains' : 'exact';
|
||||
|
||||
// Vérifier si un season pass identique existe déjà pour cet utilisateur
|
||||
final existing = _db.getSeasonPassesForUser(user.id);
|
||||
final duplicate = existing.any(
|
||||
(p) =>
|
||||
(p['show_title'] as String).toLowerCase() ==
|
||||
@@ -78,10 +100,11 @@ class SeasonPassesApi {
|
||||
}
|
||||
|
||||
final pass = _db.createSeasonPass(
|
||||
userId: userId,
|
||||
userId: user.id,
|
||||
showTitle: showTitle,
|
||||
channelId: channelId,
|
||||
streamUrl: streamUrl,
|
||||
matchMode: matchMode,
|
||||
);
|
||||
|
||||
print('[SeasonPass] Créé: "$showTitle" sur chaîne $channelId');
|
||||
@@ -101,14 +124,30 @@ class SeasonPassesApi {
|
||||
/// DELETE /api/season-passes/<id> — supprimer un season pass
|
||||
Response handleDelete(Request request, String id) {
|
||||
try {
|
||||
final user = request.context['user'] as User?;
|
||||
final pass = _db.getSeasonPassById(id);
|
||||
if (pass == null) {
|
||||
return Response.notFound(
|
||||
json.encode({'error': 'Season Pass non trouvé'}),
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
);
|
||||
}
|
||||
// Contrôle de propriété : seul le propriétaire ou un admin supprime.
|
||||
if (user == null || (!user.isAdmin && pass['user_id'] != user.id)) {
|
||||
return Response.forbidden(
|
||||
json.encode({'error': 'Accès refusé'}),
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
);
|
||||
}
|
||||
_db.deleteSeasonPass(id);
|
||||
return Response.ok(
|
||||
json.encode({'message': 'Season Pass supprimé'}),
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
);
|
||||
} catch (e) {
|
||||
print('[SeasonPass] Erreur à la suppression: $e');
|
||||
return Response.internalServerError(
|
||||
body: json.encode({'error': 'Erreur: $e'}),
|
||||
body: json.encode({'error': 'Erreur interne'}),
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
);
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user