mirror of
https://github.com/R0m1k3/xtremflow.git
synced 2026-10-19 21:18:06 +02:00
Diagnostic : le lecteur remonte ses freezes dans les logs du serveur
Les freezes (image et son figés puis reprise) se produisent dans le navigateur de l'utilisateur ; le serveur n'en voyait rien. Le lecteur envoie désormais chaque coupure (durée, buffer), saut (de → vers), erreur MPEG-TS/HLS, recréation, repli HLS et changement de profil, plus un bilan toutes les 60 s, à POST /api/player-log (authentifié). Le serveur écrit une ligne « [Player] … » par événement. Entrée filtrée : événements et champs connus seulement, texte borné, URL masquées (une erreur réseau peut recopier les identifiants Xtream), pas d'injection de lignes. Envoi jamais bloquant pour la lecture. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
775caad0ee
commit
a739d6451c
5 files changed
+292
-1
No files matched your search
@@ -0,0 +1,70 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:shelf/shelf.dart';
|
||||
|
||||
/// Journal des incidents de lecture remontés par le lecteur web.
|
||||
///
|
||||
/// POURQUOI : les freezes (image et son figés puis reprise) se produisent
|
||||
/// chez l'utilisateur, dans son navigateur ; le serveur n'en voit rien. Le
|
||||
/// lecteur envoie ici chaque coupure, erreur, recréation et saut, et le
|
||||
/// serveur les écrit dans `docker logs` (`[Player] …`) : on sait alors si un
|
||||
/// freeze vient d'un buffer à sec, d'une erreur du démuxeur ou d'une
|
||||
/// relance du flux.
|
||||
///
|
||||
/// Entrée non fiable : seuls des champs connus, courts et typés passent.
|
||||
|
||||
const _events = {
|
||||
'start', 'first_frame', 'stall', 'seek', 'mpegts_error', 'recreate',
|
||||
'fallback_hls', 'hls_error', 'profile', 'error_shown', 'heartbeat',
|
||||
};
|
||||
|
||||
const _numericFields = {
|
||||
't', 'ms', 'buffer', 'pos', 'from', 'to', 'stalls', 'stallMs', 'seeks',
|
||||
'errors', 'rate',
|
||||
};
|
||||
|
||||
const _textFields = {'session', 'ch', 'mode', 'profile', 'detail'};
|
||||
|
||||
/// Ligne de journal pour l'événement [raw], ou null s'il est invalide.
|
||||
String? formatPlayerEvent(Object? raw) {
|
||||
if (raw is! Map) return null;
|
||||
final event = raw['event'];
|
||||
if (event is! String || !_events.contains(event)) return null;
|
||||
|
||||
final parts = <String>[event];
|
||||
for (final key in _textFields) {
|
||||
final value = raw[key];
|
||||
if (value is String && value.isNotEmpty) {
|
||||
// Ni espace, ni retour ligne, ni URL : jamais d'injection de faux
|
||||
// journaux, jamais d'identifiants recopiés depuis une URL d'erreur.
|
||||
final safe = value
|
||||
.replaceAll(RegExp(r'https?://\S+'), '<url>')
|
||||
.replaceAll(RegExp(r'[^\w.\-:/<>]'), '_');
|
||||
parts.add('$key=${safe.length > 60 ? safe.substring(0, 60) : safe}');
|
||||
}
|
||||
}
|
||||
for (final key in _numericFields) {
|
||||
final value = raw[key];
|
||||
if (value is num && value.isFinite) {
|
||||
parts.add('$key=${value is int ? value : value.toStringAsFixed(1)}');
|
||||
}
|
||||
}
|
||||
return '[Player] ${parts.join(' ')}';
|
||||
}
|
||||
|
||||
Future<Response> handlePlayerLog(Request request) async {
|
||||
final body = await request.readAsString();
|
||||
if (body.length > 4096) return Response(413);
|
||||
Object? decoded;
|
||||
try {
|
||||
decoded = jsonDecode(body);
|
||||
} catch (_) {
|
||||
return Response.badRequest();
|
||||
}
|
||||
final events = decoded is List ? decoded.take(20) : [decoded];
|
||||
for (final event in events) {
|
||||
final line = formatPlayerEvent(event);
|
||||
if (line != null) print(line);
|
||||
}
|
||||
return Response(204);
|
||||
}
|
||||
@@ -29,6 +29,7 @@ import 'services/cleanup_service.dart';
|
||||
import 'services/recording_scheduler.dart';
|
||||
import 'utils/asset_versioning.dart';
|
||||
import 'api/upstream_probe.dart';
|
||||
import 'api/player_log.dart';
|
||||
|
||||
void main(List<String> args) async {
|
||||
// Parse command line arguments
|
||||
@@ -221,6 +222,14 @@ void main(List<String> args) async {
|
||||
.addMiddleware(authMiddleware(db))
|
||||
.addHandler(logoApi.handle),
|
||||
)
|
||||
// Incidents de lecture remontés par le lecteur web (freezes, erreurs),
|
||||
// écrits dans les logs pour le diagnostic.
|
||||
..post(
|
||||
'/api/player-log',
|
||||
const Pipeline()
|
||||
.addMiddleware(authMiddleware(db))
|
||||
.addHandler(handlePlayerLog),
|
||||
)
|
||||
// EPG - guide TV (auth required)
|
||||
..mount(
|
||||
'/api/epg',
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
import 'package:test/test.dart';
|
||||
import '../api/player_log.dart';
|
||||
|
||||
void main() {
|
||||
group('formatPlayerEvent', () {
|
||||
test('écrit une coupure avec sa durée et le buffer', () {
|
||||
expect(
|
||||
formatPlayerEvent({
|
||||
'event': 'stall',
|
||||
'session': 'ab12',
|
||||
'ch': '554034',
|
||||
'ms': 5230,
|
||||
'buffer': 0.04,
|
||||
}),
|
||||
'[Player] stall session=ab12 ch=554034 ms=5230 buffer=0.0',
|
||||
);
|
||||
});
|
||||
|
||||
test('refuse un événement inconnu ou un payload invalide', () {
|
||||
expect(formatPlayerEvent({'event': 'rm -rf'}), isNull);
|
||||
expect(formatPlayerEvent('stall'), isNull);
|
||||
expect(formatPlayerEvent(null), isNull);
|
||||
});
|
||||
|
||||
test('pas d\'injection de lignes ni d\'URL (identifiants Xtream)', () {
|
||||
final line = formatPlayerEvent({
|
||||
'event': 'mpegts_error',
|
||||
'detail': 'NetworkError http://panel/live/user/pass/1.ts\n[Player] faux',
|
||||
})!;
|
||||
expect(line, isNot(contains('\n')));
|
||||
expect(line, isNot(contains('user/pass')));
|
||||
expect(line, contains('<url>'));
|
||||
});
|
||||
|
||||
test('ignore les champs inattendus et les nombres non finis', () {
|
||||
final line = formatPlayerEvent({
|
||||
'event': 'seek',
|
||||
'from': 10,
|
||||
'to': double.infinity,
|
||||
'cookie': 'secret',
|
||||
})!;
|
||||
expect(line, contains('from=10'));
|
||||
expect(line, isNot(contains('to=')));
|
||||
expect(line, isNot(contains('secret')));
|
||||
});
|
||||
});
|
||||
}
|
||||
Reference in new issue
Block a user