mirror of
https://github.com/R0m1k3/xtremflow.git
synced 2026-10-11 17:30:00 +02:00
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:
10 files changed
+740
-187
No files matched your search
@@ -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(),
|
||||
}),
|
||||
);
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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']}',
|
||||
|
||||
Reference in new issue
Block a user