Fiabilise le système d'enregistrement de bout en bout

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
This commit is contained in:
Claude committed 2026-08-27 12:11:56 +00:00
1 parent 60d3cc52d9
commit 1d8717bb40
10 files changed
+740 -187

No files matched your search

+42
View File
@@ -0,0 +1,42 @@
import 'dart:convert';
import 'package:http/http.dart' as http;
import 'authed_http.dart';
/// Point d'entrée unique pour créer un enregistrement via POST /api/recordings.
///
/// Le backend exige désormais des dates ISO-8601 AVEC fuseau (suffixe 'Z' ou
/// offset) et rejette les dates naïves en 400 : trois conventions d'envoi
/// coexistaient dans l'app (UTC, local naïf, UTC naïf), d'où des
/// enregistrements décalés de 1-2 h selon l'écran utilisé.
///
/// [wallClockIsUtc] : les horaires issus de l'EPG sont des heures UTC
/// « naïves » (parsées sans fuseau par Dart mais comparées à `now.toUtc()`
/// partout dans l'app). true les re-tague en UTC sans décalage ; false (par
/// défaut) convertit depuis l'heure locale réelle (cas d'un DateTime.now()).
Future<http.Response> postRecording({
required String channelId,
required String title,
required DateTime start,
required DateTime end,
String? streamUrl,
bool wallClockIsUtc = false,
}) {
DateTime asUtc(DateTime d) {
if (d.isUtc) return d;
return wallClockIsUtc
? DateTime.utc(d.year, d.month, d.day, d.hour, d.minute, d.second)
: d.toUtc();
}
return AuthedHttp.post(
Uri.parse('/api/recordings'),
headers: {'Content-Type': 'application/json'},
body: json.encode({
'channel_id': channelId,
'stream_url': streamUrl ?? '/api/live/$channelId.ts',
'title': title,
'start_time': asUtc(start).toIso8601String(),
'end_time': asUtc(end).toIso8601String(),
}),
);
}
+9 -15
View File
@@ -1,7 +1,6 @@
import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:google_fonts/google_fonts.dart';
import '../../../core/api/authed_http.dart';
import '../../../core/api/recording_requests.dart';
import '../../../core/models/iptv_models.dart';
import '../../../core/theme/app_colors.dart';
import '../../../core/widgets/glass_container.dart';
@@ -43,20 +42,15 @@ class _RecordingModalState extends State<RecordingModal> {
setState(() => _isLoading = true);
final endTime = _startTime.add(Duration(minutes: _durationMinutes));
try {
// Utilisation d'une URL relative en Web (ou d'une configuration pour autres plateformes)
final response = await AuthedHttp.post(
Uri.parse('/api/recordings'),
headers: {'Content-Type': 'application/json'},
body: json.encode({
'channel_id': widget.channel.streamId,
'stream_url': '/api/live/${widget.channel.streamId}.ts',
'title': widget.channel.name,
// Forcer UTC pour éviter le décalage +01:00 (France) vs UTC (serveur Docker)
'start_time': _startTime.toUtc().toIso8601String(),
'end_time': endTime.toUtc().toIso8601String(),
}),
// _startTime est une vraie heure locale (pickers) : postRecording la
// convertit en UTC — seule convention acceptée par le backend.
final response = await postRecording(
channelId: widget.channel.streamId,
title: widget.channel.name,
start: _startTime,
end: endTime,
);
if (response.statusCode == 200) {
+45 -11
View File
@@ -4,6 +4,7 @@ import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:google_fonts/google_fonts.dart';
import '../../../core/api/authed_http.dart';
import '../../../core/api/recording_requests.dart';
import '../../../core/models/iptv_models.dart';
import '../../../core/models/playlist_config.dart';
import '../../../core/theme/app_colors.dart';
@@ -594,16 +595,15 @@ class _ProgrammeCard extends StatelessWidget {
DateTime end,
) async {
try {
final response = await AuthedHttp.post(
Uri.parse('/api/recordings'),
headers: {'Content-Type': 'application/json'},
body: json.encode({
'channel_id': channel.streamId,
'stream_url': '/api/live/${channel.streamId}.ts',
'title': title,
'start_time': start.toIso8601String(),
'end_time': end.toIso8601String(),
}),
// Les horaires EPG sont des heures UTC naïves : wallClockIsUtc les
// re-tague sans décalage (l'ancien envoi naïf était interprété dans le
// fuseau du serveur → enregistrement décalé de 1-2 h).
final response = await postRecording(
channelId: channel.streamId,
title: title,
start: start,
end: end,
wallClockIsUtc: true,
);
if (response.statusCode == 200) notifyRecordingsChanged();
if (context.mounted) {
@@ -914,6 +914,7 @@ class _RecordingsListViewState extends State<_RecordingsListView> {
'recording' => AppColors.live,
'completed' => AppColors.success,
'failed' => AppColors.warning,
'cancelled' => AppColors.onSurface38,
_ => AppColors.primaryContainer,
};
@@ -922,9 +923,20 @@ class _RecordingsListViewState extends State<_RecordingsListView> {
'recording' => '● En cours',
'completed' => 'Terminé',
'failed' => 'Échoué',
'cancelled' => 'Annulé',
_ => status,
};
String _fmtSize(int bytes) {
if (bytes >= 1024 * 1024 * 1024) {
return '${(bytes / (1024 * 1024 * 1024)).toStringAsFixed(1)} Go';
}
if (bytes >= 1024 * 1024) {
return '${(bytes / (1024 * 1024)).toStringAsFixed(0)} Mo';
}
return '${(bytes / 1024).toStringAsFixed(0)} Ko';
}
String _fmtDate(dynamic raw) {
if (raw == null) return '?';
try {
@@ -1040,12 +1052,34 @@ class _RecordingsListViewState extends State<_RecordingsListView> {
CrossAxisAlignment.start,
children: [
Text(
'${_fmtDate(rec['start_time'])} → ${_fmtDate(rec['end_time'])}',
'${_fmtDate(rec['start_time'])} → ${_fmtDate(rec['end_time'])}'
'${rec['file_size_bytes'] is int ? ' · ${_fmtSize(rec['file_size_bytes'] as int)}' : ''}',
style: const TextStyle(
color: AppColors.onSurface54,
fontSize: 12,
),
),
if (status == 'recording' &&
rec['progress_pct'] is int) ...[
const SizedBox(height: 6),
ClipRRect(
borderRadius:
BorderRadius.circular(3),
child: LinearProgressIndicator(
value:
(rec['progress_pct'] as int) /
100,
minHeight: 4,
backgroundColor: AppColors
.onSurface
.withOpacity(0.1),
valueColor:
const AlwaysStoppedAnimation(
AppColors.live,
),
),
),
],
if (rec['error_reason'] != null)
Text(
'⚠ ${rec['error_reason']}',