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