Merge remote-tracking branch 'origin/main' into claude/nettoyage-qualite

# Conflicts:
#	CHANGELOG.md
This commit is contained in:
Claude committed 2026-08-28 06:54:22 +00:00
commit 1295bd12ef
24 files changed
+1255 -255

No files matched your search

+20
View File
@@ -9,6 +9,26 @@
- **README réécrit** : il décrivait une architecture disparue (Hive/IndexedDB, SHA-256, dhttpd port 8080) — remplacé par l'état réel (SQLite serveur, bcrypt, binaire natif port 8089, enregistrements, CI)
- `DEPLOYMENT_CHECKLIST.md` archivé et avertissement ajouté sur `docs/archive/` (plusieurs documents s'y déclarent « COMPLETE » à tort)
### ✨ Fonctionnalités
- **Guide TV et Season Passes de retour** : l'onglet Enregistrements retrouve ses 3 vues (Guide TV pour programmer depuis l'EPG, liste des enregistrements, Season Passes) — le code existait mais n'était plus branché depuis une refonte
- **Favoris enfin utilisables** : bouton cœur sur les tuiles chaînes (desktop et mobile) ; le filtre « Favoris » affichait toujours vide faute de moyen d'en ajouter
- **Reprise de lecture** : films et épisodes reprennent où on s'était arrêté (les positions étaient sauvegardées mais jamais relues) ; le live ne pollue plus le stockage de positions
- **Enregistrements sur mobile** : nouvel onglet REC dans la barre de navigation ; les onglets mobiles conservent leur état (IndexedStack) au lieu d'être reconstruits à chaque bascule
- **Menu profil** sur l'avatar de la sidebar : nom d'utilisateur + déconnexion (le bouton était mort, aucune déconnexion possible depuis le dashboard)
- **Confirmation avant suppression** d'un enregistrement, et messages d'erreur avec bouton « Réessayer » (chaînes, enregistrements) au lieu d'exceptions brutes
### 🔧 Fiabilité des enregistrements
- **Fuseaux horaires unifiés** : le backend exige des dates ISO-8601 avec fuseau (400 sinon) et stocke tout en UTC ; le frontend passe par un helper unique `postRecording()` — fini les enregistrements décalés de 1-2 h selon l'écran utilisé
- **Contrôle de propriété** : stop/suppression/logs d'un enregistrement et suppression d'un season pass ne sont plus possibles que par leur propriétaire (ou un admin)
- **SQLite durci** : `foreign_keys=ON` (les CASCADE déclarés s'appliquent enfin), WAL, `busy_timeout`, migrations de schéma versionnées, index sur `user_id`/`start_time`
- **Gestion disque** : refus explicite de démarrer une capture sous `MIN_FREE_DISK_MB` (défaut 500 Mo) ; nouvelle rotation par quota d'octets (`RECORDINGS_QUOTA_GB`, désactivée par défaut) qui ne touche jamais un enregistrement actif et supprime fichiers + ligne BDD ensemble (l'ancienne rotation « 50 fichiers » pouvait effacer une capture en cours) ; la suppression d'un enregistrement efface aussi ses fichiers (.mkv, .log, parties)
- **Arrêt gracieux** : `docker stop` clôture proprement les enregistrements (fusion des parties, statut en base) avant de tuer les sessions de streaming
- **Noms de fichiers uniques** (fragment d'id) : deux enregistrements du même programme ne s'écrasent plus
- **Statut `cancelled`** : arrêter un enregistrement planifié l'annule au lieu de le marquer « terminé » sans fichier (lecture cassée)
- **Season passes** : la playlist du propriétaire du pass est résolue à chaque scan (plus d'injection figée du premier utilisateur), correspondance de titre exacte par défaut (`match_mode`), plafond de créations par scan, réalignement automatique des horaires si le programme est déplacé dans l'EPG, déduplication tolérante (±2 min)
- **API de suivi** : `GET /api/recordings` renvoie désormais `progress_pct`, `file_size_bytes`, `retry_count`, `is_active` ; la liste affiche la barre de progression et la taille
- Le scheduler ne relit plus toute la table toutes les 10 s (requête filtrée sur `scheduled`/`recording`)
### 📺 Enregistrements
- La liste des enregistrements se met à jour automatiquement : rafraîchissement immédiat dès qu'un enregistrement est créé/arrêté n'importe où dans l'app (guide EPG, modal, widget rapide), et polling en arrière-plan (5 s quand un enregistrement est en cours ou planifié, 20 s sinon) pour suivre les statuts sans clic manuel
- Indicateur « Suivi auto » avec heure de dernière actualisation dans l'onglet Enregistrements
+11 -2
View File
@@ -3,6 +3,7 @@ import 'package:shelf/shelf.dart';
import 'package:http/http.dart' as http;
import '../models/playlist_config.dart';
import '../services/xmltv_epg_service.dart';
import '../utils/log_redactor.dart';
/// API EPG — proxy vers Xtream avec cache 30 minutes
/// GET /api/epg/<channel_id>?days=1
@@ -112,8 +113,11 @@ class EpgApi {
},
);
} catch (e) {
// Détail redacté en log uniquement : une ClientException Dart contient
// l'URI amont, credentials Xtream inclus.
print('[EpgApi] Erreur EPG: ${LogRedactor.redactUrl('$e')}');
return Response.internalServerError(
body: json.encode({'error': 'Erreur lors de la récupération EPG: $e'}),
body: json.encode({'error': 'Erreur lors de la récupération EPG'}),
headers: {'Content-Type': 'application/json'},
);
}
@@ -258,7 +262,12 @@ class EpgApi {
return {'channel_id': channelId, 'programmes': programmes};
} catch (e) {
return {'channel_id': channelId, 'programmes': [], 'error': e.toString()};
print('[EpgApi] Erreur panneau pour $channelId: ${LogRedactor.redactUrl('$e')}');
return {
'channel_id': channelId,
'programmes': [],
'error': 'EPG indisponible',
};
}
}
+71 -12
View File
@@ -3,6 +3,8 @@ import 'dart:convert';
import 'dart:io';
import 'package:shelf/shelf.dart';
import 'package:http/http.dart' as http;
import '../database/database.dart';
import '../middleware/auth_middleware.dart';
import '../models/playlist_config.dart';
import '../utils/log_redactor.dart';
@@ -31,6 +33,7 @@ bool isForbiddenProxyHost(String host) {
/// Handler for the Xtream Proxy
class ProxyHandler {
final Future<PlaylistConfig?> Function(Request) _getPlaylist;
final AppDatabase _db;
final http.Client _client = http.Client();
final Map<String, (PlaylistConfig, DateTime)> _playlistCache = {};
@@ -70,7 +73,7 @@ class ProxyHandler {
return playlist;
}
ProxyHandler(this._getPlaylist);
ProxyHandler(this._getPlaylist, this._db);
/// Create Xtream proxy handler with M3U8 URL rewriting support
Handler get handler {
@@ -84,8 +87,16 @@ class ProxyHandler {
return Response.notFound(null);
}
// NOTE: Authentication REMOVED from proxy to allow browser-initiated requests (img src, etc.)
// SSRF protection is still active via domain validation below.
// Authentification par session. Les requêtes initiées par le navigateur
// (img src, hls.js) ne portent pas d'en-tête Authorization mais envoient
// le cookie HttpOnly `session` (SameSite=Lax, même origine) posé au
// login : extractAuthToken accepte les deux. Un proxy ouvert offrait un
// rebond SSRF non authentifié vers n'importe quel hôte public via les
// extensions d'image.
final token = extractAuthToken(request);
if (token == null || _db.findSessionByToken(token) == null) {
return Response(401, body: 'Unauthorized');
}
Uri? targetUrl;
@@ -139,6 +150,7 @@ class ProxyHandler {
targetUrl.path.contains('/picons/') ||
targetUrl.path.contains('/logos/');
String? allowedHost;
if (!isStaticAsset) {
// For API calls, enforce domain allowlist
final playlist = await _getCachedPlaylist(request);
@@ -149,7 +161,7 @@ class ProxyHandler {
}
final targetHost = targetUrl.host.toLowerCase();
final allowedHost = Uri.parse(playlist.dns).host.toLowerCase();
allowedHost = Uri.parse(playlist.dns).host.toLowerCase();
if (targetHost != allowedHost) {
print(
@@ -175,7 +187,6 @@ class ProxyHandler {
try {
print('[Proxy] Forwarding to: ${LogRedactor.redactUrl(targetUrl.toString())}');
final proxyRequest = http.Request(request.method, targetUrl);
// Forward safe request headers
for (final header in _allowedRequestHeaders) {
@@ -184,19 +195,62 @@ class ProxyHandler {
}
}
proxyRequest.headers.addAll(proxyHeaders);
proxyRequest.followRedirects = true;
List<int>? postBody;
if (request.method == 'POST') {
final bodyBytes = await request.read().toList();
proxyRequest.bodyBytes = bodyBytes.expand((i) => i).toList();
postBody = bodyBytes.expand((i) => i).toList();
}
// Redirections suivies MANUELLEMENT : chaque destination est
// revalidée (hôte privé, allowlist de domaine). Avec
// followRedirects, la validation ne portait que sur l'URL
// initiale — une 302 du serveur amont suffisait pour atteindre
// un hôte interne malgré l'anti-SSRF.
http.StreamedResponse response;
var currentUrl = targetUrl;
var redirects = 0;
while (true) {
final proxyRequest = http.Request(request.method, currentUrl);
proxyRequest.headers.addAll(proxyHeaders);
proxyRequest.followRedirects = false;
if (postBody != null) proxyRequest.bodyBytes = postBody;
// Added 90s timeout to allow frontend (60s) to time out gracefully first
final response = await _client
response = await _client
.send(proxyRequest)
.timeout(const Duration(seconds: 90));
final location = response.headers['location'];
final isRedirect = response.statusCode >= 300 &&
response.statusCode < 400 &&
location != null;
if (!isRedirect) break;
if (++redirects > 3) {
return Response.forbidden('Too many redirects');
}
final next = Uri.parse(location);
currentUrl = next.isAbsolute ? next : currentUrl.resolve(location);
if (currentUrl.scheme != 'http' && currentUrl.scheme != 'https') {
return Response.forbidden('Unsupported redirect scheme');
}
if (isForbiddenProxyHost(currentUrl.host)) {
print(
'[Proxy] Blocked SSRF redirect to private host: ${currentUrl.host}',
);
return Response.forbidden('Access to this host is forbidden');
}
if (allowedHost != null &&
currentUrl.host.toLowerCase() != allowedHost) {
print(
'[Proxy] Blocked redirect to ${currentUrl.host} (Allowed: $allowedHost)',
);
return Response.forbidden(
'Access to this domain is forbidden by policy',
);
}
}
// Build response headers from source response
final responseHeaders = <String, String>{
'access-control-allow-origin': '*',
@@ -221,7 +275,12 @@ class ProxyHandler {
rethrow;
}
} catch (e) {
print('[ProxyHandler] error on $path: $e');
// Redaction : une ClientException porte l'URL amont, credentials
// Xtream inclus. Jamais de détail d'exception vers le client.
print(
'[ProxyHandler] error on ${LogRedactor.redactUrl(path)}: '
'${LogRedactor.redactUrl('$e')}',
);
// Return transparent 1x1 pixel image fallback for images
if (targetUrl?.path.endsWith('.png') == true ||
@@ -235,7 +294,7 @@ class ProxyHandler {
}
return Response.internalServerError(
body: jsonEncode({'error': 'Proxy error', 'message': e.toString()}),
body: jsonEncode({'error': 'Proxy error'}),
headers: {'content-type': 'application/json'},
);
}
+166 -58
View File
@@ -3,6 +3,7 @@ import 'dart:convert';
import 'package:path/path.dart' as p;
import 'package:shelf/shelf.dart';
import '../database/database.dart';
import '../models/recording.dart';
import '../models/user.dart';
import '../services/recording_scheduler.dart';
import '../utils/safe_path.dart';
@@ -11,25 +12,43 @@ class RecordingsApi {
final AppDatabase _db;
final RecordingScheduler _scheduler;
/// Durée maximale d'un enregistrement (env MAX_RECORDING_HOURS).
final int maxRecordingHours = int.tryParse(
Platform.environment['MAX_RECORDING_HOURS'] ?? '',
) ??
12;
RecordingsApi(this._db, this._scheduler);
Response _json(int status, Map<String, dynamic> body) => Response(
status,
body: json.encode(body),
headers: {'Content-Type': 'application/json'},
);
/// L'utilisateur courant peut-il agir sur cet enregistrement ?
/// (même patron de contrôle de propriété que playlists_handler)
bool _canAccess(User? user, Recording recording) {
if (user == null) return false;
return user.isAdmin || recording.userId == user.id;
}
/// Handler pour GET /api/recordings/logs/<id>
/// Exposé séparément car shelf_router a un conflit entre DELETE /<id> et GET /logs/<id>
Future<Response> getLogHandler(Request request, String id) async {
final recording = _db.getRecordingById(id);
if (recording == null) {
return Response.notFound(
json.encode({'error': 'Enregistrement non trouvé'}),
headers: {'Content-Type': 'application/json'},
);
return _json(404, {'error': 'Enregistrement non trouvé'});
}
final user = request.context['user'] as User?;
if (!_canAccess(user, recording)) {
return _json(403, {'error': 'Accès refusé'});
}
if (recording.filePath == null) {
return Response.notFound(
json.encode({'error': 'Aucun fichier ni log associé pour le moment.'}),
headers: {'Content-Type': 'application/json'},
);
return _json(404, {'error': 'Aucun fichier ni log associé pour le moment.'});
}
// Les enregistrements sont écrits en .mkv avec un .log à côté
@@ -39,55 +58,115 @@ class RecordingsApi {
// Anti path-traversal : le log doit rester dans le dossier des enregistrements
final safeLogPath = SafePath.resolveWithin(recordingsDirPath, logFilePath);
if (safeLogPath == null) {
return Response.forbidden(
json.encode({'error': 'Chemin de log invalide'}),
headers: {'Content-Type': 'application/json'},
);
return _json(403, {'error': 'Chemin de log invalide'});
}
final logFile = File(safeLogPath);
if (!await logFile.exists()) {
return Response.notFound(
json.encode({'error': 'Le fichier de log est introuvable.'}),
headers: {'Content-Type': 'application/json'},
);
return _json(404, {'error': 'Le fichier de log est introuvable.'});
}
final logs = await logFile.readAsString();
return Response.ok(
json.encode({'logs': logs}),
headers: {'Content-Type': 'application/json'},
);
return _json(200, {'logs': logs});
}
/// GET /api/recordings — Liste les enregistrements de l'utilisateur
/// (tous les enregistrements pour un admin)
/// (tous les enregistrements pour un admin), enrichis des informations de
/// suivi : taille du fichier, progression, relances FFmpeg.
Response handleGetAll(Request request) {
final user = request.context['user'] as User?;
final recordings = (user != null && !user.isAdmin)
? _db.getUserRecordings(user.id)
: _db.getAllRecordings();
final now = DateTime.now().toUtc();
return Response.ok(
json.encode(recordings.map((r) => r.toMap()).toList()),
json.encode(recordings.map((r) => _enrich(r, now)).toList()),
headers: {'Content-Type': 'application/json'},
);
}
Map<String, dynamic> _enrich(Recording r, DateTime now) {
final map = r.toMap();
if (r.status == 'recording') {
final start = r.startTime.toUtc();
final end = r.endTime.toUtc();
final total = end.difference(start).inSeconds;
if (total > 0) {
final elapsed = now.difference(start).inSeconds;
map['progress_pct'] =
(elapsed * 100 / total).clamp(0, 100).round();
}
map['is_active'] = _scheduler.isCapturing(r.id);
final retries = _scheduler.retryCountOf(r.id);
if (retries != null) map['retry_count'] = retries;
}
final path = r.filePath;
if (path != null) {
try {
final file = File(path);
if (file.existsSync()) map['file_size_bytes'] = file.lengthSync();
} catch (_) {}
}
return map;
}
/// POST /api/recordings — Planifie un nouvel enregistrement
Future<Response> handlePost(Request request) async {
try {
final payload = await request.readAsString();
final data = json.decode(payload);
final user = request.context['user'] as User?;
final userId = user?.id ?? request.context['userId'] as String?;
if (userId == null) {
return _json(401, {'error': 'Authentification requise'});
}
final userId = request.context['userId'] as String? ?? 'dev_user_id';
Map<String, dynamic> data;
try {
data = json.decode(await request.readAsString()) as Map<String, dynamic>;
} catch (_) {
return _json(400, {'error': 'Corps JSON invalide'});
}
final channelId = data['channel_id']?.toString() ?? '';
final streamUrl = data['stream_url']?.toString() ?? '';
if (channelId.isEmpty) {
return _json(400, {'error': 'channel_id est requis'});
}
if (streamUrl.isEmpty) {
return _json(400, {'error': 'stream_url est requis'});
}
final startTime = _parseZonedDate(data['start_time']);
final endTime = _parseZonedDate(data['end_time']);
if (startTime == null || endTime == null) {
// Une date sans indicateur de fuseau ('Z' ou ±hh:mm) est ambiguë :
// l'interpréter dans le fuseau du serveur décale l'enregistrement
// de plusieurs heures selon le TZ du conteneur.
return _json(400, {
'error':
'start_time et end_time doivent être des dates ISO-8601 avec fuseau '
'(ex: 2026-08-27T21:00:00Z)',
});
}
if (!endTime.isAfter(startTime)) {
return _json(400, {'error': 'end_time doit être après start_time'});
}
if (endTime.difference(startTime) > Duration(hours: maxRecordingHours)) {
return _json(400, {
'error': 'Durée maximale dépassée ($maxRecordingHours h)',
});
}
try {
final recording = _db.createRecording(
userId: userId,
channelId: data['channel_id'],
streamUrl: data['stream_url'],
title: data['title'] ?? 'Sans Titre',
startTime: DateTime.parse(data['start_time']),
endTime: DateTime.parse(data['end_time']),
channelId: channelId,
streamUrl: streamUrl,
title: data['title']?.toString() ?? 'Sans Titre',
startTime: startTime,
endTime: endTime,
);
return Response.ok(
@@ -95,58 +174,87 @@ class RecordingsApi {
headers: {'Content-Type': 'application/json'},
);
} catch (e) {
return Response.internalServerError(
body: json.encode({'error': 'Erreur lors de la programmation: $e'}),
headers: {'Content-Type': 'application/json'},
);
print('[RecordingsApi] Erreur à la création: $e');
return _json(500, {'error': 'Erreur lors de la programmation'});
}
}
/// Parse une date ISO-8601 en exigeant un indicateur de fuseau, et la
/// normalise en UTC. Retourne null si absente, invalide ou naïve.
DateTime? _parseZonedDate(dynamic raw) {
final str = raw?.toString() ?? '';
if (str.isEmpty) return null;
// 'Z' final ou offset ±hh[:mm] après l'heure
final hasZone =
str.endsWith('Z') || RegExp(r'[+-]\d{2}:?\d{2}$').hasMatch(str);
if (!hasZone) return null;
return DateTime.tryParse(str)?.toUtc();
}
/// DELETE /api/recordings/<id> — Annule ou supprime un enregistrement
/// Si un enregistrement FFmpeg est actif, il est arrêté avant la suppression
/// Si un enregistrement FFmpeg est actif, il est arrêté avant la suppression.
/// Les fichiers associés (.mkv, .log, parties) sont supprimés avec la ligne.
Future<Response> handleDelete(Request request, String id) async {
final recording = _db.getRecordingById(id);
if (recording == null) {
return Response.notFound(
json.encode({'error': 'Enregistrement non trouvé'}),
headers: {'Content-Type': 'application/json'},
);
return _json(404, {'error': 'Enregistrement non trouvé'});
}
final user = request.context['user'] as User?;
if (!_canAccess(user, recording)) {
return _json(403, {'error': 'Accès refusé'});
}
// Tuer FFmpeg si cet enregistrement est en cours AVANT de supprimer de la DB
await _scheduler.stopRecording(id);
// Supprimer les fichiers pour ne pas laisser d'orphelins sur le volume,
// en restant confiné au dossier des enregistrements.
final path = recording.filePath;
if (path != null) {
final safePath = SafePath.resolveWithin(recordingsDirPath, path);
if (safePath != null) {
await _scheduler.deleteRecordingFiles(safePath);
}
}
_db.deleteRecording(id);
return Response.ok(
json.encode({'message': 'Enregistrement supprimé avec succès'}),
headers: {'Content-Type': 'application/json'},
);
return _json(200, {'message': 'Enregistrement supprimé avec succès'});
}
/// POST /api/recordings/stop/<id> — Arrête un enregistrement FFmpeg en cours
Future<Response> handleStop(Request request, String id) async {
final recording = _db.getRecordingById(id);
if (recording == null) {
return Response.notFound(
json.encode({'error': 'Enregistrement non trouvé'}),
headers: {'Content-Type': 'application/json'},
);
return _json(404, {'error': 'Enregistrement non trouvé'});
}
final user = request.context['user'] as User?;
if (!_canAccess(user, recording)) {
return _json(403, {'error': 'Accès refusé'});
}
final stopped = await _scheduler.stopRecording(id);
if (stopped) {
return Response.ok(
json.encode({'message': 'Enregistrement arrêté'}),
headers: {'Content-Type': 'application/json'},
);
} else {
// Pas de processus FFmpeg actif pour cet ID → marquer comme complété quand même
return _json(200, {'message': 'Enregistrement arrêté'});
}
if (recording.status == 'scheduled') {
// Rien n'a encore été capturé : annulé, pas « terminé ». Marquer
// completed sans fichier faisait ensuite échouer la lecture.
_db.updateRecordingStatus(id, 'cancelled');
return _json(200, {'message': 'Enregistrement annulé'});
}
if (recording.status == 'recording') {
// Statut « recording » sans processus actif (orphelin) : clôturer.
_db.updateRecordingStatus(id, 'completed');
return Response.ok(
json.encode({'message': 'Enregistrement marqué comme terminé'}),
headers: {'Content-Type': 'application/json'},
);
return _json(200, {'message': 'Enregistrement marqué comme terminé'});
}
// Déjà completed/failed/cancelled : ne pas écraser le statut final.
return _json(200, {'message': 'Enregistrement déjà clôturé'});
}
}
+47 -8
View File
@@ -9,17 +9,29 @@ class SeasonPassesApi {
SeasonPassesApi(this._db);
/// GET /api/season-passes — liste tous les season passes
/// GET /api/season-passes — liste les season passes de l'utilisateur
/// (tous les passes pour un admin)
Response handleGetAll(Request request) {
try {
final passes = _db.getAllSeasonPasses();
final user = request.context['user'] as User?;
if (user == null) {
return Response(
401,
body: json.encode({'error': 'Authentification requise'}),
headers: {'Content-Type': 'application/json'},
);
}
final passes = user.isAdmin
? _db.getAllSeasonPasses()
: _db.getSeasonPassesForUser(user.id);
return Response.ok(
json.encode(passes),
headers: {'Content-Type': 'application/json'},
);
} catch (e) {
print('[SeasonPass] Erreur au listage: $e');
return Response.internalServerError(
body: json.encode({'error': 'Erreur: $e'}),
body: json.encode({'error': 'Erreur interne'}),
headers: {'Content-Type': 'application/json'},
);
}
@@ -59,10 +71,20 @@ class SeasonPassesApi {
// Récupérer l'utilisateur depuis le contexte
final user = request.context['user'] as User?;
final userId = user?.id ?? 'admin'; // fallback
if (user == null) {
return Response(
401,
body: json.encode({'error': 'Authentification requise'}),
headers: {'Content-Type': 'application/json'},
);
}
// Vérifier si un season pass identique existe déjà
final existing = _db.getAllSeasonPasses();
// 'exact' par défaut : « Journal » ne doit pas capturer tous les
// programmes qui contiennent le mot. 'contains' reste disponible.
final matchMode = data['match_mode'] == 'contains' ? 'contains' : 'exact';
// Vérifier si un season pass identique existe déjà pour cet utilisateur
final existing = _db.getSeasonPassesForUser(user.id);
final duplicate = existing.any(
(p) =>
(p['show_title'] as String).toLowerCase() ==
@@ -78,10 +100,11 @@ class SeasonPassesApi {
}
final pass = _db.createSeasonPass(
userId: userId,
userId: user.id,
showTitle: showTitle,
channelId: channelId,
streamUrl: streamUrl,
matchMode: matchMode,
);
print('[SeasonPass] Créé: "$showTitle" sur chaîne $channelId');
@@ -101,14 +124,30 @@ class SeasonPassesApi {
/// DELETE /api/season-passes/<id> — supprimer un season pass
Response handleDelete(Request request, String id) {
try {
final user = request.context['user'] as User?;
final pass = _db.getSeasonPassById(id);
if (pass == null) {
return Response.notFound(
json.encode({'error': 'Season Pass non trouvé'}),
headers: {'Content-Type': 'application/json'},
);
}
// Contrôle de propriété : seul le propriétaire ou un admin supprime.
if (user == null || (!user.isAdmin && pass['user_id'] != user.id)) {
return Response.forbidden(
json.encode({'error': 'Accès refusé'}),
headers: {'Content-Type': 'application/json'},
);
}
_db.deleteSeasonPass(id);
return Response.ok(
json.encode({'message': 'Season Pass supprimé'}),
headers: {'Content-Type': 'application/json'},
);
} catch (e) {
print('[SeasonPass] Erreur à la suppression: $e');
return Response.internalServerError(
body: json.encode({'error': 'Erreur: $e'}),
body: json.encode({'error': 'Erreur interne'}),
headers: {'Content-Type': 'application/json'},
);
}
+14
View File
@@ -65,6 +65,13 @@ class UsersHandler {
}), headers: {'Content-Type': 'application/json'},);
}
if (password.length < 8) {
return Response.badRequest(body: jsonEncode({
'success': false,
'error': 'Le mot de passe doit faire au moins 8 caractères',
}), headers: {'Content-Type': 'application/json'},);
}
if (db.findUserByUsername(username) != null) {
return Response.badRequest(body: jsonEncode({
'success': false,
@@ -103,6 +110,13 @@ class UsersHandler {
}), headers: {'Content-Type': 'application/json'},);
}
if (password.length < 8) {
return Response.badRequest(body: jsonEncode({
'success': false,
'error': 'Le mot de passe doit faire au moins 8 caractères',
}), headers: {'Content-Type': 'application/json'},);
}
db.updateUserPassword(id, password);
return Response.ok(jsonEncode({'success': true}), headers: {'Content-Type': 'application/json'});
+199 -20
View File
@@ -1,4 +1,5 @@
import 'dart:io';
import 'dart:math';
import 'package:sqlite3/sqlite3.dart';
import 'package:uuid/uuid.dart';
import '../models/user.dart';
@@ -23,10 +24,68 @@ class AppDatabase {
_db = sqlite3.open(dbPath);
// Sans foreign_keys, les ON DELETE CASCADE déclarés dans le schéma sont
// ignorés par SQLite : supprimer un utilisateur laissait ses sessions
// (donc des jetons valides), playlists et enregistrements orphelins.
_db.execute('PRAGMA foreign_keys = ON');
_db.execute('PRAGMA journal_mode = WAL');
_db.execute('PRAGMA busy_timeout = 5000');
await _createTables();
_runMigrations();
print('Database initialized: $dbPath');
}
/// Migrations de schéma pour les bases créées par une version antérieure.
///
/// `CREATE TABLE IF NOT EXISTS` n'ajoute jamais de colonne à une table
/// existante : chaque colonne introduite après coup doit avoir sa migration.
/// Les migrations sont numérotées et rejouées uniquement si nécessaire.
void _runMigrations() {
_db.execute('''
CREATE TABLE IF NOT EXISTS schema_version (
version INTEGER PRIMARY KEY
)
''');
final result = _db.select(
'SELECT COALESCE(MAX(version), 0) AS v FROM schema_version',
);
var current = result.first['v'] as int;
final migrations = <int, void Function()>{
// v1 : colonne error_reason absente des bases d'avant son introduction.
1: () => _addColumnIfMissing('tv_recordings', 'error_reason', 'TEXT'),
// v2 : mode de correspondance des season passes. 'contains' pour les
// lignes existantes (comportement historique) ; les nouvelles créations
// passent par l'API qui choisit 'exact' par défaut.
2: () => _addColumnIfMissing(
'season_passes',
'match_mode',
"TEXT NOT NULL DEFAULT 'contains'",
),
};
for (final entry in migrations.entries) {
if (entry.key <= current) continue;
entry.value();
_db.execute(
'INSERT INTO schema_version (version) VALUES (?)',
[entry.key],
);
current = entry.key;
print('[DB] Migration v${entry.key} appliquée');
}
}
void _addColumnIfMissing(String table, String column, String definition) {
final columns = _db.select('PRAGMA table_info($table)');
final exists = columns.any((row) => row['name'] == column);
if (!exists) {
_db.execute('ALTER TABLE $table ADD COLUMN $column $definition');
}
}
/// Create database tables
Future<void> _createTables() async {
// Users table
@@ -107,6 +166,7 @@ class AppDatabase {
channel_id TEXT NOT NULL,
stream_url TEXT NOT NULL,
enabled INTEGER DEFAULT 1,
match_mode TEXT NOT NULL DEFAULT 'exact',
created_at TEXT DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
)
@@ -125,16 +185,32 @@ class AppDatabase {
_db.execute(
'CREATE INDEX IF NOT EXISTS idx_recordings_status ON tv_recordings(status)',
);
_db.execute(
'CREATE INDEX IF NOT EXISTS idx_recordings_user ON tv_recordings(user_id)',
);
_db.execute(
'CREATE INDEX IF NOT EXISTS idx_recordings_start ON tv_recordings(start_time)',
);
_db.execute(
'CREATE INDEX IF NOT EXISTS idx_season_passes_user ON season_passes(user_id)',
);
}
/// Seed default admin user if no users exist
/// Seed default admin user if no users exist.
///
/// Le mot de passe initial vient de ADMIN_INITIAL_PASSWORD, ou est généré
/// aléatoirement et affiché UNE FOIS dans les logs de démarrage. L'ancien
/// couple admin/admin restait souvent en place sur les instances exposées.
Future<void> seedAdmin() async {
final result = _db.select('SELECT COUNT(*) as count FROM users');
final count = result.first['count'] as int;
if (count == 0) {
final adminId = _uuid.v4();
final passwordHash = PasswordHasher.hash('admin');
final envPassword = Platform.environment['ADMIN_INITIAL_PASSWORD'];
final generated = envPassword == null || envPassword.isEmpty;
final password = generated ? _generatePassword() : envPassword;
final passwordHash = PasswordHasher.hash(password);
_db.execute(
'''
@@ -144,9 +220,33 @@ class AppDatabase {
[adminId, 'admin', passwordHash],
);
print('Default admin user created (username: admin, password: admin)');
if (generated) {
print('╔══════════════════════════════════════════════════════════╗');
print(' Compte admin créé — mot de passe initial (affiché une');
print(' seule fois, changez-le après la première connexion) :');
print(' utilisateur: admin');
print(' mot de passe: $password');
print('╚══════════════════════════════════════════════════════════╝');
} else {
print(
'Default admin user created (username: admin, '
'password: ADMIN_INITIAL_PASSWORD)',
);
}
}
}
static String _generatePassword({int length = 16}) {
// Sans caractères ambigus (0/O, 1/l/I) : le mot de passe est recopié
// depuis les logs du conteneur.
const chars =
'abcdefghijkmnopqrstuvwxyzABCDEFGHJKLMNPQRSTUVWXYZ23456789';
final random = Random.secure();
return List.generate(
length,
(_) => chars[random.nextInt(chars.length)],
).join();
}
// ==================== Users ====================
@@ -473,6 +573,11 @@ class AppDatabase {
final recordingId = _uuid.v4();
final now = DateTime.now().toIso8601String();
// Toujours stocker en UTC avec suffixe 'Z' : les comparaisons du scheduler
// et la déduplication des season passes reposent sur ce format unique.
final startUtc = startTime.toUtc();
final endUtc = endTime.toUtc();
_db.execute(
'''
INSERT INTO tv_recordings (id, user_id, channel_id, stream_url, title, start_time, end_time, created_at, updated_at)
@@ -484,8 +589,8 @@ class AppDatabase {
channelId,
streamUrl,
title,
startTime.toIso8601String(),
endTime.toIso8601String(),
startUtc.toIso8601String(),
endUtc.toIso8601String(),
now,
now,
],
@@ -497,21 +602,41 @@ class AppDatabase {
channelId: channelId,
streamUrl: streamUrl,
title: title,
startTime: startTime,
endTime: endTime,
startTime: startUtc,
endTime: endUtc,
status: 'scheduled',
createdAt: DateTime.parse(now),
updatedAt: DateTime.parse(now),
);
}
/// Lister tous les enregistrements (pour le Scheduler et l'admin)
/// Lister tous les enregistrements (pour l'admin)
List<Recording> getAllRecordings() {
final result =
_db.select('SELECT * FROM tv_recordings ORDER BY start_time ASC');
return result.map((row) => Recording.fromMap(row)).toList();
}
/// Enregistrements qui intéressent le scheduler : à lancer ou en cours.
/// Évite de désérialiser tout l'historique toutes les 10 secondes.
List<Recording> getPendingRecordings() {
final result = _db.select(
"SELECT * FROM tv_recordings WHERE status IN ('scheduled', 'recording') "
'ORDER BY start_time ASC',
);
return result.map((row) => Recording.fromMap(row)).toList();
}
/// Enregistrements terminés (completed/failed/cancelled) du plus ancien au
/// plus récent — utilisé par la rotation disque.
List<Recording> getFinishedRecordingsOldestFirst() {
final result = _db.select(
"SELECT * FROM tv_recordings WHERE status IN ('completed', 'failed', 'cancelled') "
'ORDER BY start_time ASC',
);
return result.map((row) => Recording.fromMap(row)).toList();
}
/// Lister les enregistrements d'un utilisateur spécifique
List<Recording> getUserRecordings(String userId) {
final result = _db.select(
@@ -551,6 +676,24 @@ class AppDatabase {
);
}
/// Réaligner la fenêtre d'un enregistrement planifié (programme déplacé
/// dans l'EPG depuis sa création par un season pass).
void updateRecordingWindow(String id, DateTime start, DateTime end) {
_db.execute(
'''
UPDATE tv_recordings
SET start_time = ?, end_time = ?, updated_at = ?
WHERE id = ?
''',
[
start.toUtc().toIso8601String(),
end.toUtc().toIso8601String(),
DateTime.now().toIso8601String(),
id,
],
);
}
/// Supprimer un enregistrement depuis la BDD (ne supprime pas le fichier)
void deleteRecording(String id) {
_db.execute('DELETE FROM tv_recordings WHERE id = ?', [id]);
@@ -564,12 +707,13 @@ class AppDatabase {
required String showTitle,
required String channelId,
required String streamUrl,
String matchMode = 'exact',
}) {
final id = _uuid.v4();
final now = DateTime.now().toIso8601String();
_db.execute(
'INSERT INTO season_passes (id, user_id, show_title, channel_id, stream_url, created_at) VALUES (?, ?, ?, ?, ?, ?)',
[id, userId, showTitle, channelId, streamUrl, now],
'INSERT INTO season_passes (id, user_id, show_title, channel_id, stream_url, match_mode, created_at) VALUES (?, ?, ?, ?, ?, ?, ?)',
[id, userId, showTitle, channelId, streamUrl, matchMode, now],
);
return {
'id': id,
@@ -577,12 +721,13 @@ class AppDatabase {
'show_title': showTitle,
'channel_id': channelId,
'stream_url': streamUrl,
'match_mode': matchMode,
'enabled': 1,
'created_at': now,
};
}
/// Lister tous les Season Passes
/// Lister les Season Passes actifs (pour le scheduler)
List<Map<String, dynamic>> getAllSeasonPasses() {
final result = _db.select(
'SELECT * FROM season_passes WHERE enabled = 1 ORDER BY created_at DESC',
@@ -590,23 +735,57 @@ class AppDatabase {
return result.map((r) => Map<String, dynamic>.from(r)).toList();
}
/// Lister les Season Passes d'un utilisateur (actifs ou non, pour l'API)
List<Map<String, dynamic>> getSeasonPassesForUser(String userId) {
final result = _db.select(
'SELECT * FROM season_passes WHERE user_id = ? ORDER BY created_at DESC',
[userId],
);
return result.map((r) => Map<String, dynamic>.from(r)).toList();
}
/// Récupérer un Season Pass par id (contrôle de propriété côté API)
Map<String, dynamic>? getSeasonPassById(String id) {
final result =
_db.select('SELECT * FROM season_passes WHERE id = ?', [id]);
if (result.isEmpty) return null;
return Map<String, dynamic>.from(result.first);
}
/// Supprimer un Season Pass
void deleteSeasonPass(String id) {
_db.execute('DELETE FROM season_passes WHERE id = ?', [id]);
}
/// Vérifier si un enregistrement existe déjà pour ce titre (déduplication)
/// Retourne true si un enregistrement non-échoué avec ce titre existe pour cetteémission programméeà la même heure
bool existsRecordingForEpisode(String title, DateTime startTime) {
// Normaliser le titre pour la comparaison (insensible casse, sans espaces doubles)
/// Cherche un enregistrement existant pour cet épisode (déduplication des
/// season passes) : même titre, début à ±[tolerance] près.
///
/// La comparaison par plage remplace l'ancienne égalité de chaîne, qui
/// échouait dès que le format stocké différait (avec/sans 'Z') ou que le
/// panneau décalait le programme de quelques secondes — l'épisode était
/// alors réenregistré en double.
Recording? findRecordingForEpisode(
String title,
DateTime startTime, {
Duration tolerance = const Duration(minutes: 2),
}) {
final startUtc = startTime.toUtc();
// Les dates sont stockées en ISO-8601 UTC : l'ordre lexicographique
// correspond à l'ordre chronologique, un BETWEEN sur chaînes suffit.
final result = _db.select(
'''SELECT COUNT(*) as cnt FROM tv_recordings
'''SELECT * FROM tv_recordings
WHERE LOWER(title) = LOWER(?)
AND start_time = ?
AND status NOT IN ('failed')''',
[title, startTime.toUtc().toIso8601String()],
AND start_time BETWEEN ? AND ?
AND status NOT IN ('failed', 'cancelled')
LIMIT 1''',
[
title,
startUtc.subtract(tolerance).toIso8601String(),
startUtc.add(tolerance).toIso8601String(),
],
);
return (result.first['cnt'] as int) > 0;
if (result.isEmpty) return null;
return Recording.fromMap(result.first);
}
/// Close database connection
+6 -1
View File
@@ -77,7 +77,12 @@ Middleware streamAuthMiddleware(AppDatabase db) {
};
}
/// Extract token from Authorization header or cookie
/// Extract token from Authorization header or cookie.
/// Public : le proxy /api/xtream fait sa propre vérification de session
/// (le contrôle doit rester DANS le handler, après le test de chemin,
/// pour que les requêtes non-proxy tombent sur le handler statique).
String? extractAuthToken(Request request) => _extractToken(request);
String? _extractToken(Request request) {
// Try Authorization header first
final authHeader = request.headers['authorization'];
+82 -11
View File
@@ -1,26 +1,94 @@
import 'package:shelf/shelf.dart';
import 'dart:async';
import 'dart:io';
import '../utils/log_redactor.dart';
/// Security Middleware Collection
///
/// Includes:
/// - Redacted request logging
/// - Honeypot Routes (Trap for bots)
/// - Security Headers (HSTS, XSS Protection, CSP Report-Only)
/// - Rate Limiting (Basic DoS protection)
/// - Login-specific rate limiting (brute-force protection)
/// Resolve the real client IP.
/// Honors the first hop of X-Forwarded-For when behind nginx, otherwise
/// falls back to the socket connection info.
String clientIpOf(Request request) {
final forwarded = request.headers['x-forwarded-for'];
if (forwarded != null && forwarded.isNotEmpty) {
return forwarded.split(',').first.trim();
/// Proxys de confiance dont l'en-tête X-Forwarded-For est honoré.
/// Par défaut : loopback et plages privées RFC1918 (le reverse proxy du
/// docker-compose parle depuis le réseau Docker). Surcharger avec
/// TRUSTED_PROXIES (liste d'IP séparées par des virgules) pour restreindre.
final List<String> _trustedProxies =
(Platform.environment['TRUSTED_PROXIES'] ?? '')
.split(',')
.map((s) => s.trim())
.where((s) => s.isNotEmpty)
.toList();
bool _isTrustedProxy(String address) {
if (_trustedProxies.isNotEmpty) return _trustedProxies.contains(address);
final ip = InternetAddress.tryParse(address);
if (ip == null) return false;
if (ip.isLoopback) return true;
if (ip.type == InternetAddressType.IPv4) {
final parts = ip.address.split('.').map(int.parse).toList();
if (parts[0] == 10) return true;
if (parts[0] == 172 && parts[1] >= 16 && parts[1] <= 31) return true;
if (parts[0] == 192 && parts[1] == 168) return true;
}
return false;
}
/// Resolve the real client IP.
///
/// X-Forwarded-For n'est honoré que si la connexion socket provient d'un
/// proxy de confiance : sinon un client direct peut forger l'en-tête et
/// contourner le rate limit global comme la limite de tentatives de login.
String clientIpOf(Request request) {
final connectionInfo =
request.context['shelf.io.connection_info'] as HttpConnectionInfo?;
return connectionInfo?.remoteAddress.address ?? 'unknown';
final socketAddress = connectionInfo?.remoteAddress.address;
final forwarded = request.headers['x-forwarded-for'];
if (forwarded != null &&
forwarded.isNotEmpty &&
socketAddress != null &&
_isTrustedProxy(socketAddress)) {
return forwarded.split(',').first.trim();
}
return socketAddress ?? 'unknown';
}
/// 0. Redacted request logging.
///
/// Remplace `logRequests()` de shelf : le chemin `/api/xtream/<url>` embarque
/// `username`/`password` Xtream en clair dans l'URI, que le logger standard
/// écrivait tels quels — annulant l'effort de LogRedactor partout ailleurs.
Middleware redactedLogRequests() {
return (Handler handler) {
return (Request request) async {
final watch = Stopwatch()..start();
try {
final response = await handler(request);
watch.stop();
final query =
request.requestedUri.hasQuery ? '?${request.requestedUri.query}' : '';
print(
'${DateTime.now().toIso8601String()} ${response.statusCode} '
'${request.method} '
'${LogRedactor.redactUrl('${request.requestedUri.path}$query')} '
'(${watch.elapsedMilliseconds}ms)',
);
return response;
} catch (e) {
watch.stop();
print(
'${DateTime.now().toIso8601String()} ERR ${request.method} '
'${LogRedactor.redactUrl(request.requestedUri.path)}: '
'${LogRedactor.redactUrl('$e')}',
);
rethrow;
}
};
};
}
/// 1. Security Headers Middleware
@@ -71,11 +139,14 @@ Middleware honeypotMiddleware() {
return (Handler handler) {
return (Request request) {
final path = request.url.path;
// Comparaison sur le chemin exact ou un préfixe de segment. L'ancienne
// comparaison `contains(trap.replaceAll('/', ''))` bloquait toute URL
// contenant « console », « env » ou « wpadmin » n'importe où — y
// compris des URLs proxifiées parfaitement légitimes.
final path = '/${request.url.path}';
// Check if path contains any honeypot target
for (final trap in honeypotPaths) {
if (path.contains(trap.replaceAll('/', ''))) { // Simple check
if (path == trap || path.startsWith('$trap/')) {
print('SECURITY ALERT: Honeypot triggered by ${clientIpOf(request)} on path: $path');
return Response.forbidden('Access Denied');
}
+32 -22
View File
@@ -42,31 +42,37 @@ void main(List<String> args) async {
await db.seedAdmin();
// Initialize and start Recording Scheduler
// (les Season Passes résolvent la playlist de leur propriétaire à chaque
// scan : plus d'injection figée du premier utilisateur au démarrage)
final recordingScheduler = RecordingScheduler(db);
recordingScheduler.start();
// Injecter la config playlist dans le scheduler pour les Season Passes
// (on prend la playlist du premier utilisateur disponible)
Future<void> injectPlaylistToScheduler() async {
final users = db.getAllUsers();
if (users.isNotEmpty) {
final playlists = db.getPlaylists(users[0].id);
if (playlists.isNotEmpty) {
final p = playlists.first;
recordingScheduler.playlistDns = p.serverUrl;
recordingScheduler.playlistUsername = p.username;
recordingScheduler.playlistPassword = p.password;
print('[Server] Playlist injectée dans le scheduler: ${p.name}');
}
}
}
// Injecter après 5s pour attendre l'initialisation complète
Future.delayed(const Duration(seconds: 5), injectPlaylistToScheduler);
// Initialize Streaming Subsystem
await initStreaming();
// Arrêt gracieux unique (docker stop / Ctrl+C) : clôturer d'abord les
// enregistrements (kill FFmpeg, fusion des parties, statut en base), puis
// les sessions de streaming, puis sortir. Sans cela les enregistrements
// restaient au statut « recording » et la reprise d'orphelins devait
// systématiquement rattraper au redémarrage.
var shuttingDown = false;
Future<void> shutdownServer(String signal) async {
if (shuttingDown) return;
shuttingDown = true;
print('[Server] $signal reçu, arrêt en cours…');
try {
await recordingScheduler.shutdown();
} catch (e) {
print('[Server] Erreur à l\'arrêt du scheduler: $e');
}
sessionManager.killAll();
db.close();
exit(0);
}
ProcessSignal.sigterm.watch().listen((_) => shutdownServer('SIGTERM'));
ProcessSignal.sigint.watch().listen((_) => shutdownServer('SIGINT'));
// Helper to get playlist from request
Future<PlaylistConfig?> getPlaylist(Request request) async {
Playlist? playlist;
@@ -114,7 +120,7 @@ void main(List<String> args) async {
final playlistsHandler = PlaylistsHandler(db);
final usersHandler = UsersHandler(db);
final settingsHandler = SettingsHandler(db);
final proxyHandler = ProxyHandler(getPlaylist);
final proxyHandler = ProxyHandler(getPlaylist, db);
final recordingsApi = RecordingsApi(db, recordingScheduler);
// Source XMLTV de repli. Vider EPG_XMLTV_URLS désactive tout appel sortant :
// l'EPG se limite alors au panneau de l'abonné.
@@ -216,8 +222,10 @@ void main(List<String> args) async {
// Do NOT mount here as it would intercept and block the actual proxy
// Initialize Cleanup Service
// Ne JAMAIS cibler Directory.systemTemp en récursif : il contient les
// temporaires de la VM Dart et le dossier des sessions HLS — les fichiers
// de plus de 24 h y étaient supprimés aveuglément.
final cleanupService = CleanupService();
cleanupService.addTarget(Directory.systemTemp);
cleanupService.addTarget(Directory('/app/data/logs'));
cleanupService.addTarget(Directory('/app/data/tmp'));
@@ -332,8 +340,10 @@ void main(List<String> args) async {
.handler;
// Add middleware
// redactedLogRequests remplace logRequests() : l'URI de /api/xtream/<url>
// contient username/password Xtream en clair.
final pipeline = const Pipeline()
.addMiddleware(logRequests())
.addMiddleware(redactedLogRequests())
.addMiddleware(securityHeadersMiddleware())
.addMiddleware(honeypotMiddleware())
.addMiddleware(rateLimitMiddleware())
+4 -9
View File
@@ -59,15 +59,10 @@ class FfmpegSessionManager {
_reaper = Timer.periodic(const Duration(seconds: 60), (_) => _reap());
// Clean shutdown for docker stop / Ctrl+C
ProcessSignal.sigterm.watch().listen((_) {
killAll();
exit(0);
});
ProcessSignal.sigint.watch().listen((_) {
killAll();
exit(0);
});
// L'arrêt propre (docker stop / Ctrl+C) est orchestré par server.dart :
// il clôture d'abord les enregistrements puis appelle killAll(). Un
// handler local qui ferait exit(0) immédiatement court-circuiterait
// cette clôture.
}
FfmpegSession? get(String id) => _sessions[id];
+225 -49
View File
@@ -127,14 +127,34 @@ class RecordingScheduler {
) ??
2;
// Playlist config pour les appels EPG des season passes
// Rempli depuis server.dart après initialisation
String? playlistDns;
String? playlistUsername;
String? playlistPassword;
/// Espace libre minimal (Mo) exigé pour démarrer une capture.
final int minFreeDiskMb = int.tryParse(
Platform.environment['MIN_FREE_DISK_MB'] ?? '',
) ??
500;
/// Quota du dossier d'enregistrements en Go (0 = rotation désactivée).
/// Remplace l'ancienne rotation « max 50 fichiers » qui supprimait
/// aveuglément, y compris des enregistrements en cours d'écriture.
final int recordingsQuotaGb = int.tryParse(
Platform.environment['RECORDINGS_QUOTA_GB'] ?? '',
) ??
0;
/// Nombre maximal d'enregistrements créés par season pass et par scan.
final int seasonPassMaxPerScan = int.tryParse(
Platform.environment['SEASON_PASS_MAX_PER_SCAN'] ?? '',
) ??
10;
RecordingScheduler(this._db);
/// Un enregistrement est-il activement capturé par un processus FFmpeg ?
bool isCapturing(String id) => _active.containsKey(id);
/// Nombre de relances FFmpeg de l'enregistrement actif [id] (null si inactif).
int? retryCountOf(String id) => _active[id]?.consecutiveFailures;
void start() {
print(
'[RecordingScheduler] Démarrage du planificateur d\'enregistrements TV '
@@ -163,6 +183,31 @@ class RecordingScheduler {
print('[RecordingScheduler] Arrêté');
}
/// Arrêt gracieux pour un `docker stop` : arrête les timers, clôt chaque
/// enregistrement actif (kill FFmpeg, fusion des parties, statut en base)
/// et attend la fin des clôtures dans la limite de [timeout].
///
/// Sans cette attente, le conteneur meurt avant la clôture : les
/// enregistrements restent au statut « recording » et la reprise d'orphelins
/// doit systématiquement rattraper au redémarrage.
Future<void> shutdown({Duration timeout = const Duration(seconds: 8)}) async {
_timer?.cancel();
_seasonPassTimer?.cancel();
final closings = <Future<void>>[];
for (final id in _active.keys.toList()) {
final future =
_stopActiveRecording(id, reason: 'Arrêt du serveur');
if (future != null) closings.add(future);
}
if (closings.isNotEmpty) {
print(
'[RecordingScheduler] Clôture de ${closings.length} enregistrement(s)…',
);
await Future.wait(closings).timeout(timeout, onTimeout: () => const []);
}
print('[RecordingScheduler] Arrêté proprement');
}
Future<void> _checkAndRunRecordings() async {
if (_isRunning) return;
_isRunning = true;
@@ -185,7 +230,7 @@ class RecordingScheduler {
// Lire la base APRÈS les arrêts : un instantané pris avant ferait passer
// l'enregistrement tout juste terminé pour un orphelin (il n'est plus
// dans `_active` alors que l'instantané le dit encore « recording »).
final recordings = _db.getAllRecordings();
final recordings = _db.getPendingRecordings();
// Rechercher les enregistrements planifiés
for (final recording in recordings) {
@@ -286,21 +331,22 @@ class RecordingScheduler {
}
}
/// Normalise un titre pour la correspondance : minuscules, espaces réduits.
static String _normalizeTitle(String title) =>
title.toLowerCase().trim().replaceAll(RegExp(r'\s+'), ' ');
/// Le titre EPG [title] correspond-il au pass selon son [matchMode] ?
static bool _titleMatches(String title, String showTitle, String matchMode) {
final t = _normalizeTitle(title);
final s = _normalizeTitle(showTitle);
if (s.isEmpty) return false;
return matchMode == 'contains' ? t.contains(s) : t == s;
}
Future<void> _checkSeasonPasses() async {
final passes = _db.getAllSeasonPasses();
if (passes.isEmpty) return;
final dns = playlistDns;
final username = playlistUsername;
final password = playlistPassword;
if (dns == null || username == null || password == null) {
print(
'[SeasonPass] Config playlist non disponible, vérification annulée',
);
return;
}
print('[SeasonPass] Vérification de ${passes.length} Season Pass(s)...');
for (final pass in passes) {
@@ -309,9 +355,28 @@ class RecordingScheduler {
final streamUrl = pass['stream_url'] as String;
final showTitle = pass['show_title'] as String;
final userId = pass['user_id'] as String;
final matchMode = pass['match_mode'] as String? ?? 'contains';
// Récupérer l'EPG de la chaîne (48 prochaines heures)
final url = '$dns/player_api.php?username=$username&password=$password'
// Résoudre la playlist du PROPRIÉTAIRE du pass au moment du scan.
// L'ancienne config injectée au démarrage venait du premier utilisateur
// de la base et n'était jamais réactualisée : une playlist ajoutée
// après coup rendait les passes muets, et en multi-utilisateurs les
// credentials de l'un servaient aux passes d'un autre.
final playlists = _db.getPlaylists(userId);
if (playlists.isEmpty) {
print(
'[SeasonPass] Aucune playlist pour le propriétaire du pass '
'"$showTitle", scan ignoré',
);
continue;
}
final playlist = playlists.first;
// Récupérer l'EPG de la chaîne (les prochains programmes ; `limit`
// est un nombre de programmes, pas des heures)
final url =
'${playlist.serverUrl}/player_api.php?username=${playlist.username}'
'&password=${playlist.password}'
'&action=get_simple_data_table&stream_id=$channelId&type=epg&limit=48';
final response =
@@ -322,14 +387,14 @@ class RecordingScheduler {
final listings =
(raw is Map ? raw['epg_listings'] : raw) as List<dynamic>? ?? [];
var createdThisScan = 0;
for (final item in listings) {
String title = item['title'] as String? ?? '';
try {
title = utf8.decode(base64Decode(title));
} catch (_) {}
// Vérifier si le titre correspond au Season Pass (insensible casse, recherche partielle)
if (!title.toLowerCase().contains(showTitle.toLowerCase())) continue;
if (!_titleMatches(title, showTitle, matchMode)) continue;
// Parser les heures de début/fin
final startStr = item['start'] as String? ?? '';
@@ -348,12 +413,31 @@ class RecordingScheduler {
// Ne pas créer pour les programmes déjà terminés
if (endTime.isBefore(DateTime.now().toUtc())) continue;
// Déduplication : vérifier si cet épisode est déjà planifié/enregistré
if (_db.existsRecordingForEpisode(title, startTime)) {
print('[SeasonPass] "$title" déjà enregistré, skip.');
// Déduplication : cet épisode est-il déjà planifié/enregistré ?
final existing = _db.findRecordingForEpisode(title, startTime);
if (existing != null) {
// Programme déplacé dans l'EPG depuis la planification :
// réaligner la fenêtre tant que la capture n'a pas commencé.
if (existing.status == 'scheduled' &&
(existing.startTime.toUtc() != startTime ||
existing.endTime.toUtc() != endTime)) {
_db.updateRecordingWindow(existing.id, startTime, endTime);
print(
'[SeasonPass] "$title" réaligné sur le nouvel horaire '
'${startTime.toLocal()}',
);
}
continue;
}
if (createdThisScan >= seasonPassMaxPerScan) {
print(
'[SeasonPass] Plafond de $seasonPassMaxPerScan créations atteint '
'pour "$showTitle" sur ce scan',
);
break;
}
// Créer l'enregistrement automatiquement
_db.createRecording(
userId: userId,
@@ -363,12 +447,18 @@ class RecordingScheduler {
startTime: startTime,
endTime: endTime,
);
createdThisScan++;
print(
'[SeasonPass] ✓ Planifié automatiquement: "$title" le ${startTime.toLocal()}',
);
}
} catch (e) {
print('[SeasonPass] Erreur pour le pass "${pass['show_title']}": $e');
// Ne jamais imprimer l'exception brute : une ClientException peut
// contenir l'URL amont avec les credentials Xtream.
print(
'[SeasonPass] Erreur pour le pass "${pass['show_title']}": '
'${LogRedactor.redactUrl('$e')}',
);
}
}
}
@@ -392,6 +482,24 @@ class RecordingScheduler {
// Nettoyer l'espace disque si nécessaire
await _checkDiskSpaceAndRotate(recordingsDir);
// Refuser de démarrer sur un volume plein : mieux vaut un échec
// explicite immédiat qu'une capture qui meurt à mi-parcours.
final freeBytes = await _freeDiskBytes(recordingsDir.path);
if (freeBytes != null && freeBytes < minFreeDiskMb * 1024 * 1024) {
final freeMb = freeBytes ~/ (1024 * 1024);
print(
'[RecordingScheduler] Espace disque insuffisant ($freeMb Mo libres, '
'minimum $minFreeDiskMb Mo) : "${recording.title}" refusé',
);
_db.updateRecordingStatus(
recording.id,
'failed',
errorReason:
'Espace disque insuffisant ($freeMb Mo libres, minimum $minFreeDiskMb Mo)',
);
return;
}
final filePath = p.join(recordingsDir.path, _fileNameFor(recording));
final logPath = p.setExtension(filePath, '.log');
@@ -673,9 +781,11 @@ class RecordingScheduler {
return false; // Pas d'enregistrement actif avec cet ID
}
void _stopActiveRecording(String id, {String? reason}) {
/// Arrête l'enregistrement actif [id] et retourne la future de clôture
/// (fusion des parties + statut), ou null si aucun n'est actif.
Future<void>? _stopActiveRecording(String id, {String? reason}) {
final active = _active.remove(id);
if (active == null) return;
if (active == null) return null;
active.stopping = true;
if (reason != null) {
print('[RecordingScheduler] Arrêt: $reason (${active.recording.title})');
@@ -685,7 +795,9 @@ class RecordingScheduler {
);
active.process?.kill(ProcessSignal.sigterm);
_db.updateRecordingStatus(id, 'completed');
unawaited(_finalizeStopped(active));
final closing = _finalizeStopped(active);
unawaited(closing);
return closing;
}
/// Attend la fin effective de FFmpeg puis clôture proprement (fusion + log).
@@ -709,7 +821,9 @@ class RecordingScheduler {
}
String _fileNameFor(Recording recording) {
// Génération d'un nom de fichier unique et sûr
// Génération d'un nom de fichier unique et sûr. Le fragment d'id garantit
// l'unicité : deux utilisateurs enregistrant le même programme sur la même
// chaîne s'écrasaient mutuellement (FFmpeg est lancé avec -y).
final safeTitle =
recording.title.replaceAll(RegExp(r'[^a-zA-Z0-9_\-]'), '_');
final dateStr = recording.startTime
@@ -717,7 +831,10 @@ class RecordingScheduler {
.toIso8601String()
.replaceAll(':', '')
.split('.')[0];
return '${safeTitle}_$dateStr.mkv';
final idFragment = recording.id.length >= 8
? recording.id.substring(0, 8)
: recording.id;
return '${safeTitle}_${dateStr}_$idFragment.mkv';
}
/// Chemin de la n-ième partie (la partie 1 étant le fichier principal).
@@ -755,28 +872,87 @@ class RecordingScheduler {
} catch (_) {}
}
Future<void> _checkDiskSpaceAndRotate(Directory dir) async {
// Cette fonction pourrait invoquer une commande système `df` ou simplement lister les fichiers
// et supprimer les plus anciens si un quota (ex: max 20 Go) est atteint.
// Pour l'implémentation initiale, nous pouvons lister et supprimer si plus de X fichiers
/// Espace libre (octets) sur le volume qui porte [path], ou null si `df`
/// n'est pas disponible.
Future<int?> _freeDiskBytes(String path) async {
try {
const maxFiles = 50; // Nombre max d'enregistrements (exemple simpliste)
final files = dir.listSync().whereType<File>().toList();
if (files.length > maxFiles) {
print(
'[RecordingScheduler] Rotation de l\'espace disque : suppression des anciens enregistrements',
);
files.sort(
(a, b) => a.statSync().modified.compareTo(b.statSync().modified),
); // Du plus vieux au plus récent
// Supprimer les plus anciens pour revenir sous la limite
final filesToDelete = files.take(files.length - maxFiles);
for (var file in filesToDelete) {
file.deleteSync();
final result = await Process.run('df', ['-B1', '--output=avail', path]);
if (result.exitCode != 0) return null;
final lines = (result.stdout as String).trim().split('\n');
return int.tryParse(lines.last.trim());
} catch (_) {
return null;
}
}
/// Fichiers sur disque associés à un enregistrement : fichier principal,
/// log, et parties issues des relances.
List<String> filesFor(String filePath) {
final paths = <String>[filePath, p.setExtension(filePath, '.log')];
for (var attempt = 1;; attempt++) {
final part = _partPath(filePath, attempt);
if (!File(part).existsSync()) break;
paths.add(part);
}
return paths;
}
/// Supprime les fichiers d'un enregistrement (appelé par l'API à la
/// suppression, et par la rotation disque).
Future<void> deleteRecordingFiles(String filePath) async {
for (final path in filesFor(filePath)) {
await _deleteQuietly(path);
}
}
/// Rotation par quota d'octets (env RECORDINGS_QUOTA_GB, 0 = désactivée).
///
/// Remplace l'ancienne rotation « max 50 fichiers » qui supprimait les plus
/// anciens fichiers du dossier sans distinction : elle pouvait effacer une
/// partie en cours d'écriture par FFmpeg et laissait en base des lignes
/// pointant vers des fichiers disparus. Ici on ne supprime que des
/// enregistrements TERMINÉS connus de la base, du plus ancien au plus
/// récent, fichiers et ligne BDD ensemble, jamais un enregistrement actif.
Future<void> _checkDiskSpaceAndRotate(Directory dir) async {
if (recordingsQuotaGb <= 0) return;
try {
final quotaBytes = recordingsQuotaGb * 1024 * 1024 * 1024;
var totalBytes = 0;
await for (final entity in dir.list()) {
if (entity is File) {
try {
totalBytes += await entity.length();
} catch (_) {}
}
}
if (totalBytes <= quotaBytes) return;
print(
'[RecordingScheduler] Quota disque dépassé '
'(${totalBytes ~/ (1024 * 1024)} Mo > $recordingsQuotaGb Go) : '
'rotation des enregistrements terminés les plus anciens',
);
for (final old in _db.getFinishedRecordingsOldestFirst()) {
if (totalBytes <= quotaBytes) break;
final path = old.filePath;
if (path == null) continue;
// Jamais un enregistrement encore capturé (statut périmé en base).
if (_active.containsKey(old.id)) continue;
var freed = 0;
for (final f in filesFor(path)) {
try {
freed += File(f).existsSync() ? File(f).lengthSync() : 0;
} catch (_) {}
}
await deleteRecordingFiles(path);
_db.deleteRecording(old.id);
totalBytes -= freed;
print(
'[RecordingScheduler] Rotation : "${old.title}" supprimé '
'(${freed ~/ (1024 * 1024)} Mo libérés)',
);
}
} catch (e) {
print(
'[RecordingScheduler] Erreur lors de la rotation de l\'espace disque : $e',
+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(),
}),
);
}
@@ -1,5 +1,6 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../auth/providers/auth_provider.dart';
import '../../../core/models/playlist_config.dart';
import '../../../core/theme/app_colors.dart';
import '../../../core/widgets/glass_container.dart';
@@ -200,13 +201,61 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
const Spacer(),
// Settings / Profile
// Profil : nom d'utilisateur + déconnexion. L'avatar était
// un bouton mort (onTap vide) — aucune déconnexion possible
// depuis le dashboard desktop.
Padding(
padding: const EdgeInsets.only(bottom: 24),
child: TvFocusableCard(
onTap: () {},
borderRadius: 50,
scaleFactor: 1.1,
child: PopupMenuButton<String>(
tooltip: 'Profil',
color: AppColors.surfaceContainerHigh,
offset: const Offset(56, -12),
itemBuilder: (context) => [
PopupMenuItem<String>(
enabled: false,
child: Row(
children: [
const Icon(
Icons.person,
size: 18,
color: AppColors.textSecondary,
),
const SizedBox(width: 8),
Text(
ref.read(authProvider).currentUser?.username ??
'Utilisateur',
style: const TextStyle(
color: AppColors.onSurface,
fontWeight: FontWeight.bold,
),
),
],
),
),
const PopupMenuDivider(),
const PopupMenuItem<String>(
value: 'logout',
child: Row(
children: [
Icon(
Icons.logout,
size: 18,
color: AppColors.textSecondary,
),
SizedBox(width: 8),
Text(
'Déconnexion',
style: TextStyle(color: AppColors.onSurface),
),
],
),
),
],
onSelected: (value) {
if (value == 'logout') {
ref.read(authProvider.notifier).logout();
}
},
child: const CircleAvatar(
radius: 20,
backgroundColor: AppColors.surfaceContainerHigh,
+4 -2
View File
@@ -271,8 +271,10 @@ class _PlayerScreenState extends ConsumerState<PlayerScreen> {
});
}
// Update watch history if relevant
if (currentTime > 0) {
// Update watch history if relevant. Jamais en live : la « position »
// d'un flux continu n'a pas de sens et polluait le stockage avec une
// entrée bidon par chaîne zappée.
if (currentTime > 0 && widget.streamType != StreamType.live) {
ref.read(playbackPositionsProvider.notifier).savePosition(
widget.streamId,
currentTime,
@@ -5,6 +5,7 @@ import 'package:google_fonts/google_fonts.dart';
import '../../../core/models/playlist_config.dart';
import '../models/xtream_models.dart';
import '../providers/xtream_provider.dart';
import '../providers/playback_positions_provider.dart';
import '../providers/watch_history_provider.dart';
import '../../../core/theme/app_colors.dart';
import 'player_screen.dart';
@@ -288,6 +289,13 @@ class _SeriesDetailScreenState extends ConsumerState<SeriesDetailScreen> {
if (!context.mounted) return;
// Reprise de lecture : le player sauvegarde la position sous
// episode.id, on la relit ici pour reprendre où on s'était arrêté.
final positions = ref.read(playbackPositionsProvider);
final resumeAt = positions.hasPosition(episode.id)
? positions.getPosition(episode.id)
: null;
Navigator.push(
context,
MaterialPageRoute(
@@ -298,6 +306,7 @@ class _SeriesDetailScreenState extends ConsumerState<SeriesDetailScreen> {
streamType: StreamType.series,
containerExtension: episode.containerExtension ?? 'mkv',
duration: episodeDuration,
startTime: resumeAt,
),
),
);
+54 -2
View File
@@ -63,8 +63,23 @@ class _LiveTVTabState extends ConsumerState<LiveTVTab>
body: channelsAsync.when(
loading: () => const ThemedLoading(),
error: (e, s) => Center(
child: Text('Error: $e',
style: const TextStyle(color: AppColors.onSurface)),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const Text(
'Impossible de charger les chaînes',
style: TextStyle(color: AppColors.onSurface),
),
const SizedBox(height: 12),
OutlinedButton.icon(
icon: const Icon(Icons.refresh, size: 18),
label: const Text('Réessayer'),
onPressed: () => ref.invalidate(
liveChannelsByPlaylistProvider(widget.playlist),
),
),
],
),
),
data: (groupedChannels) {
var categories = groupedChannels.keys.toList();
@@ -515,6 +530,43 @@ class _LiveTVTabState extends ConsumerState<LiveTVTab>
),
],
),
// Favori (Positioned top left) — toggleFavorite n'était appelé
// nulle part : le filtre « Favoris » affichait toujours vide.
Positioned(
top: 8,
left: 8,
child: Consumer(
builder: (context, ref, _) {
final isFav = ref
.watch(favoritesProvider)
.contains(channel.streamId);
return TvFocusableCard(
onTap: () => ref
.read(favoritesProvider.notifier)
.toggleFavorite(channel.streamId),
borderRadius: 20,
scaleFactor: 1.2,
semanticLabel: isFav
? 'Retirer ${channel.name} des favoris'
: 'Ajouter ${channel.name} aux favoris',
child: Container(
padding: const EdgeInsets.all(6),
decoration: BoxDecoration(
color: Colors.black.withOpacity(0.5),
shape: BoxShape.circle,
),
child: Icon(
isFav ? Icons.favorite : Icons.favorite_border,
color: isFav
? AppColors.primary
: AppColors.onSurface54,
size: 16,
),
),
);
},
),
),
// Record Button Icon Overlay (Positioned top right)
Positioned(
top: 8,
@@ -8,6 +8,7 @@ import '../../../core/utils/responsive_layout.dart';
import '../../../core/widgets/hero_carousel.dart';
import '../../../core/widgets/glass_container.dart';
import '../../../core/widgets/tv_focusable_card.dart';
import '../providers/playback_positions_provider.dart';
import '../providers/watch_history_provider.dart';
import '../models/xtream_models.dart';
import '../providers/xtream_provider.dart';
@@ -163,6 +164,13 @@ class _MoviesTabState extends ConsumerState<MoviesTab> {
if (!mounted) return;
// Reprise de lecture : les positions étaient sauvegardées par le player
// mais jamais relues — le film repartait systématiquement de zéro.
final positions = ref.read(playbackPositionsProvider);
final resumeAt = positions.hasPosition(movie.streamId)
? positions.getPosition(movie.streamId)
: null;
Navigator.push(
context,
MaterialPageRoute(
@@ -173,6 +181,7 @@ class _MoviesTabState extends ConsumerState<MoviesTab> {
streamType: StreamType.vod,
containerExtension: movie.containerExtension ?? 'mp4',
duration: movieDuration,
startTime: resumeAt,
),
),
);
+8 -14
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';
@@ -45,18 +44,13 @@ class _RecordingModalState extends State<RecordingModal> {
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) {
+155 -21
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';
@@ -14,6 +15,10 @@ import '../screens/player_screen.dart';
// ═══════════════════════════════════════════════════════════════════════════
// ENTRÉE — Onglet "Enregistrements"
// Trois vues : Guide TV (programmer depuis l'EPG), Enregistrements (liste),
// Season Passes (enregistrements récurrents). _EpgGuideView et
// _SeasonPassesView existaient déjà mais n'étaient plus instanciés depuis
// une refonte : les fonctions étaient codées mais inaccessibles.
// ═══════════════════════════════════════════════════════════════════════════
class RecordingsTab extends StatefulWidget {
@@ -24,7 +29,24 @@ class RecordingsTab extends StatefulWidget {
State<RecordingsTab> createState() => _RecordingsTabState();
}
class _RecordingsTabState extends State<RecordingsTab> {
class _RecordingsTabState extends State<RecordingsTab>
with SingleTickerProviderStateMixin {
late final TabController _tabController;
@override
void initState() {
super.initState();
// Ouvrir sur la liste des enregistrements (onglet du milieu), l'usage le
// plus fréquent ; le guide sert à en programmer de nouveaux.
_tabController = TabController(length: 3, vsync: this, initialIndex: 1);
}
@override
void dispose() {
_tabController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Container(
@@ -32,9 +54,12 @@ class _RecordingsTabState extends State<RecordingsTab> {
child: Column(
children: [
Container(
padding: const EdgeInsets.fromLTRB(24, 24, 24, 16),
padding: const EdgeInsets.fromLTRB(24, 24, 24, 0),
color: Colors.grey[900],
child: const Row(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Row(
children: [
Icon(Icons.videocam, color: AppColors.onSurface, size: 28),
SizedBox(width: 12),
@@ -48,9 +73,40 @@ class _RecordingsTabState extends State<RecordingsTab> {
),
],
),
const SizedBox(height: 8),
TabBar(
controller: _tabController,
isScrollable: true,
indicatorColor: AppColors.primary,
labelColor: AppColors.onSurface,
unselectedLabelColor: AppColors.onSurface54,
tabs: const [
Tab(
icon: Icon(Icons.calendar_month, size: 18),
text: 'Guide TV',
),
Tab(
icon: Icon(Icons.fiber_manual_record, size: 18),
text: 'Enregistrements',
),
Tab(
icon: Icon(Icons.repeat, size: 18),
text: 'Season Passes',
),
],
),
],
),
),
Expanded(
child: _RecordingsListView(playlist: widget.playlist),
child: TabBarView(
controller: _tabController,
children: [
_EpgGuideView(playlist: widget.playlist),
_RecordingsListView(playlist: widget.playlist),
const _SeasonPassesView(),
],
),
),
],
),
@@ -594,16 +650,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) {
@@ -825,7 +880,39 @@ class _RecordingsListViewState extends State<_RecordingsListView> {
}
}
Future<void> _deleteRecording(String id) async {
/// Demande confirmation avant suppression : l'ancienne corbeille supprimait
/// immédiatement, sans retour ni possibilité d'annuler.
Future<void> _deleteRecording(String id, String title) async {
final confirmed = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
backgroundColor: AppColors.surfaceContainer,
title: Text(
'Supprimer l\'enregistrement ?',
style: GoogleFonts.fraunces(color: AppColors.onSurface, fontSize: 18),
),
content: Text(
'« $title » et son fichier seront définitivement supprimés.',
style: const TextStyle(color: AppColors.onSurfaceVariant),
),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx, false),
child: const Text('Annuler'),
),
ElevatedButton(
style: ElevatedButton.styleFrom(
backgroundColor: AppColors.errorContainer,
foregroundColor: AppColors.onErrorContainer,
),
onPressed: () => Navigator.pop(ctx, true),
child: const Text('Supprimer'),
),
],
),
);
if (confirmed != true) return;
await AuthedHttp.delete(Uri.parse('/api/recordings/$id'));
_fetchRecordings();
}
@@ -914,6 +1001,7 @@ class _RecordingsListViewState extends State<_RecordingsListView> {
'recording' => AppColors.live,
'completed' => AppColors.success,
'failed' => AppColors.warning,
'cancelled' => AppColors.onSurface38,
_ => AppColors.primaryContainer,
};
@@ -922,9 +1010,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 {
@@ -974,9 +1073,20 @@ class _RecordingsListViewState extends State<_RecordingsListView> {
? const Center(child: CircularProgressIndicator())
: _error != null
? Center(
child: Text(
_error ?? 'Erreur',
style: const TextStyle(color: AppColors.live),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const Text(
'Impossible de charger les enregistrements',
style: TextStyle(color: AppColors.live),
),
const SizedBox(height: 12),
OutlinedButton.icon(
icon: const Icon(Icons.refresh, size: 18),
label: const Text('Réessayer'),
onPressed: _fetchRecordings,
),
],
),
)
: _recordings.isEmpty
@@ -1040,12 +1150,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']}',
@@ -1123,8 +1255,10 @@ class _RecordingsListViewState extends State<_RecordingsListView> {
size: 20,
),
tooltip: 'Supprimer',
onPressed: () =>
_deleteRecording(rec['id']),
onPressed: () => _deleteRecording(
rec['id'],
rec['title'] ?? '',
),
),
],
),
@@ -3,6 +3,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../../core/models/playlist_config.dart';
import '../../../widgets/mobile_scaffold.dart';
import '../../../theme/mobile_theme.dart';
import '../../../../features/iptv/widgets/recordings_tab.dart';
import '../widgets/mobile_live_tv_tab.dart';
import '../widgets/mobile_movies_tab.dart';
import '../widgets/mobile_series_tab.dart';
@@ -34,23 +35,20 @@ class _MobileDashboardScreenState extends ConsumerState<MobileDashboardScreen> {
_currentIndex = index;
});
},
child: _buildActiveTab(),
// IndexedStack conserve l'état des onglets (position de scroll,
// catalogues chargés) : l'ancien switch reconstruisait tout à chaque
// changement d'onglet. Même approche que le dashboard desktop.
child: IndexedStack(
index: _currentIndex,
children: [
MobileLiveTVTab(playlist: widget.playlist),
MobileMoviesTab(playlist: widget.playlist),
MobileSeriesTab(playlist: widget.playlist),
RecordingsTab(playlist: widget.playlist),
const MobileSettingsTab(),
],
),
),
);
}
Widget _buildActiveTab() {
switch (_currentIndex) {
case 0:
return MobileLiveTVTab(playlist: widget.playlist);
case 1:
return MobileMoviesTab(playlist: widget.playlist);
case 2:
return MobileSeriesTab(playlist: widget.playlist);
case 3:
return const MobileSettingsTab();
default:
return MobileLiveTVTab(playlist: widget.playlist);
}
}
}
@@ -335,7 +335,7 @@ class _MobileLiveTVTabState extends ConsumerState<MobileLiveTVTab> {
}
}
class _MobileChannelTile extends StatelessWidget {
class _MobileChannelTile extends ConsumerWidget {
final Channel channel;
final VoidCallback onTap;
@@ -345,7 +345,10 @@ class _MobileChannelTile extends StatelessWidget {
});
@override
Widget build(BuildContext context) {
Widget build(BuildContext context, WidgetRef ref) {
// Sans ce bouton, aucun moyen d'ajouter un favori : le filtre « Favoris »
// de l'en-tête affichait toujours une liste vide.
final isFav = ref.watch(favoritesProvider).contains(channel.streamId);
final iconUrl =
channel.streamIcon.isNotEmpty && channel.streamIcon.startsWith('http')
? '/api/xtream/${channel.streamIcon}'
@@ -393,6 +396,18 @@ class _MobileChannelTile extends StatelessWidget {
overflow: TextOverflow.ellipsis,
),
),
IconButton(
visualDensity: VisualDensity.compact,
tooltip: isFav ? 'Retirer des favoris' : 'Ajouter aux favoris',
icon: Icon(
isFav ? Icons.favorite : Icons.favorite_border,
color: isFav ? AppColors.primary : AppColors.onSurface54,
size: 20,
),
onPressed: () => ref
.read(favoritesProvider.notifier)
.toggleFavorite(channel.streamId),
),
Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
+5
View File
@@ -78,6 +78,11 @@ class MobileScaffold extends ConsumerWidget {
activeIcon: Icon(Icons.video_library_rounded),
label: 'Series',
),
BottomNavigationBarItem(
icon: Icon(Icons.videocam_outlined),
activeIcon: Icon(Icons.videocam_rounded),
label: 'REC',
),
BottomNavigationBarItem(
icon: Icon(Icons.settings_outlined),
activeIcon: Icon(Icons.settings_rounded),
+7 -1
View File
@@ -131,7 +131,10 @@
};
XFPlayer.prototype.send = function (msg) {
try { global.parent.postMessage(msg, '*'); } catch (e) {}
// Cible restreinte à notre origine : l'iframe est toujours même-origine
// que le parent Flutter, un wildcard '*' livrerait l'état du player à
// n'importe quelle page qui embarquerait player.html.
try { global.parent.postMessage(msg, global.location.origin); } catch (e) {}
};
// ---------------- Démarrage ----------------
@@ -491,6 +494,9 @@
XFPlayer.prototype._wireParentMessages = function () {
var self = this;
global.addEventListener('message', function (event) {
// N'accepter que les commandes émises par notre propre origine
// (le côté Flutter filtre déjà les messages entrants de la même façon).
if (event.origin !== global.location.origin) return;
var d = event.data;
if (!d || !d.type) return;
var v = self.video;