Files
xtremflow/bin/api/recordings_api.dart
T
Claude f798b43a1e fix(enregistrements): un enregistrement mené à terme n'est plus marqué « échoué »
Chaque enregistrement arrivé au bout de sa fenêtre était marqué
« Échoué / Interruption inattendue du serveur » alors que le fichier était
bien sur le disque : le tick lisait la base AVANT d'arrêter les captures
terminées, si bien que l'instantané annonçait encore « recording » pour un
enregistrement déjà retiré de la table des processus actifs — la détection
d'orphelin le requalifiait aussitôt en échec. La base est désormais lue
après les arrêts, et la requalification revérifie le statut courant.

Deux autres façons de perdre un enregistrement sont corrigées au passage :

- FFmpeg livre encore des morceaux de stderr après la résolution de
  `exitCode` ; écrire sur l'`IOSink` déjà fermé levait une `StateError`
  depuis un callback de stream, donc une erreur asynchrone non rattrapée
  qui tue l'isolate — et avec lui le serveur et toutes les captures en
  cours. Le log passe par un écrivain tolérant et n'est fermé qu'une fois
  stdout et stderr drainés.
- Une coupure amont terminait la capture définitivement. FFmpeg reçoit
  maintenant les options de reconnexion (comme le proxy live), et le
  planificateur relance la capture sur la fin de fenêtre quand le process
  sort trop tôt (backoff 3→30 s, quota remis à zéro après une capture
  saine). Les parties issues des relances sont recollées dans le fichier
  principal via le demuxer `concat`, la lecture reste donc un seul fichier.

Également :
- reprise des captures interrompues par un redémarrage du conteneur tant
  que la fenêtre est ouverte, au lieu d'un échec sec ; fichier partiel
  conservé (statut « terminé ») quand la fenêtre est passée
- `-t` calculé sur le temps restant jusqu'à la fin programmée : un
  démarrage tardif ne rogne plus la fin du programme
- `-hide_banner -nostats` : le log d'enregistrement redevient lisible (et
  ne pèse plus des mégaoctets, il est relu en entier par l'API)
- `RECORDINGS_DIR` et `FFMPEG_PATH` surchargeables, et le motif d'erreur
  est effacé au (re)démarrage d'une capture

Vérifié avec un faux ffmpeg sur un planificateur réel : avant correctif,
un enregistrement mené jusqu'à la fin de fenêtre ressort « failed /
Interruption inattendue du serveur » ; après, « completed » — de même que
la reprise après interruption, la relance après coupure et la fusion des
parties.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E5xWYTbtZwJuYB3E51K243
2026-08-26 19:09:31 +00:00

153 lines
5.1 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/user.dart';
import '../services/recording_scheduler.dart';
import '../utils/safe_path.dart';
class RecordingsApi {
final AppDatabase _db;
final RecordingScheduler _scheduler;
RecordingsApi(this._db, this._scheduler);
/// 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'},
);
}
if (recording.filePath == null) {
return Response.notFound(
json.encode({'error': 'Aucun fichier ni log associé pour le moment.'}),
headers: {'Content-Type': 'application/json'},
);
}
// 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 Response.forbidden(
json.encode({'error': 'Chemin de log invalide'}),
headers: {'Content-Type': 'application/json'},
);
}
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'},
);
}
final logs = await logFile.readAsString();
return Response.ok(
json.encode({'logs': logs}),
headers: {'Content-Type': 'application/json'},
);
}
/// GET /api/recordings — Liste les enregistrements de l'utilisateur
/// (tous les enregistrements pour un admin)
Response handleGetAll(Request request) {
final user = request.context['user'] as User?;
final recordings = (user != null && !user.isAdmin)
? _db.getUserRecordings(user.id)
: _db.getAllRecordings();
return Response.ok(
json.encode(recordings.map((r) => r.toMap()).toList()),
headers: {'Content-Type': 'application/json'},
);
}
/// 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 userId = request.context['userId'] as String? ?? 'dev_user_id';
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']),
);
return Response.ok(
recording.toJson(),
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'},
);
}
}
/// DELETE /api/recordings/<id> — Annule ou supprime un enregistrement
/// Si un enregistrement FFmpeg est actif, il est arrêté avant la suppression
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'},
);
}
// Tuer FFmpeg si cet enregistrement est en cours AVANT de supprimer de la DB
await _scheduler.stopRecording(id);
_db.deleteRecording(id);
return Response.ok(
json.encode({'message': 'Enregistrement supprimé avec succès'}),
headers: {'Content-Type': 'application/json'},
);
}
/// 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'},
);
}
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'},
);
}
}
}