Files
xtremflow/bin/api/recordings_api.dart
T
Claude 1d8717bb40 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
2026-08-27 12:11:56 +00:00

261 lines
9.0 KiB
Dart

import 'dart:io';
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';
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 _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 _json(404, {'error': 'Aucun fichier ni log associé pour le moment.'});
}
// Les enregistrements sont écrits en .mkv avec un .log à côté
// (l'ancien replaceAll('.mp4', '.log') ne trouvait jamais le fichier).
final logFilePath = p.setExtension(recording.filePath!, '.log');
// Anti path-traversal : le log doit rester dans le dossier des enregistrements
final safeLogPath = SafePath.resolveWithin(recordingsDirPath, logFilePath);
if (safeLogPath == null) {
return _json(403, {'error': 'Chemin de log invalide'});
}
final logFile = File(safeLogPath);
if (!await logFile.exists()) {
return _json(404, {'error': 'Le fichier de log est introuvable.'});
}
final logs = await logFile.readAsString();
return _json(200, {'logs': logs});
}
/// GET /api/recordings — Liste les enregistrements de l'utilisateur
/// (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) => _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 {
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'});
}
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: channelId,
streamUrl: streamUrl,
title: data['title']?.toString() ?? 'Sans Titre',
startTime: startTime,
endTime: endTime,
);
return Response.ok(
recording.toJson(),
headers: {'Content-Type': 'application/json'},
);
} catch (e) {
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.
/// 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 _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 _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 _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 _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é'});
}
}