Files
xtremflow/lib/features/iptv/widgets/recording_modal.dart
T
Claude 1d8717bb40 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
2026-08-27 12:11:56 +00:00

367 lines
15 KiB
Dart

import 'package:flutter/material.dart';
import 'package:google_fonts/google_fonts.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';
import '../../../core/widgets/tv_focusable_card.dart';
import '../providers/recordings_refresh.dart';
class RecordingModal extends StatefulWidget {
final Channel channel;
const RecordingModal({
super.key,
required this.channel,
});
static Future<void> show(BuildContext context, Channel channel) {
return showDialog(
context: context,
builder: (context) => RecordingModal(channel: channel),
);
}
@override
State<RecordingModal> createState() => _RecordingModalState();
}
class _RecordingModalState extends State<RecordingModal> {
DateTime _startTime = DateTime.now();
int _durationMinutes = 60;
bool _isLoading = false;
Future<void> _recordNow() async {
setState(() {
_startTime = DateTime.now();
});
await _scheduleRecording();
}
Future<void> _scheduleRecording() async {
setState(() => _isLoading = true);
final endTime = _startTime.add(Duration(minutes: _durationMinutes));
try {
// _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) {
notifyRecordingsChanged();
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Enregistrement programmé !')),
);
Navigator.of(context).pop();
}
} else {
throw Exception('Erreur API');
}
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Erreur: $e')),
);
}
} finally {
if (mounted) {
setState(() => _isLoading = false);
}
}
}
@override
Widget build(BuildContext context) {
return Center(
child: Material(
color: Colors.transparent,
child: Container(
width: 600,
padding: const EdgeInsets.all(24),
decoration: BoxDecoration(
color: AppColors.background.withOpacity(0.9),
borderRadius: BorderRadius.circular(24),
border: Border.all(color: AppColors.primary.withOpacity(0.3)),
),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
// Header
Row(
children: [
const Icon(Icons.fiber_manual_record, color: AppColors.error),
const SizedBox(width: 12),
Expanded(
child: Text(
'Programmer un enregistrement',
style: GoogleFonts.karla(
fontSize: 20,
fontWeight: FontWeight.bold,
color: AppColors.onSurface,
),
),
),
],
),
const SizedBox(height: 24),
// Informations Chaine
Text(
'Chaîne ciblée :',
style: GoogleFonts.karla(color: AppColors.onSurfaceVariant, fontSize: 14),
),
const SizedBox(height: 8),
Text(
widget.channel.name,
style: GoogleFonts.karla(color: AppColors.onSurface, fontSize: 18),
),
const SizedBox(height: 24),
// Date, Heure et Durée
Row(
children: [
// Date
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Date :',
style: GoogleFonts.karla(color: AppColors.onSurfaceVariant, fontSize: 14),
),
const SizedBox(height: 8),
TvFocusableCard(
onTap: () async {
final date = await showDatePicker(
context: context,
initialDate: _startTime,
firstDate: DateTime.now(),
lastDate: DateTime.now().add(const Duration(days: 365)),
);
if (date != null) {
setState(() {
_startTime = DateTime(
date.year, date.month, date.day,
_startTime.hour, _startTime.minute,
);
});
}
},
borderRadius: 8,
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 12),
decoration: BoxDecoration(
color: AppColors.onSurface.withOpacity(0.1),
borderRadius: BorderRadius.circular(8),
),
child: Row(
children: [
const Icon(Icons.calendar_today, color: AppColors.onSurface, size: 18),
const SizedBox(width: 8),
Expanded(
child: Text(
'${_startTime.day.toString().padLeft(2, '0')}/${_startTime.month.toString().padLeft(2, '0')}/${_startTime.year}',
style: GoogleFonts.karla(color: AppColors.onSurface),
overflow: TextOverflow.ellipsis,
),
),
],
),
),
),
],
),
),
const SizedBox(width: 12),
// Heure
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Heure :',
style: GoogleFonts.karla(color: AppColors.onSurfaceVariant, fontSize: 14),
),
const SizedBox(height: 8),
TvFocusableCard(
onTap: () async {
final time = await showTimePicker(
context: context,
initialTime: TimeOfDay.fromDateTime(_startTime),
builder: (context, child) {
return MediaQuery(
data: MediaQuery.of(context).copyWith(alwaysUse24HourFormat: true),
child: child!,
);
},
);
if (time != null) {
setState(() {
final now = DateTime.now();
_startTime = DateTime(
_startTime.year, _startTime.month, _startTime.day,
time.hour, time.minute,
);
// Empêcher l'heure de passé si c'est aujourd'hui
if (_startTime.isBefore(now)) {
_startTime = _startTime.add(const Duration(days: 1));
}
});
}
},
borderRadius: 8,
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 12),
decoration: BoxDecoration(
color: AppColors.onSurface.withOpacity(0.1),
borderRadius: BorderRadius.circular(8),
),
child: Row(
children: [
const Icon(Icons.access_time, color: AppColors.onSurface, size: 18),
const SizedBox(width: 8),
Expanded(
child: Text(
'${_startTime.hour.toString().padLeft(2, '0')}:${_startTime.minute.toString().padLeft(2, '0')}',
style: GoogleFonts.karla(color: AppColors.onSurface),
),
),
],
),
),
),
],
),
),
const SizedBox(width: 12),
// Durée
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Durée (min) :',
style: GoogleFonts.karla(color: AppColors.onSurfaceVariant, fontSize: 14),
),
const SizedBox(height: 8),
GlassContainer.glass(
borderRadius: 8,
padding: EdgeInsets.zero,
child: Row(
children: [
IconButton(
icon: const Icon(Icons.remove, color: AppColors.onSurface, size: 18),
padding: EdgeInsets.zero,
constraints: const BoxConstraints(minWidth: 32, minHeight: 46),
onPressed: () {
if (_durationMinutes > 15) {
setState(() => _durationMinutes -= 15);
}
},
),
Expanded(
child: Text(
'$_durationMinutes',
textAlign: TextAlign.center,
style: GoogleFonts.karla(color: AppColors.onSurface),
),
),
IconButton(
icon: const Icon(Icons.add, color: AppColors.onSurface, size: 18),
padding: EdgeInsets.zero,
constraints: const BoxConstraints(minWidth: 32, minHeight: 46),
onPressed: () {
if (_durationMinutes < 300) {
setState(() => _durationMinutes += 15);
}
},
),
],
),
),
],
),
),
],
),
const SizedBox(height: 32),
// Actions
Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
TextButton(
onPressed: _isLoading ? null : () => Navigator.of(context).pop(),
child: Text(
'Annuler',
style: GoogleFonts.karla(color: AppColors.onSurfaceVariant),
),
),
const SizedBox(width: 16),
TvFocusableCard(
onTap: _isLoading ? () {} : _recordNow,
borderRadius: 8,
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
decoration: BoxDecoration(
color: AppColors.error.withOpacity(0.8),
borderRadius: BorderRadius.circular(8),
),
child: _isLoading
? const SizedBox(
width: 20, height: 20,
child: CircularProgressIndicator(color: AppColors.onSurface, strokeWidth: 2),
)
: Text(
'Enregistrer de suite',
style: GoogleFonts.karla(
color: AppColors.onSurface,
fontWeight: FontWeight.bold,
),
),
),
),
const SizedBox(width: 8),
TvFocusableCard(
onTap: _isLoading ? () {} : _scheduleRecording,
borderRadius: 8,
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
decoration: BoxDecoration(
color: AppColors.primary,
borderRadius: BorderRadius.circular(8),
),
child: _isLoading
? const SizedBox(
width: 20, height: 20,
child: CircularProgressIndicator(color: AppColors.onSurface, strokeWidth: 2),
)
: Text(
'Programmer',
style: GoogleFonts.karla(
color: AppColors.onSurface,
fontWeight: FontWeight.bold,
),
),
),
),
],
),
],
),
),
),
);
}
}