mirror of
https://github.com/R0m1k3/xtremflow.git
synced 2026-10-11 17:30:00 +02:00
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
261 lines
9.0 KiB
Dart
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é'});
|
|
}
|
|
}
|