mirror of
https://github.com/R0m1k3/xtremflow.git
synced 2026-10-18 04:39:41 +02:00
Enregistrements : lecteur avancé, navigation et reprise
La lecture d'un enregistrement ne permettait ni de reculer, ni d'avancer, ni de reprendre où on s'était arrêté. Cause côté serveur : la playlist était servie en `EXT-X-PLAYLIST-TYPE:VOD`. hls.js la considère alors comme définitive et ne la relit jamais — il ne voyait donc que les quelques secondes déjà transcodées à l'ouverture, d'où une durée absurde et une barre de progression inutilisable. Elle passe en `EVENT` : la zone navigable grandit au rythme de l'encodage. FFmpeg transcodant séquentiellement, sauter à 45 min imposait malgré tout d'attendre qu'il y arrive. La playlist accepte donc `?start=<secondes>` et démarre une session dédiée (`-ss` avant `-i`). Les segments y sont préfixés par l'offset (`t2700/segment_000.ts`) : la query string ne survit pas aux URLs relatives de la playlist, deux sessions du même enregistrement se seraient mélangées. Les sessions d'offsets abandonnés sont fermées dès qu'aucun lecteur ne les consomme (killIdleSiblings). Côté lecteur, tout ce qui sort du moteur est désormais en temps absolu (`offset` réinjecté) : une recherche dans la zone déjà transcodée est un simple `currentTime`, au-delà le lecteur répond `seek_out_of_range` et l'app relance le flux à cet instant. Ajouts visibles : - reprise proposée à l'ouverture d'un enregistrement entamé, et mention « Reprendre à … » avec sa progression dans la liste ; - durée réelle mesurée par ffprobe (`duration_seconds`), mise en cache tant que le fichier ne bouge pas — `end_time - start_time` n'est que la durée programmée ; - sauts ±10 s et ±1 min, sélecteur de vitesse (0,5× à 2×), portion déjà transcodée visible sur la barre ; - raccourcis K/espace, J/L, Maj+←/→, 0-9, F, M, virgule/point. L'identifiant de vue de plateforme porte maintenant un numéro de génération : sans lui, réutiliser le même identifiant laissait l'ancienne iframe en place (`registerViewFactory` conserve la première fabrique enregistrée) — ce qui affectait déjà le retour sur une chaîne précédemment zappée. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01T1A6WRnoktWcT7ZG4b2rbC
This commit is contained in:
9 files changed
+905
-133
No files matched your search
@@ -0,0 +1,43 @@
|
||||
/// Adressage des sessions de lecture d'un enregistrement.
|
||||
///
|
||||
/// Un enregistrement est transcodé à la volée par FFmpeg, séquentiellement.
|
||||
/// Reprendre à 45 min sans le lui dire imposerait d'attendre que l'encodeur
|
||||
/// y arrive : le lecteur demande donc une playlist qui *commence* à cette
|
||||
/// position (`?start=2700`), servie par une session FFmpeg dédiée.
|
||||
///
|
||||
/// Ces fonctions sont isolées du handler HTTP pour rester testables.
|
||||
library;
|
||||
|
||||
/// Position de départ demandée (`?start=<secondes>`), bornée au positif.
|
||||
/// Toute valeur absente, négative ou non numérique vaut « depuis le début ».
|
||||
int parseRecordingStart(String? raw) {
|
||||
final value = double.tryParse(raw ?? '');
|
||||
if (value == null || !value.isFinite || value <= 0) return 0;
|
||||
return value.floor();
|
||||
}
|
||||
|
||||
/// Segment de chemin qui rattache un segment `.ts` à SA session.
|
||||
///
|
||||
/// Les segments sont référencés en relatif dans la playlist et la
|
||||
/// query string n'y survit pas : sans ce répertoire virtuel, la playlist
|
||||
/// démarrée à 0 s et celle démarrée à 45 min demanderaient toutes deux
|
||||
/// `segment_000.ts` à la même URL.
|
||||
String recordingOffsetKey(int start) => 't$start';
|
||||
|
||||
/// Inverse de [recordingOffsetKey] ; `null` si la clé est mal formée.
|
||||
int? parseRecordingOffsetKey(String key) {
|
||||
if (!key.startsWith('t') || key.length < 2) return null;
|
||||
final value = int.tryParse(key.substring(1));
|
||||
return (value == null || value < 0) ? null : value;
|
||||
}
|
||||
|
||||
/// Préfixe les segments de [playlist] par [offsetKey].
|
||||
///
|
||||
/// Seules les lignes de segment sont touchées : les balises `#EXT-X-…` et
|
||||
/// les lignes vides passent telles quelles.
|
||||
String rewriteRecordingPlaylist(String playlist, String offsetKey) {
|
||||
return playlist.replaceAllMapped(
|
||||
RegExp(r'^(segment_\d+\.ts)\s*$', multiLine: true),
|
||||
(m) => '$offsetKey/${m[1]}',
|
||||
);
|
||||
}
|
||||
@@ -74,18 +74,94 @@ class RecordingsApi {
|
||||
/// 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) {
|
||||
Future<Response> handleGetAll(Request request) async {
|
||||
final user = request.context['user'] as User?;
|
||||
final recordings = (user != null && !user.isAdmin)
|
||||
? _db.getUserRecordings(user.id)
|
||||
: _db.getAllRecordings();
|
||||
final now = DateTime.now().toUtc();
|
||||
final enriched = <Map<String, dynamic>>[];
|
||||
for (final r in recordings) {
|
||||
final map = _enrich(r, now);
|
||||
// Durée réelle du média : `end_time - start_time` n'est que la durée
|
||||
// *programmée*. Un enregistrement arrêté en avance ou tronqué par une
|
||||
// coupure amont donnait une barre de progression fausse et un « reprendre »
|
||||
// hors des clous.
|
||||
final duration = await _probeDuration(r);
|
||||
if (duration != null) map['duration_seconds'] = duration;
|
||||
enriched.add(map);
|
||||
}
|
||||
return Response.ok(
|
||||
json.encode(recordings.map((r) => _enrich(r, now)).toList()),
|
||||
json.encode(enriched),
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
);
|
||||
}
|
||||
|
||||
/// Binaire ffprobe : même logique de résolution que FFmpeg côté scheduler
|
||||
/// (l'image Docker les installe tous deux dans `/usr/local/bin`).
|
||||
static String _ffprobePath() {
|
||||
final fromEnv = Platform.environment['FFPROBE_PATH'];
|
||||
if (fromEnv != null && fromEnv.isNotEmpty) return fromEnv;
|
||||
if (Platform.isLinux && File('/usr/local/bin/ffprobe').existsSync()) {
|
||||
return '/usr/local/bin/ffprobe';
|
||||
}
|
||||
return 'ffprobe';
|
||||
}
|
||||
|
||||
/// Durées mesurées par ffprobe, mémorisées tant que le fichier ne bouge pas.
|
||||
/// Sans ce cache, chaque cycle de rafraîchissement (5 s pendant une capture)
|
||||
/// relancerait un ffprobe par enregistrement.
|
||||
static final Map<String, ({int size, int mtimeMs, double duration})>
|
||||
_durationCache = {};
|
||||
|
||||
/// Durée du média en secondes, ou `null` si elle n'est pas mesurable
|
||||
/// (ffprobe absent, fichier en cours d'écriture, conteneur sans durée).
|
||||
Future<double?> _probeDuration(Recording r) async {
|
||||
final path = r.filePath;
|
||||
// Un enregistrement en cours grossit en permanence : le cache serait
|
||||
// invalidé à chaque appel et ffprobe tournerait en boucle. La durée
|
||||
// programmée suffit tant que la capture n'est pas terminée.
|
||||
if (path == null || r.status != 'completed') return null;
|
||||
|
||||
final safePath = SafePath.resolveWithin(recordingsDirPath, path);
|
||||
if (safePath == null) return null;
|
||||
|
||||
final file = File(safePath);
|
||||
FileStat stat;
|
||||
try {
|
||||
stat = file.statSync();
|
||||
if (stat.type == FileSystemEntityType.notFound) return null;
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
|
||||
final cached = _durationCache[safePath];
|
||||
if (cached != null &&
|
||||
cached.size == stat.size &&
|
||||
cached.mtimeMs == stat.modified.millisecondsSinceEpoch) {
|
||||
return cached.duration;
|
||||
}
|
||||
|
||||
try {
|
||||
final result = await Process.run(_ffprobePath(), [
|
||||
'-v', 'error',
|
||||
'-show_entries', 'format=duration',
|
||||
'-of', 'default=noprint_wrappers=1:nokey=1',
|
||||
safePath,
|
||||
]);
|
||||
final value = double.tryParse((result.stdout as String).trim());
|
||||
if (value == null || !value.isFinite || value <= 0) return null;
|
||||
_durationCache[safePath] = (
|
||||
size: stat.size,
|
||||
mtimeMs: stat.modified.millisecondsSinceEpoch,
|
||||
duration: value,
|
||||
);
|
||||
return value;
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
Map<String, dynamic> _enrich(Recording r, DateTime now) {
|
||||
final map = r.toMap();
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ import '../database/database.dart';
|
||||
import '../models/playlist_config.dart';
|
||||
import '../services/ffmpeg_session_manager.dart';
|
||||
import '../utils/log_redactor.dart';
|
||||
import 'recording_playlist.dart';
|
||||
|
||||
/// Directory for temporary HLS segments
|
||||
final Directory _hlsTempDir =
|
||||
@@ -632,6 +633,10 @@ Handler createRecordingStreamHandler(
|
||||
|
||||
router.get('/<streamId>/playlist.m3u8',
|
||||
(Request request, String streamId) async {
|
||||
if (!_isValidStreamId(streamId)) {
|
||||
return Response.badRequest(body: 'Invalid request');
|
||||
}
|
||||
|
||||
final recording = db.getRecordingById(streamId);
|
||||
if (recording == null) {
|
||||
return Response.notFound('Recording not found');
|
||||
@@ -661,7 +666,10 @@ Handler createRecordingStreamHandler(
|
||||
}
|
||||
|
||||
final useNvidiaGpu = isGpuEnabled?.call() ?? _isNvidiaGpuEnabled();
|
||||
final sessionId = 'rec_$streamId';
|
||||
final start = parseRecordingStart(request.url.queryParameters['start']);
|
||||
final offsetKey = recordingOffsetKey(start);
|
||||
final sessionPrefix = 'rec_${streamId}_t';
|
||||
final sessionId = 'rec_${streamId}_$offsetKey';
|
||||
|
||||
final session = await sessionManager.getOrStart(
|
||||
id: sessionId,
|
||||
@@ -670,6 +678,9 @@ Handler createRecordingStreamHandler(
|
||||
argsBuilder: (dir) => [
|
||||
'-hide_banner', '-loglevel', 'warning',
|
||||
if (useNvidiaGpu) ...['-hwaccel', 'cuda'],
|
||||
// -ss AVANT -i : recherche rapide par index, sans décoder ce qui
|
||||
// précède. La sortie repart de 0, le lecteur ré-ajoute l'offset.
|
||||
if (start > 0) ...['-ss', '$start'],
|
||||
'-i', targetUrl,
|
||||
if (useNvidiaGpu) ...[
|
||||
'-c:v', 'h264_nvenc', '-preset', 'p4', '-tune', 'hq',
|
||||
@@ -686,8 +697,13 @@ Handler createRecordingStreamHandler(
|
||||
'-g', '48', '-threads', '0',
|
||||
],
|
||||
..._audioArgs(withFilters: true),
|
||||
// `event` et non `vod` : en VOD, hls.js considère la playlist comme
|
||||
// définitive et ne la relit jamais. Il ne voyait donc que les
|
||||
// quelques secondes déjà transcodées à l'ouverture — d'où une durée
|
||||
// absurde et une barre de progression inutilisable. En `event`, la
|
||||
// playlist est rechargée et la zone navigable grandit avec l'encodage.
|
||||
'-f', 'hls', '-hls_time', '4', '-hls_list_size', '0',
|
||||
'-hls_playlist_type', 'vod', '-hls_allow_cache', '1',
|
||||
'-hls_playlist_type', 'event', '-hls_allow_cache', '1',
|
||||
'-hls_flags', 'independent_segments', '-hls_segment_type', 'mpegts',
|
||||
'-hls_segment_filename', 'segment_%03d.ts', '-start_number', '0',
|
||||
'playlist.m3u8',
|
||||
@@ -702,22 +718,46 @@ Handler createRecordingStreamHandler(
|
||||
}
|
||||
|
||||
session.touch();
|
||||
// Ménage des offsets abandonnés (aller-retours dans la barre de
|
||||
// progression), une fois la nouvelle session confirmée démarrée.
|
||||
sessionManager.killIdleSiblings(sessionPrefix, keep: sessionId);
|
||||
|
||||
// Les segments sont préfixés par l'offset pour rester rattachés à LEUR
|
||||
// session : `segment_000.ts` d'une playlist à 0 s et d'une playlist à
|
||||
// 45 min sont deux fichiers différents.
|
||||
final playlist = await File('${session.dir.path}/playlist.m3u8')
|
||||
.readAsString();
|
||||
|
||||
return Response.ok(
|
||||
File('${session.dir.path}/playlist.m3u8').openRead(),
|
||||
rewriteRecordingPlaylist(playlist, offsetKey),
|
||||
headers: _hlsHeaders(),
|
||||
);
|
||||
});
|
||||
|
||||
router.get('/<streamId>/<segment>',
|
||||
(Request request, String streamId, String segment) async {
|
||||
if (segment.contains('..')) {
|
||||
Response serveSegment(String streamId, String offsetKey, String segment) {
|
||||
if (!_isValidStreamId(streamId) ||
|
||||
parseRecordingOffsetKey(offsetKey) == null ||
|
||||
segment.contains('..') ||
|
||||
segment.contains('/')) {
|
||||
return Response.badRequest(body: 'Invalid request');
|
||||
}
|
||||
final sessionId = 'rec_$streamId';
|
||||
final sessionId = 'rec_${streamId}_$offsetKey';
|
||||
sessionManager.touch(sessionId);
|
||||
final file = File('${_hlsTempDir.path}/$sessionId/$segment');
|
||||
if (!file.existsSync()) return Response.notFound('Segment not found');
|
||||
return Response.ok(file.openRead(), headers: _segmentHeaders(maxAge: 3600));
|
||||
}
|
||||
|
||||
router.get('/<streamId>/<offsetKey>/<segment>',
|
||||
(Request request, String streamId, String offsetKey, String segment) {
|
||||
return serveSegment(streamId, offsetKey, segment);
|
||||
});
|
||||
|
||||
// Compat : playlist servie avant la mise en place des offsets, encore en
|
||||
// cache dans un onglet ouvert.
|
||||
router.get('/<streamId>/<segment>',
|
||||
(Request request, String streamId, String segment) {
|
||||
return serveSegment(streamId, recordingOffsetKey(0), segment);
|
||||
});
|
||||
|
||||
return router.call;
|
||||
|
||||
Reference in new issue
Block a user