From 1d8717bb4077becf22888d16b4d1efb6bfb1dd4f Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 27 Aug 2026 12:11:56 +0000 Subject: [PATCH 1/4] =?UTF-8?q?Fiabilise=20le=20syst=C3=A8me=20d'enregistr?= =?UTF-8?q?ement=20de=20bout=20en=20bout?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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/ 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 Claude-Session: https://claude.ai/code/session_015oEu9QayWsw7hCKhenxgVa --- CHANGELOG.md | 12 + bin/api/recordings_api.dart | 228 +++++++++++---- bin/api/season_passes_api.dart | 55 +++- bin/database/database.dart | 183 ++++++++++-- bin/server.dart | 44 +-- bin/services/ffmpeg_session_manager.dart | 13 +- bin/services/recording_scheduler.dart | 270 +++++++++++++++--- lib/core/api/recording_requests.dart | 42 +++ .../iptv/widgets/recording_modal.dart | 24 +- lib/features/iptv/widgets/recordings_tab.dart | 56 +++- 10 files changed, 740 insertions(+), 187 deletions(-) create mode 100644 lib/core/api/recording_requests.dart diff --git a/CHANGELOG.md b/CHANGELOG.md index 481d259..c2f57cd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,18 @@ ## Non publié +### 🔧 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 diff --git a/bin/api/recordings_api.dart b/bin/api/recordings_api.dart index 67f732e..908d877 100644 --- a/bin/api/recordings_api.dart +++ b/bin/api/recordings_api.dart @@ -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 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/ /// Exposé séparément car shelf_router a un conflit entre DELETE / et GET /logs/ Future 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 _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 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 data; + try { + data = json.decode(await request.readAsString()) as Map; + } 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/ — 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 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/ — Arrête un enregistrement FFmpeg en cours Future 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 - _db.updateRecordingStatus(id, 'completed'); - return Response.ok( - json.encode({'message': 'Enregistrement marqué comme terminé'}), - headers: {'Content-Type': 'application/json'}, - ); + 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 _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é'}); } } diff --git a/bin/api/season_passes_api.dart b/bin/api/season_passes_api.dart index 686544a..9d96f56 100644 --- a/bin/api/season_passes_api.dart +++ b/bin/api/season_passes_api.dart @@ -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/ — 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'}, ); } diff --git a/bin/database/database.dart b/bin/database/database.dart index 71dc5a5..6d70089 100644 --- a/bin/database/database.dart +++ b/bin/database/database.dart @@ -23,10 +23,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 = { + // 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 _createTables() async { // Users table @@ -107,6 +165,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,6 +184,15 @@ 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 @@ -473,6 +541,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 +557,8 @@ class AppDatabase { channelId, streamUrl, title, - startTime.toIso8601String(), - endTime.toIso8601String(), + startUtc.toIso8601String(), + endUtc.toIso8601String(), now, now, ], @@ -497,21 +570,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 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 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 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 getUserRecordings(String userId) { final result = _db.select( @@ -551,6 +644,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 +675,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 +689,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> getAllSeasonPasses() { final result = _db.select( 'SELECT * FROM season_passes WHERE enabled = 1 ORDER BY created_at DESC', @@ -590,23 +703,57 @@ class AppDatabase { return result.map((r) => Map.from(r)).toList(); } + /// Lister les Season Passes d'un utilisateur (actifs ou non, pour l'API) + List> 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.from(r)).toList(); + } + + /// Récupérer un Season Pass par id (contrôle de propriété côté API) + Map? getSeasonPassById(String id) { + final result = + _db.select('SELECT * FROM season_passes WHERE id = ?', [id]); + if (result.isEmpty) return null; + return Map.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 - WHERE LOWER(title) = LOWER(?) - AND start_time = ? - AND status NOT IN ('failed')''', - [title, startTime.toUtc().toIso8601String()], + '''SELECT * FROM tv_recordings + WHERE LOWER(title) = LOWER(?) + 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 diff --git a/bin/server.dart b/bin/server.dart index 0db4644..e936e98 100644 --- a/bin/server.dart +++ b/bin/server.dart @@ -42,31 +42,37 @@ void main(List 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 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 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 getPlaylist(Request request) async { Playlist? playlist; diff --git a/bin/services/ffmpeg_session_manager.dart b/bin/services/ffmpeg_session_manager.dart index 2c5af8e..314aa0f 100644 --- a/bin/services/ffmpeg_session_manager.dart +++ b/bin/services/ffmpeg_session_manager.dart @@ -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]; diff --git a/bin/services/recording_scheduler.dart b/bin/services/recording_scheduler.dart index 27cabbf..1615f7d 100644 --- a/bin/services/recording_scheduler.dart +++ b/bin/services/recording_scheduler.dart @@ -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 shutdown({Duration timeout = const Duration(seconds: 8)}) async { + _timer?.cancel(); + _seasonPassTimer?.cancel(); + final closings = >[]; + 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 _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 _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? ?? []; + 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? _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 _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 _freeDiskBytes(String path) async { try { - const maxFiles = 50; // Nombre max d'enregistrements (exemple simpliste) - final files = dir.listSync().whereType().toList(); + 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; + } + } - 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 + /// Fichiers sur disque associés à un enregistrement : fichier principal, + /// log, et parties issues des relances. + List filesFor(String filePath) { + final paths = [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; + } - // Supprimer les plus anciens pour revenir sous la limite - final filesToDelete = files.take(files.length - maxFiles); - for (var file in filesToDelete) { - file.deleteSync(); + /// Supprime les fichiers d'un enregistrement (appelé par l'API à la + /// suppression, et par la rotation disque). + Future 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 _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', diff --git a/lib/core/api/recording_requests.dart b/lib/core/api/recording_requests.dart new file mode 100644 index 0000000..0a42c03 --- /dev/null +++ b/lib/core/api/recording_requests.dart @@ -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 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(), + }), + ); +} diff --git a/lib/features/iptv/widgets/recording_modal.dart b/lib/features/iptv/widgets/recording_modal.dart index 1c5352f..88f4b2e 100644 --- a/lib/features/iptv/widgets/recording_modal.dart +++ b/lib/features/iptv/widgets/recording_modal.dart @@ -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 { 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) { diff --git a/lib/features/iptv/widgets/recordings_tab.dart b/lib/features/iptv/widgets/recordings_tab.dart index 08de7bb..dd05c84 100644 --- a/lib/features/iptv/widgets/recordings_tab.dart +++ b/lib/features/iptv/widgets/recordings_tab.dart @@ -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']}', From ea63314ba7c8ddea4cad2ca91b5c3d0479069332 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 27 Aug 2026 12:19:58 +0000 Subject: [PATCH 2/4] =?UTF-8?q?Durcit=20la=20s=C3=A9curit=C3=A9=20du=20bac?= =?UTF-8?q?kend=20et=20du=20player?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Proxy /api/xtream : - Authentification de session rétablie (Authorization ou cookie HttpOnly session — les requêtes navigateur même-origine le portent) ; le proxy était volontairement ouvert, offrant un rebond SSRF non authentifié - Redirections suivies manuellement avec revalidation à chaque saut (hôte privé interdit + allowlist de domaine) : avec followRedirects, seule l'URL initiale était validée, une 302 amont suffisait pour atteindre un hôte interne - Erreurs proxy sans détail d'exception (ClientException porte l'URL amont, credentials Xtream inclus), logs redactés Logs : - redactedLogRequests remplace logRequests() de shelf : l'URI de /api/xtream/ écrivait username/password Xtream en clair à chaque requête, annulant l'effort de LogRedactor partout ailleurs - Les 500 d'epg_api ne renvoient plus e.toString() au client (même risque ClientException) ; détail redacté en log serveur Middleware : - X-Forwarded-For honoré uniquement depuis un proxy de confiance (loopback + RFC1918 par défaut, surchargables via TRUSTED_PROXIES) : un client direct forgeait l'en-tête et contournait le rate limit global comme la limite de tentatives de login - Honeypot comparé sur chemin exact/préfixe : l'ancien contains(trap.replaceAll('/','')) bloquait toute URL contenant console, env ou wpadmin, y compris des URLs proxifiées légitimes Comptes : - Mot de passe admin initial aléatoire (Random.secure, affiché une fois au démarrage) ou ADMIN_INITIAL_PASSWORD ; fini le admin/admin persistant - Longueur minimale de 8 caractères à la création et au changement Divers : - CleanupService ne cible plus Directory.systemTemp en récursif (il supprimait les temporaires de la VM Dart et le parent des sessions HLS) - web/xf-player-core.js : postMessage vers location.origin au lieu de '*', et filtrage d'event.origin à la réception (le côté Flutter le faisait déjà) Validé : dart analyze (0 issue) et dart test (48/48) sur bin/. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_015oEu9QayWsw7hCKhenxgVa --- bin/api/epg_api.dart | 13 +++- bin/api/proxy_handler.dart | 89 +++++++++++++++++++---- bin/api/users_handler.dart | 14 ++++ bin/database/database.dart | 38 +++++++++- bin/middleware/auth_middleware.dart | 7 +- bin/middleware/security_middleware.dart | 93 ++++++++++++++++++++++--- bin/server.dart | 10 ++- web/xf-player-core.js | 8 ++- 8 files changed, 236 insertions(+), 36 deletions(-) diff --git a/bin/api/epg_api.dart b/bin/api/epg_api.dart index fab003f..ea27153 100644 --- a/bin/api/epg_api.dart +++ b/bin/api/epg_api.dart @@ -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/?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', + }; } } diff --git a/bin/api/proxy_handler.dart b/bin/api/proxy_handler.dart index b6f2c6a..9ae9c66 100644 --- a/bin/api/proxy_handler.dart +++ b/bin/api/proxy_handler.dart @@ -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 Function(Request) _getPlaylist; + final AppDatabase _db; final http.Client _client = http.Client(); final Map _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,18 +195,61 @@ class ProxyHandler { } } - proxyRequest.headers.addAll(proxyHeaders); - proxyRequest.followRedirects = true; - + List? postBody; if (request.method == 'POST') { final bodyBytes = await request.read().toList(); - proxyRequest.bodyBytes = bodyBytes.expand((i) => i).toList(); + postBody = bodyBytes.expand((i) => i).toList(); } - // Added 90s timeout to allow frontend (60s) to time out gracefully first - final response = await _client - .send(proxyRequest) - .timeout(const Duration(seconds: 90)); + // 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 + 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 = { @@ -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'}, ); } diff --git a/bin/api/users_handler.dart b/bin/api/users_handler.dart index de3a7e9..886e71e 100644 --- a/bin/api/users_handler.dart +++ b/bin/api/users_handler.dart @@ -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'}); diff --git a/bin/database/database.dart b/bin/database/database.dart index 71dc5a5..4d4fdd5 100644 --- a/bin/database/database.dart +++ b/bin/database/database.dart @@ -1,4 +1,5 @@ import 'dart:io'; +import 'dart:math'; import 'package:sqlite3/sqlite3.dart'; import 'package:uuid/uuid.dart'; import '../models/user.dart'; @@ -127,14 +128,21 @@ class AppDatabase { ); } - /// 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 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,10 +152,34 @@ 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 ==================== /// Find user by username diff --git a/bin/middleware/auth_middleware.dart b/bin/middleware/auth_middleware.dart index 08b9be6..fb135fb 100644 --- a/bin/middleware/auth_middleware.dart +++ b/bin/middleware/auth_middleware.dart @@ -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']; diff --git a/bin/middleware/security_middleware.dart b/bin/middleware/security_middleware.dart index d896d80..9d307c3 100644 --- a/bin/middleware/security_middleware.dart +++ b/bin/middleware/security_middleware.dart @@ -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 _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/` 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'); } diff --git a/bin/server.dart b/bin/server.dart index 0db4644..1869280 100644 --- a/bin/server.dart +++ b/bin/server.dart @@ -114,7 +114,7 @@ void main(List 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 +216,10 @@ void main(List 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 +334,10 @@ void main(List args) async { .handler; // Add middleware + // redactedLogRequests remplace logRequests() : l'URI de /api/xtream/ + // contient username/password Xtream en clair. final pipeline = const Pipeline() - .addMiddleware(logRequests()) + .addMiddleware(redactedLogRequests()) .addMiddleware(securityHeadersMiddleware()) .addMiddleware(honeypotMiddleware()) .addMiddleware(rateLimitMiddleware()) diff --git a/web/xf-player-core.js b/web/xf-player-core.js index f873025..b164223 100644 --- a/web/xf-player-core.js +++ b/web/xf-player-core.js @@ -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; From aacfa546044a324d03399012a3fc02d9c4bca9df Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 27 Aug 2026 12:29:23 +0000 Subject: [PATCH 3/4] =?UTF-8?q?Rebranche=20les=20fonctionnalit=C3=A9s=20co?= =?UTF-8?q?d=C3=A9es=20mais=20inaccessibles?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Onglet Enregistrements : - TabBar à 3 vues : Guide TV (programmer depuis l'EPG), liste des enregistrements, Season Passes. _EpgGuideView et _SeasonPassesView (~800 lignes fonctionnelles) n'étaient plus instanciés depuis une refonte — programmer depuis le guide et les enregistrements récurrents étaient devenus inaccessibles - Confirmation avant suppression d'un enregistrement (la corbeille supprimait immédiatement, sans retour ni annulation) - Erreur de chargement avec bouton Réessayer Favoris : - Bouton cœur sur les tuiles chaînes desktop (overlay) et mobile : toggleFavorite() n'était appelé nulle part, le filtre « Favoris » affichait toujours une liste vide Reprise de lecture : - Films et épisodes lisent playbackPositionsProvider et passent startTime au player : les positions étaient écrites mais jamais relues, tout repartait de zéro - Plus de sauvegarde de position en live (une entrée bidon par chaîne zappée) Mobile : - 5e onglet REC réutilisant RecordingsTab - IndexedStack au lieu du switch : les onglets conservent scroll et catalogues chargés (aligné sur le dashboard desktop) Dashboard : - Menu profil sur l'avatar de la sidebar (nom d'utilisateur + déconnexion) : le bouton était mort, aucune déconnexion possible depuis le dashboard desktop - Erreur de chargement des chaînes avec bouton Réessayer Validé : flutter analyze (202 issues, identique à la baseline main, 0 erreur/warning), flutter test (OK), flutter build web --release (OK). Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_015oEu9QayWsw7hCKhenxgVa --- CHANGELOG.md | 8 + .../iptv/screens/dashboard_screen.dart | 59 +++++++- lib/features/iptv/screens/player_screen.dart | 6 +- .../iptv/screens/series_detail_screen.dart | 11 +- lib/features/iptv/widgets/live_tv_tab.dart | 56 ++++++- lib/features/iptv/widgets/movies_tab.dart | 9 ++ lib/features/iptv/widgets/recordings_tab.dart | 138 +++++++++++++++--- .../iptv/screens/mobile_dashboard_screen.dart | 30 ++-- .../iptv/widgets/mobile_live_tv_tab.dart | 19 ++- lib/mobile/widgets/mobile_scaffold.dart | 5 + 10 files changed, 294 insertions(+), 47 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 481d259..3020510 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,14 @@ ## Non publié +### ✨ 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 + ### 📺 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 diff --git a/lib/features/iptv/screens/dashboard_screen.dart b/lib/features/iptv/screens/dashboard_screen.dart index 288dc31..49a73b8 100644 --- a/lib/features/iptv/screens/dashboard_screen.dart +++ b/lib/features/iptv/screens/dashboard_screen.dart @@ -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 { 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( + tooltip: 'Profil', + color: AppColors.surfaceContainerHigh, + offset: const Offset(56, -12), + itemBuilder: (context) => [ + PopupMenuItem( + 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( + 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, diff --git a/lib/features/iptv/screens/player_screen.dart b/lib/features/iptv/screens/player_screen.dart index 786ff87..dad525e 100644 --- a/lib/features/iptv/screens/player_screen.dart +++ b/lib/features/iptv/screens/player_screen.dart @@ -271,8 +271,10 @@ class _PlayerScreenState extends ConsumerState { }); } - // 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, diff --git a/lib/features/iptv/screens/series_detail_screen.dart b/lib/features/iptv/screens/series_detail_screen.dart index db2deef..0e934e3 100644 --- a/lib/features/iptv/screens/series_detail_screen.dart +++ b/lib/features/iptv/screens/series_detail_screen.dart @@ -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'; @@ -287,7 +288,14 @@ class _SeriesDetailScreenState extends ConsumerState { } 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 { streamType: StreamType.series, containerExtension: episode.containerExtension ?? 'mkv', duration: episodeDuration, + startTime: resumeAt, ), ), ); diff --git a/lib/features/iptv/widgets/live_tv_tab.dart b/lib/features/iptv/widgets/live_tv_tab.dart index 25999ad..11083d4 100644 --- a/lib/features/iptv/widgets/live_tv_tab.dart +++ b/lib/features/iptv/widgets/live_tv_tab.dart @@ -63,8 +63,23 @@ class _LiveTVTabState extends ConsumerState 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 ), ], ), + // 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, diff --git a/lib/features/iptv/widgets/movies_tab.dart b/lib/features/iptv/widgets/movies_tab.dart index d9f2507..792d925 100644 --- a/lib/features/iptv/widgets/movies_tab.dart +++ b/lib/features/iptv/widgets/movies_tab.dart @@ -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 { 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 { streamType: StreamType.vod, containerExtension: movie.containerExtension ?? 'mp4', duration: movieDuration, + startTime: resumeAt, ), ), ); diff --git a/lib/features/iptv/widgets/recordings_tab.dart b/lib/features/iptv/widgets/recordings_tab.dart index 08de7bb..dc073fe 100644 --- a/lib/features/iptv/widgets/recordings_tab.dart +++ b/lib/features/iptv/widgets/recordings_tab.dart @@ -14,6 +14,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 +28,24 @@ class RecordingsTab extends StatefulWidget { State createState() => _RecordingsTabState(); } -class _RecordingsTabState extends State { +class _RecordingsTabState extends State + 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,25 +53,59 @@ class _RecordingsTabState extends State { 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: [ - Icon(Icons.videocam, color: AppColors.onSurface, size: 28), - SizedBox(width: 12), - Text( - 'Enregistrements', - style: TextStyle( - fontSize: 26, - fontWeight: FontWeight.bold, - color: AppColors.onSurface, - ), + const Row( + children: [ + Icon(Icons.videocam, color: AppColors.onSurface, size: 28), + SizedBox(width: 12), + Text( + 'Enregistrements', + style: TextStyle( + fontSize: 26, + fontWeight: FontWeight.bold, + color: AppColors.onSurface, + ), + ), + ], + ), + 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(), + ], + ), ), ], ), @@ -825,7 +880,39 @@ class _RecordingsListViewState extends State<_RecordingsListView> { } } - Future _deleteRecording(String id) async { + /// Demande confirmation avant suppression : l'ancienne corbeille supprimait + /// immédiatement, sans retour ni possibilité d'annuler. + Future _deleteRecording(String id, String title) async { + final confirmed = await showDialog( + 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(); } @@ -974,9 +1061,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 @@ -1123,8 +1221,10 @@ class _RecordingsListViewState extends State<_RecordingsListView> { size: 20, ), tooltip: 'Supprimer', - onPressed: () => - _deleteRecording(rec['id']), + onPressed: () => _deleteRecording( + rec['id'], + rec['title'] ?? '', + ), ), ], ), diff --git a/lib/mobile/features/iptv/screens/mobile_dashboard_screen.dart b/lib/mobile/features/iptv/screens/mobile_dashboard_screen.dart index d8c1b64..9753bc9 100644 --- a/lib/mobile/features/iptv/screens/mobile_dashboard_screen.dart +++ b/lib/mobile/features/iptv/screens/mobile_dashboard_screen.dart @@ -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 { _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); - } - } } diff --git a/lib/mobile/features/iptv/widgets/mobile_live_tv_tab.dart b/lib/mobile/features/iptv/widgets/mobile_live_tv_tab.dart index ccce9a1..0a76605 100644 --- a/lib/mobile/features/iptv/widgets/mobile_live_tv_tab.dart +++ b/lib/mobile/features/iptv/widgets/mobile_live_tv_tab.dart @@ -335,7 +335,7 @@ class _MobileLiveTVTabState extends ConsumerState { } } -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( diff --git a/lib/mobile/widgets/mobile_scaffold.dart b/lib/mobile/widgets/mobile_scaffold.dart index 0d2a71e..2ee2256 100644 --- a/lib/mobile/widgets/mobile_scaffold.dart +++ b/lib/mobile/widgets/mobile_scaffold.dart @@ -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), From 7fa9897d5cfd5198f7ab0316c9e69f47fd2cd1a5 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 27 Aug 2026 12:33:36 +0000 Subject: [PATCH 4/4] =?UTF-8?q?Builds=20reproductibles,=20CI=20durcie,=20d?= =?UTF-8?q?ocs=20remises=20=C3=A0=20jour?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Lockfiles : - pubspec.lock et bin/pubspec.lock versionnés (le .gitignore les excluait via *.lock ; le Dockerfile résolvait des versions fraîches à chaque build, comme son commentaire l'assumait) ; le Dockerfile les COPY désormais, .dockerignore ajusté CI (ci.yml) : - flutter-version 3.38.4 et Dart 3.13.2 épinglés (channel: stable seul fait dériver le SDK et peut casser la CI sans changement du dépôt) - cache pub activé, concurrency: cancel-in-progress Publication (docker-publish.yml) : - Chaîné sur la CI via workflow_run : l'image :latest n'est publiée qu'après analyze/test/build verts sur main (l'ancien push: [main] tournait en parallèle et pouvait publier une image cassée) ; build du head_sha validé par la CI docker-compose : - Défaut RECORDINGS_PATH: ./data/recordings au lieu du chemin unRAID spécifique à une machine (/mnt/user/Data/Sport) - Variable TZ (défaut Europe/Paris) Docs : - README réécrit : il décrivait une architecture disparue (Hive/ IndexedDB, hachage SHA-256, dhttpd port 8080, --web-renderer html) au lieu de l'état réel (SQLite serveur, bcrypt, binaire natif port 8089, enregistrements, transcodage, CI) - DEPLOYMENT_CHECKLIST.md archivé (6 fichiers cités inexistants, versions de dépendances fausses) - docs/archive/README.md : avertissement que ces documents sont historiques (plusieurs se déclarent « COMPLETE » à tort) Non traité ici : l'épinglage de la release FFmpeg du Dockerfile (l'accès aux releases BtbN est bloqué depuis cet environnement, impossible de vérifier un tag valide) et la suppression du code mort (reportée après le merge des PRs #7/#8/#9 pour éviter les conflits). Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_015oEu9QayWsw7hCKhenxgVa --- .dockerignore | 1 - .github/workflows/ci.yml | 14 +- .github/workflows/docker-publish.yml | 16 +- .gitignore | 3 + CHANGELOG.md | 7 + Dockerfile | 7 +- README.md | 331 +---- bin/pubspec.lock | 477 +++++++ docker-compose.yml | 5 +- .../archive/DEPLOYMENT_CHECKLIST.md | 0 docs/archive/README.md | 12 + pubspec.lock | 1266 +++++++++++++++++ 12 files changed, 1876 insertions(+), 263 deletions(-) create mode 100644 bin/pubspec.lock rename DEPLOYMENT_CHECKLIST.md => docs/archive/DEPLOYMENT_CHECKLIST.md (100%) create mode 100644 docs/archive/README.md create mode 100644 pubspec.lock diff --git a/.dockerignore b/.dockerignore index 1b2ec48..9c34788 100644 --- a/.dockerignore +++ b/.dockerignore @@ -8,7 +8,6 @@ build/ bin/.dart_tool bin/.packages -bin/pubspec.lock ios/ android/ windows/ diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9ca70c8..bd13dd2 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -6,6 +6,12 @@ on: pull_request: branches: [main] +# Un push qui en suit un autre annule le run précédent : inutile de +# consommer un runner pour un commit déjà obsolète. +concurrency: + group: ci-${{ github.ref }} + cancel-in-progress: true + jobs: frontend: runs-on: ubuntu-latest @@ -13,7 +19,13 @@ jobs: - uses: actions/checkout@v4 - uses: subosito/flutter-action@v2 with: + # Version épinglée : `channel: stable` seul fait dériver le SDK au + # fil du temps (une release Flutter peut casser la CI sans aucun + # changement dans le dépôt). Le cache évite de retélécharger SDK et + # dépendances pub à chaque run. + flutter-version: 3.38.4 channel: stable + cache: true - run: flutter pub get # Infos (pre-existing withOpacity/dart:html deprecations) are not # fatal; errors and warnings still fail the build. @@ -30,7 +42,7 @@ jobs: - uses: actions/checkout@v4 - uses: dart-lang/setup-dart@v1 with: - sdk: stable + sdk: 3.13.2 - run: dart pub get - run: dart analyze - run: dart test diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml index 66c5c97..068023d 100644 --- a/.github/workflows/docker-publish.yml +++ b/.github/workflows/docker-publish.yml @@ -1,7 +1,12 @@ name: Docker Publish +# Chaîné sur la CI : l'image :latest n'est publiée que si analyze/test/build +# sont verts sur main. L'ancien déclencheur `push: [main]` tournait en +# parallèle de la CI et pouvait publier une image cassée. on: - push: + workflow_run: + workflows: [CI] + types: [completed] branches: [main] workflow_dispatch: @@ -11,11 +16,18 @@ env: jobs: build-and-push: runs-on: ubuntu-latest + if: >- + github.event_name == 'workflow_dispatch' || + github.event.workflow_run.conclusion == 'success' permissions: contents: read packages: write steps: - uses: actions/checkout@v4 + with: + # Sur workflow_run, github.sha pointe sur le commit du workflow + # par défaut : construire exactement le commit validé par la CI. + ref: ${{ github.event.workflow_run.head_sha || github.sha }} - uses: docker/setup-buildx-action@v3 @@ -34,6 +46,6 @@ jobs: push: true tags: | ${{ env.IMAGE_NAME }}:latest - ${{ env.IMAGE_NAME }}:${{ github.sha }} + ${{ env.IMAGE_NAME }}:${{ github.event.workflow_run.head_sha || github.sha }} cache-from: type=gha cache-to: type=gha,mode=max diff --git a/.gitignore b/.gitignore index ca837d8..05cfa78 100644 --- a/.gitignore +++ b/.gitignore @@ -49,4 +49,7 @@ app.*.map.json # Hive data (local development) *.hive *.lock +# Les lockfiles pub sont versionnés : sans eux, chaque build Docker/CI +# résout des versions différentes (builds non reproductibles). +!pubspec.lock .claude/settings.local.json diff --git a/CHANGELOG.md b/CHANGELOG.md index 481d259..5601639 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,13 @@ ## Non publié +### 🧰 Qualité / Infra +- **Builds reproductibles** : `pubspec.lock` (frontend et backend) désormais versionnés et utilisés par le Dockerfile — chaque build résolvait jusqu'ici des versions fraîches +- **CI durcie** : versions Flutter/Dart épinglées, cache pub, annulation des runs obsolètes (`concurrency`) ; `docker-publish` ne publie plus `:latest` qu'après une CI verte sur main (il tournait en parallèle et pouvait publier une image cassée) +- **docker-compose** : défaut `RECORDINGS_PATH` porté à `./data/recordings` (l'ancien défaut était un chemin unRAID spécifique à une machine), variable `TZ` ajoutée +- **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) + ### 📺 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 diff --git a/Dockerfile b/Dockerfile index affb330..7a8be61 100644 --- a/Dockerfile +++ b/Dockerfile @@ -12,9 +12,10 @@ ENV FLUTTER_NO_ANALYTICS=1 RUN flutter config --enable-web && flutter precache --web # 2. Dependency Resolution (ONLY UPDATES IF PUBSPEC CHANGES) -COPY pubspec.yaml ./ -COPY bin/pubspec.yaml ./bin/ -# Note: No .lock files found locally, so we fetch fresh ones here +# Les lockfiles sont versionnés : le build résout exactement les versions +# committées au lieu d'en chercher de nouvelles à chaque build. +COPY pubspec.yaml pubspec.lock ./ +COPY bin/pubspec.yaml bin/pubspec.lock ./bin/ RUN flutter pub get && cd bin && dart pub get # 3. Source Code Copy (CHANGES OFTEN) diff --git a/README.md b/README.md index 5507202..c5bfc73 100644 --- a/README.md +++ b/README.md @@ -1,282 +1,103 @@ -# XtremFlow - IPTV Web Application +# XtremFlow — Application Web IPTV -High-performance, containerized IPTV Web Application using Flutter Web and Xtream Codes API. +Application IPTV auto-hébergée : frontend Flutter Web + serveur Dart natif, empaquetés dans une seule image Docker. Se connecte à un abonnement Xtream Codes et ajoute le magnétoscope (enregistrements planifiés, season passes), l'EPG avec repli XMLTV, et le transcodage FFmpeg à la demande. -## Features +## Fonctionnalités -✅ **Local Authentication System** -- Default admin user (`admin`/`admin`) -- Secure salt-based password hashing (SHA-256) -- No public signup - private app only +- **Live TV, Films, Séries** : catalogues Xtream avec catégories, recherche, favoris, reprise de lecture +- **Guide TV (EPG)** : panneau de l'abonné en priorité, repli automatique sur un dump XMLTV quand le panneau est figé +- **Enregistrements TV** : planification depuis le guide, capture FFmpeg (`-c copy`), reprise après coupure amont ou redémarrage du serveur, fusion automatique des parties +- **Season Passes** : enregistrement automatique de toutes les diffusions d'une émission (scan EPG toutes les 4 h) +- **Transcodage à la demande** : `source | high | medium | low` (live et VOD), `source` = zéro transcodage ; NVENC optionnel +- **Multi-utilisateurs** : comptes locaux (bcrypt), playlists par utilisateur, panneau d'administration -✅ **Multi-Playlist Management** -- Centralized Xtream credentials management -- Playlist assignment to users -- Easy switching between playlists +## Stack -✅ **High-Performance Dashboard (60fps)** -- Category-based pagination (100 items/page for Live TV, 50 for Movies) -- Lazy loading with `ListView.builder` / `GridView.builder` -- Image caching with `cached_network_image` +| Couche | Techno | +|---|---| +| Frontend | Flutter Web (Riverpod, GoRouter), players HTML (hls.js / mpegts.js vendorisés) | +| Backend | Dart compilé en natif (`dart compile exe`), shelf | +| Base | SQLite côté serveur (`/app/data/xtremflow.db`) | +| Capture/Transcodage | FFmpeg (build BtbN, NVENC inclus) | +| Conteneur | Debian slim multi-stage, port **8089** | -✅ **Live TV with EPG** -- Electronic Program Guide (EPG) overlay -- "Now & Next" program display -- Real-time progress bar - -✅ **VOD & Series** -- Movies and Series organized by categories -- Grid layout with posters -- Optimized ratings display (1 decimal place) - -✅ **Docker Deployment** -- Multi-stage build with Flutter and Dart -- Custom Dart Server (`bin/server.dart`) -- **FFmpeg Transcoding** for mobile compatibility -- **Cache Management** system for temporary files -- External network support (`nginx_default`) - -## Tech Stack - -- **Framework**: Flutter Web -- **State Management**: Riverpod -- **Local Database**: Hive (Web IndexedDB) with AES encryption -- **Networking**: Dio with cache interceptors -- **Routing**: GoRouter with auth guards -- **Video Player**: `video_player` + `chewie` -- **UI**: Google Fonts, Material Design 3 - -## Prerequisites - -- Docker & Docker Compose -- Existing `nginx_default` network (for reverse proxy routing) -- Flutter SDK (for local development only) - -## Quick Start (Docker) - -### 1. Build the Docker image +## Démarrage rapide (Docker) ```bash -docker-compose build +docker-compose up -d --build +# Application sur http://localhost:8089 ``` -### 2. Start the container - -```bash -docker-compose up -d -``` - -### 3. Access via reverse proxy - -Configure your reverse proxy (Nginx/Traefik) to route traffic to: -- **Container**: `xtremflow` -- **Internal Port**: `8080` -- **Network**: `nginx_default` - -Example Nginx configuration: - -```nginx -location /iptv { - proxy_pass http://xtremflow:8080; - proxy_set_header Host $host; - proxy_set_header X-Real-IP $remote_addr; -} -``` - -### 4. Login - -- **URL**: `http://your-domain/iptv` -- **Default Credentials**: - - Username: `admin` - - Password: `admin` - -⚠️ **Change the admin password immediately after first login!** - -## Local Development - -### Install dependencies +Au premier démarrage, un compte `admin` est créé avec un **mot de passe aléatoire affiché une seule fois dans les logs** (`docker logs xtremflow`), sauf si `ADMIN_INITIAL_PASSWORD` est défini. Changez-le après la première connexion. + +### Variables d'environnement (docker-compose.yml) + +| Variable | Défaut | Rôle | +|---|---|---| +| `RECORDINGS_PATH` | `./data/recordings` | Dossier hôte des enregistrements | +| `TZ` | `Europe/Paris` | Fuseau du conteneur (les enregistrements sont stockés en UTC) | +| `MAX_CONCURRENT_RECORDINGS` | `2` | Enregistrements simultanés | +| `EPG_XMLTV_URLS` | dump FR | Sources XMLTV de repli (vide = aucun appel sortant) | +| `NVIDIA_GPU` | `false` | Transcodage NVENC | +| `ADMIN_INITIAL_PASSWORD` | *(généré)* | Mot de passe initial du compte admin | +| `MIN_FREE_DISK_MB` | `500` | Espace libre minimal pour démarrer une capture | +| `RECORDINGS_QUOTA_GB` | `0` (off) | Quota du dossier d'enregistrements (rotation des plus anciens terminés) | +| `TRUSTED_PROXIES` | loopback + RFC1918 | IPs de reverse proxy dont `X-Forwarded-For` est honoré | + +## Développement local ```bash +# Frontend flutter pub get +flutter analyze && flutter test +flutter run -d chrome # nécessite le backend lancé pour les routes /api + +# Backend +cd bin +dart pub get +dart analyze && dart test +dart run server.dart --port 8089 --path ../build/web ``` -### Generate Hive adapters (if modified) +Le build Windows natif est documenté dans `BUILD.md`. -```bash -flutter pub run build_runner build --delete-conflicting-outputs -``` - -### Run web app - -```bash -flutter run -d chrome -``` - -## Project Structure +## Structure du projet ``` -lib/ -├── core/ -│ ├── database/ -│ │ └── hive_service.dart # Hive initialization & encryption -│ ├── models/ -│ │ ├── app_user.dart # User model (Hive) -│ │ ├── playlist_config.dart # Playlist credentials (Hive) -│ │ └── iptv_models.dart # Channel, VOD, Series, EPG models -│ ├── router/ -│ │ └── app_router.dart # GoRouter configuration -│ └── utils/ -│ └── crypto_utils.dart # Password hashing utilities -├── features/ -│ ├── auth/ -│ │ ├── providers/ -│ │ │ └── auth_provider.dart # Authentication state -│ │ └── screens/ -│ │ └── login_screen.dart -│ ├── admin/ -│ │ └── screens/ -│ │ └── admin_panel.dart # User & Playlist CRUD -│ └── iptv/ -│ ├── services/ -│ │ └── xtream_service.dart # Xtream API client -│ ├── providers/ -│ │ └── xtream_provider.dart # Riverpod providers -│ ├── screens/ -│ │ └── player_screen.dart # Video player -│ └── widgets/ -│ ├── live_tv_tab.dart # Live TV with pagination -│ ├── movies_tab.dart # Movies grid -│ ├── series_tab.dart # Series grid -│ └── epg_overlay.dart # EPG display -└── main.dart +bin/ Serveur Dart +├── server.dart Point d'entrée, routage, arrêt gracieux +├── api/ Handlers HTTP (auth, playlists, recordings, EPG, proxy…) +├── services/ Scheduler d'enregistrement, sessions FFmpeg, XMLTV +├── database/ SQLite (schéma, migrations) +├── middleware/ Auth (session), sécurité (rate limit, honeypot, logs redactés) +└── test/ Tests backend (dart test) + +lib/ Frontend Flutter +├── core/ Modèles, thème, router, clients API +├── features/ auth / admin / iptv (desktop) +└── mobile/ Variantes d'écrans mobiles + +web/ Players HTML + libs vendorisées (hls.js, mpegts.js) ``` -## Security Features +## Sécurité -### Password Storage -- **Algorithm**: SHA-256 with random UUID-based salt -- **Format**: `salt:hash` (stored in Hive) -- **Legacy Support**: Fallback to unsalted comparison for migration +- Mots de passe **bcrypt** (migration lazy depuis les anciens hashes au login) +- Les credentials Xtream ne quittent jamais le serveur : passerelle `/api/xtream-api` et proxy `/api/xtream` (authentifié par session) avec injection côté serveur +- Redaction des credentials dans tous les logs ; anti-SSRF avec revalidation des redirections +- Cookie de session HttpOnly ; rate limiting global + limite de tentatives de login par IP -### Database Encryption -- **Hive AES Cipher** (256-bit key) -- Key stored in `FlutterSecureStorage` -- Automatic key generation on first run +## CI / Publication -### Authentication Flow -1. User enters credentials -2. System retrieves stored hash -3. Input password is hashed with same salt -4. Constant-time comparison prevents timing attacks +`ci.yml` (analyze + tests + build web/backend) tourne sur chaque PR et push main ; `docker-publish.yml` publie `ghcr.io/r0m1k3/xtremflow:latest` **uniquement après une CI verte** sur main. -## Performance Optimizations +## Dépannage -### Memory Management (20k+ channels) -- **Grouping**: Channels organized by category -- **Pagination**: 100 items per page (Live TV), 50 per page (Movies) -- **Lazy Loading**: Only render visible items -- **Image Caching**: Disk/memory cache with `cached_network_image` +- **Logs** : `docker logs xtremflow` (les URLs y sont redactées) +- **Mot de passe admin perdu** : supprimer le volume `xtremflow-data` recrée la base et affiche un nouveau mot de passe (⚠ efface utilisateurs et historique d'enregistrements) +- **EPG vide** : vérifier `EPG_XMLTV_URLS` et que la chaîne a un `epg_channel_id` ; l'en-tête `X-Epg-Source` des réponses `/api/epg/` indique la source utilisée +- **Enregistrement échoué** : bouton « Logs » sur la ligne de l'enregistrement ; la raison (`error_reason`) est affichée sous le titre -### Network Optimization -- **Dio Cache Interceptor**: 1-hour cache for API responses -- **EPG Cache**: 5-minute refresh for program data -- **Hive Disk Store**: Persistent cache across sessions +## Licence -### Rendering (60fps Target) -- `ListView.builder` with fixed `itemExtent` -- `AutomaticKeepAliveClientMixin` for tab state -- Expansion panels for category navigation -- Grid with fixed `crossAxisCount` and `childAspectRatio` - -## Xtream API Integration - -### Supported Endpoints - -| Endpoint | Purpose | Caching | -|----------|---------|---------| -| `player_api.php` | Authentication | 1 hour | -| `get_live_streams` | Live TV channels | 1 hour | -| `get_vod_streams` | Movies | 1 hour | -| `get_series` | Series | 1 hour | -| `get_short_epg` | EPG data | 5 minutes | - -### Stream URL Formats - -```dart -// Live TV -http://[dns]/live/[username]/[password]/[stream_id].m3u8 - -// Movies -http://[dns]/movie/[username]/[password]/[stream_id].[container_extension] - -// Series -http://[dns]/series/[username]/[password]/[stream_id].[container_extension] -``` - -## Docker Configuration - -### Dockerfile (Multi-Stage) - -**Stage 1: Builder** -- Base: `cirrusci/flutter:stable` -- Build: `flutter build web --release --web-renderer html` - -**Stage 2: Runtime** -- Base: `dart:stable` -- Server: `dhttpd --host 0.0.0.0 --port 8080` -- Size: ~150MB (compressed) - -### docker-compose.yml - -```yaml -services: - iptv-web: - build: . - container_name: xtremflow - restart: unless-stopped - networks: - - nginx_default - -networks: - nginx_default: - external: true -``` - -**No port mapping** - Access via reverse proxy only. - -## Troubleshooting - -### Container won't start -```bash -# Check logs -docker logs xtremflow - -# Verify network exists -docker network ls | grep nginx_default - -# Create network if missing -docker network create nginx_default -``` - -### Login fails with admin/admin -- Check Hive database initialization in logs -- Verify `HiveService.init()` completed successfully -- Default admin is seeded only if `users` box is empty - -### EPG not displaying -- EPG is optional and gracefully degrades -- Check if Xtream server supports `get_short_epg` -- Verify stream has `epg_channel_id` - -### Performance issues (FPS drops) -- Reduce `_itemsPerPage` constant (currently 100 for Live TV) -- Disable image caching temporarily -- Check browser DevTools Performance tab - -## License - -Proprietary - Private Use Only - -## Support - -For Xtream API documentation, consult your IPTV provider. +Propriétaire — usage privé uniquement. diff --git a/bin/pubspec.lock b/bin/pubspec.lock new file mode 100644 index 0000000..bddb80c --- /dev/null +++ b/bin/pubspec.lock @@ -0,0 +1,477 @@ +# Generated by pub +# See https://dart.dev/tools/pub/glossary#lockfile +packages: + _fe_analyzer_shared: + dependency: transitive + description: + name: _fe_analyzer_shared + sha256: "9a3386eea899815698dd55995277cf7cb8572ee52b399a6edfb7ae2b50e5fc19" + url: "https://pub.dev" + source: hosted + version: "105.0.0" + analyzer: + dependency: transitive + description: + name: analyzer + sha256: "62993bed6eadbe9596c5c20d5c167e7bc563c5fe266657a04ddeb93bdb84f4c9" + url: "https://pub.dev" + source: hosted + version: "14.1.0" + args: + dependency: "direct main" + description: + name: args + sha256: d0481093c50b1da8910eb0bb301626d4d8eb7284aa739614d2b394ee09e3ea04 + url: "https://pub.dev" + source: hosted + version: "2.7.0" + async: + dependency: transitive + description: + name: async + sha256: e2eb0491ba5ddb6177742d2da23904574082139b07c1e33b8503b9f46f3e1a37 + url: "https://pub.dev" + source: hosted + version: "2.13.1" + bcrypt: + dependency: "direct main" + description: + name: bcrypt + sha256: "6073a700cbbc59f1d4ab27cd532755e3de5e676c4941f535f351374df849270b" + url: "https://pub.dev" + source: hosted + version: "1.2.0" + boolean_selector: + dependency: transitive + description: + name: boolean_selector + sha256: "8aab1771e1243a5063b8b0ff68042d67334e3feab9e95b9490f9a6ebf73b42ea" + url: "https://pub.dev" + source: hosted + version: "2.1.2" + cli_config: + dependency: transitive + description: + name: cli_config + sha256: ac20a183a07002b700f0c25e61b7ee46b23c309d76ab7b7640a028f18e4d99ec + url: "https://pub.dev" + source: hosted + version: "0.2.0" + collection: + dependency: transitive + description: + name: collection + sha256: "2f5709ae4d3d59dd8f7cd309b4e023046b57d8a6c82130785d2b0e5868084e76" + url: "https://pub.dev" + source: hosted + version: "1.19.1" + convert: + dependency: transitive + description: + name: convert + sha256: b30acd5944035672bc15c6b7a8b47d773e41e2f17de064350988c5d02adb1c68 + url: "https://pub.dev" + source: hosted + version: "3.1.2" + coverage: + dependency: transitive + description: + name: coverage + sha256: "956a3de0725ca232ad353565a8290d3357592bf4250f6f298a185e2d949c5d3d" + url: "https://pub.dev" + source: hosted + version: "1.15.1" + crypto: + dependency: "direct main" + description: + name: crypto + sha256: c8ea0233063ba03258fbcf2ca4d6dadfefe14f02fab57702265467a19f27fadf + url: "https://pub.dev" + source: hosted + version: "3.0.7" + equatable: + dependency: "direct main" + description: + name: equatable + sha256: "3bce007a596ff8b3119c45d68aaef631272537c03d30e5d4534dd24bf4c5eaa2" + url: "https://pub.dev" + source: hosted + version: "2.1.0" + ffi: + dependency: transitive + description: + name: ffi + sha256: "6d7fd89431262d8f3125e81b50d3847a091d846eafcd4fdb88dd06f36d705a45" + url: "https://pub.dev" + source: hosted + version: "2.2.0" + file: + dependency: transitive + description: + name: file + sha256: a3b4f84adafef897088c160faf7dfffb7696046cb13ae90b508c2cbc95d3b8d4 + url: "https://pub.dev" + source: hosted + version: "7.0.1" + fixnum: + dependency: transitive + description: + name: fixnum + sha256: b6dc7065e46c974bc7c5f143080a6764ec7a4be6da1285ececdc37be96de53be + url: "https://pub.dev" + source: hosted + version: "1.1.1" + frontend_server_client: + dependency: transitive + description: + name: frontend_server_client + sha256: f64a0333a82f30b0cca061bc3d143813a486dc086b574bfb233b7c1372427694 + url: "https://pub.dev" + source: hosted + version: "4.0.0" + glob: + dependency: transitive + description: + name: glob + sha256: c3f1ee72c96f8f78935e18aa8cecced9ab132419e8625dc187e1c2408efc20de + url: "https://pub.dev" + source: hosted + version: "2.1.3" + hive: + dependency: "direct main" + description: + name: hive + sha256: "8dcf6db979d7933da8217edcec84e9df1bdb4e4edc7fc77dbd5aa74356d6d941" + url: "https://pub.dev" + source: hosted + version: "2.2.3" + http: + dependency: "direct main" + description: + name: http + sha256: "87721a4a50b19c7f1d49001e51409bddc46303966ce89a65af4f4e6004896412" + url: "https://pub.dev" + source: hosted + version: "1.6.0" + http_methods: + dependency: transitive + description: + name: http_methods + sha256: "6bccce8f1ec7b5d701e7921dca35e202d425b57e317ba1a37f2638590e29e566" + url: "https://pub.dev" + source: hosted + version: "1.1.1" + http_multi_server: + dependency: transitive + description: + name: http_multi_server + sha256: aa6199f908078bb1c5efb8d8638d4ae191aac11b311132c3ef48ce352fb52ef8 + url: "https://pub.dev" + source: hosted + version: "3.2.2" + http_parser: + dependency: transitive + description: + name: http_parser + sha256: "178d74305e7866013777bab2c3d8726205dc5a4dd935297175b19a23a2e66571" + url: "https://pub.dev" + source: hosted + version: "4.1.2" + io: + dependency: transitive + description: + name: io + sha256: dfd5a80599cf0165756e3181807ed3e77daf6dd4137caaad72d0b7931597650b + url: "https://pub.dev" + source: hosted + version: "1.0.5" + logging: + dependency: transitive + description: + name: logging + sha256: c8245ada5f1717ed44271ed1c26b8ce85ca3228fd2ffdb75468ab01979309d61 + url: "https://pub.dev" + source: hosted + version: "1.3.0" + matcher: + dependency: transitive + description: + name: matcher + sha256: "31bd099b47c10cd1aeb55146a2d46ce0277630ecef3f7dae54ad7873f36696cd" + url: "https://pub.dev" + source: hosted + version: "0.12.20" + meta: + dependency: transitive + description: + name: meta + sha256: "307249ce4ff29d58a18e97f6345f539382eb9c9c29ecda628900f31de0443dd9" + url: "https://pub.dev" + source: hosted + version: "1.19.0" + mime: + dependency: transitive + description: + name: mime + sha256: "41a20518f0cb1256669420fdba0cd90d21561e560ac240f26ef8322e45bb7ed6" + url: "https://pub.dev" + source: hosted + version: "2.0.0" + node_preamble: + dependency: transitive + description: + name: node_preamble + sha256: "6e7eac89047ab8a8d26cf16127b5ed26de65209847630400f9aefd7cd5c730db" + url: "https://pub.dev" + source: hosted + version: "2.0.2" + package_config: + dependency: transitive + description: + name: package_config + sha256: ffcf4cf3d6c0b74ac43708d9f56625506e8a68aa935abe9d267a7330f320eb5d + url: "https://pub.dev" + source: hosted + version: "3.0.0" + path: + dependency: "direct main" + description: + name: path + sha256: "75cca69d1490965be98c73ceaea117e8a04dd21217b37b292c9ddbec0d955bc5" + url: "https://pub.dev" + source: hosted + version: "1.9.1" + petitparser: + dependency: transitive + description: + name: petitparser + sha256: "91bd59303e9f769f108f8df05e371341b15d59e995e6806aefab827b58336675" + url: "https://pub.dev" + source: hosted + version: "7.0.2" + pool: + dependency: transitive + description: + name: pool + sha256: "978783255c543aa3586a1b3c21f6e9d720eb315376a915872c61ef8b5c20177d" + url: "https://pub.dev" + source: hosted + version: "1.5.2" + pub_semver: + dependency: transitive + description: + name: pub_semver + sha256: "5bfcf68ca79ef689f8990d1160781b4bad40a3bd5e5218ad4076ddb7f4081585" + url: "https://pub.dev" + source: hosted + version: "2.2.0" + shelf: + dependency: "direct main" + description: + name: shelf + sha256: e7dd780a7ffb623c57850b33f43309312fc863fb6aa3d276a754bb299839ef12 + url: "https://pub.dev" + source: hosted + version: "1.4.2" + shelf_packages_handler: + dependency: transitive + description: + name: shelf_packages_handler + sha256: "89f967eca29607c933ba9571d838be31d67f53f6e4ee15147d5dc2934fee1b1e" + url: "https://pub.dev" + source: hosted + version: "3.0.2" + shelf_router: + dependency: "direct main" + description: + name: shelf_router + sha256: f5e5d492440a7fb165fe1e2e1a623f31f734d3370900070b2b1e0d0428d59864 + url: "https://pub.dev" + source: hosted + version: "1.1.4" + shelf_static: + dependency: "direct main" + description: + name: shelf_static + sha256: c87c3875f91262785dade62d135760c2c69cb217ac759485334c5857ad89f6e3 + url: "https://pub.dev" + source: hosted + version: "1.1.3" + shelf_web_socket: + dependency: transitive + description: + name: shelf_web_socket + sha256: "3632775c8e90d6c9712f883e633716432a27758216dfb61bd86a8321c0580925" + url: "https://pub.dev" + source: hosted + version: "3.0.0" + source_map_stack_trace: + dependency: transitive + description: + name: source_map_stack_trace + sha256: c0713a43e323c3302c2abe2a1cc89aa057a387101ebd280371d6a6c9fa68516b + url: "https://pub.dev" + source: hosted + version: "2.1.2" + source_maps: + dependency: transitive + description: + name: source_maps + sha256: "14c2945847669b44089bb1222f66873d7ff7103c58911917f2a63c5a62327898" + url: "https://pub.dev" + source: hosted + version: "0.10.14" + source_span: + dependency: transitive + description: + name: source_span + sha256: "56a02f1f4cd1a2d96303c0144c93bd6d909eea6bee6bf5a0e0b685edbd4c47ab" + url: "https://pub.dev" + source: hosted + version: "1.10.2" + sqlite3: + dependency: "direct main" + description: + name: sqlite3 + sha256: "3145bd74dcdb4fd6f5c6dda4d4e4490a8087d7f286a14dee5d37087290f0f8a2" + url: "https://pub.dev" + source: hosted + version: "2.9.4" + stack_trace: + dependency: transitive + description: + name: stack_trace + sha256: "8b27215b45d22309b5cddda1aa2b19bdfec9df0e765f2de506401c071d38d1b1" + url: "https://pub.dev" + source: hosted + version: "1.12.1" + stream_channel: + dependency: transitive + description: + name: stream_channel + sha256: "969e04c80b8bcdf826f8f16579c7b14d780458bd97f56d107d3950fdbeef059d" + url: "https://pub.dev" + source: hosted + version: "2.1.4" + string_scanner: + dependency: transitive + description: + name: string_scanner + sha256: "921cd31725b72fe181906c6a94d987c78e3b98c2e205b397ea399d4054872b43" + url: "https://pub.dev" + source: hosted + version: "1.4.1" + term_glyph: + dependency: transitive + description: + name: term_glyph + sha256: "7f554798625ea768a7518313e58f83891c7f5024f88e46e7182a4558850a4b8e" + url: "https://pub.dev" + source: hosted + version: "1.2.2" + test: + dependency: "direct dev" + description: + name: test + sha256: "0d5ba5602ec3baa28c8ce365e1efc5575969c765f45c554a3e167dc7945b9c30" + url: "https://pub.dev" + source: hosted + version: "1.31.2" + test_api: + dependency: transitive + description: + name: test_api + sha256: "475610b2aa23c19687cce2961e44b0cc57cafe220f67c2b80201231b2a07fbe7" + url: "https://pub.dev" + source: hosted + version: "0.7.13" + test_core: + dependency: transitive + description: + name: test_core + sha256: a39c204a4fc7a7ccb04a2b985e359fda3cc37e45e0b8ac61c3fb1a05aa832132 + url: "https://pub.dev" + source: hosted + version: "0.6.19" + typed_data: + dependency: transitive + description: + name: typed_data + sha256: f9049c039ebfeb4cf7a7104a675823cd72dba8297f264b6637062516699fa006 + url: "https://pub.dev" + source: hosted + version: "1.4.0" + uuid: + dependency: "direct main" + description: + name: uuid + sha256: "9b129329f58692f6e6578329498a8fe9fbe98f090beb764ffbb8ee2eadd01dcd" + url: "https://pub.dev" + source: hosted + version: "4.6.0" + vm_service: + dependency: transitive + description: + name: vm_service + sha256: "5f37239c4851efcef929cea7824e76df7f2f0970aef85d66bbc430afa40e72f0" + url: "https://pub.dev" + source: hosted + version: "15.3.0" + watcher: + dependency: transitive + description: + name: watcher + sha256: "1398c9f081a753f9226febe8900fce8f7d0a67163334e1c94a2438339d79d635" + url: "https://pub.dev" + source: hosted + version: "1.2.1" + web: + dependency: transitive + description: + name: web + sha256: "868d88a33d8a87b18ffc05f9f030ba328ffefba92d6c127917a2ba740f9cfe4a" + url: "https://pub.dev" + source: hosted + version: "1.1.1" + web_socket: + dependency: transitive + description: + name: web_socket + sha256: "34d64019aa8e36bf9842ac014bb5d2f5586ca73df5e4d9bf5c936975cae6982c" + url: "https://pub.dev" + source: hosted + version: "1.0.1" + web_socket_channel: + dependency: transitive + description: + name: web_socket_channel + sha256: d645757fb0f4773d602444000a8131ff5d48c9e47adfe9772652dd1a4f2d45c8 + url: "https://pub.dev" + source: hosted + version: "3.0.3" + webkit_inspection_protocol: + dependency: transitive + description: + name: webkit_inspection_protocol + sha256: "87d3f2333bb240704cd3f1c6b5b7acd8a10e7f0bc28c28dcf14e782014f4a572" + url: "https://pub.dev" + source: hosted + version: "1.2.1" + xml: + dependency: "direct main" + description: + name: xml + sha256: "971043b3a0d3da28727e40ed3e0b5d18b742fa5a68665cca88e74b7876d5e025" + url: "https://pub.dev" + source: hosted + version: "6.6.1" + yaml: + dependency: transitive + description: + name: yaml + sha256: b9da305ac7c39faa3f030eccd175340f968459dae4af175130b3fc47e40d76ce + url: "https://pub.dev" + source: hosted + version: "3.1.3" +sdks: + dart: ">=3.11.0 <4.0.0" diff --git a/docker-compose.yml b/docker-compose.yml index 74987ec..aa34c42 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -13,8 +13,11 @@ services: # Pour changer: modifiez le chemin avant les deux points ":" # Exemple Windows : - D:\MesVideos\TV:/app/recordings # Exemple Linux : - /mnt/nas/videos:/app/recordings - - ${RECORDINGS_PATH:-/mnt/user/Data/Sport}:/app/recordings + - ${RECORDINGS_PATH:-./data/recordings}:/app/recordings environment: + # Fuseau horaire du conteneur (affichage des logs ; les enregistrements + # sont stockés en UTC quoi qu'il arrive) + - TZ=${TZ:-Europe/Paris} # Origine externe autorisée pour CORS (optionnel — l'app est servie # same-origin, ne définir que si un autre domaine doit appeler l'API) - ALLOWED_ORIGIN=${ALLOWED_ORIGIN:-} diff --git a/DEPLOYMENT_CHECKLIST.md b/docs/archive/DEPLOYMENT_CHECKLIST.md similarity index 100% rename from DEPLOYMENT_CHECKLIST.md rename to docs/archive/DEPLOYMENT_CHECKLIST.md diff --git a/docs/archive/README.md b/docs/archive/README.md new file mode 100644 index 0000000..b72f1d1 --- /dev/null +++ b/docs/archive/README.md @@ -0,0 +1,12 @@ +# Archives — documents historiques + +⚠️ **Les documents de ce dossier sont historiques et ne décrivent pas l'état actuel du code.** + +Ils datent de phases de conception ou de refontes antérieures. Plusieurs se déclarent « COMPLETE » ou « production-ready » alors que le travail décrit n'a jamais été intégré (ou a été remplacé depuis) : + +- `RECORDING_SYSTEM_UPGRADE.txt` — décrit un remplacement du scheduler par `SimpleRecorder` qui n'a jamais été branché ; le système réel est `bin/services/recording_scheduler.dart`. +- `THEME_REDESIGN_SUMMARY.md` — la « Phase 4: Integration » n'a pas eu lieu ; les widgets qu'il liste n'existent plus. +- `ANALYSIS_AND_IMPROVEMENTS.md` — plan d'améliorations dont une partie seulement a été réalisée. +- `DEPLOYMENT_CHECKLIST.md` — référence des fichiers et versions de dépendances qui n'ont jamais existé dans le dépôt. + +Pour l'état actuel du projet, se référer au `README.md` racine et au `CHANGELOG.md`. diff --git a/pubspec.lock b/pubspec.lock new file mode 100644 index 0000000..8cbf66c --- /dev/null +++ b/pubspec.lock @@ -0,0 +1,1266 @@ +# Generated by pub +# See https://dart.dev/tools/pub/glossary#lockfile +packages: + _fe_analyzer_shared: + dependency: transitive + description: + name: _fe_analyzer_shared + sha256: "0b2f2bd91ba804e53a61d757b986f89f1f9eaed5b11e4b2f5a2468d86d6c9fc7" + url: "https://pub.dev" + source: hosted + version: "67.0.0" + analyzer: + dependency: transitive + description: + name: analyzer + sha256: "37577842a27e4338429a1cbc32679d508836510b056f1eedf0c8d20e39c1383d" + url: "https://pub.dev" + source: hosted + version: "6.4.1" + analyzer_plugin: + dependency: transitive + description: + name: analyzer_plugin + sha256: "9661b30b13a685efaee9f02e5d01ed9f2b423bd889d28a304d02d704aee69161" + url: "https://pub.dev" + source: hosted + version: "0.11.3" + archive: + dependency: transitive + description: + name: archive + sha256: cb6a278ef2dbb298455e1a713bda08524a175630ec643a242c399c932a0a1f7d + url: "https://pub.dev" + source: hosted + version: "3.6.1" + args: + dependency: transitive + description: + name: args + sha256: d0481093c50b1da8910eb0bb301626d4d8eb7284aa739614d2b394ee09e3ea04 + url: "https://pub.dev" + source: hosted + version: "2.7.0" + async: + dependency: transitive + description: + name: async + sha256: e2eb0491ba5ddb6177742d2da23904574082139b07c1e33b8503b9f46f3e1a37 + url: "https://pub.dev" + source: hosted + version: "2.13.1" + boolean_selector: + dependency: transitive + description: + name: boolean_selector + sha256: "8aab1771e1243a5063b8b0ff68042d67334e3feab9e95b9490f9a6ebf73b42ea" + url: "https://pub.dev" + source: hosted + version: "2.1.2" + build: + dependency: transitive + description: + name: build + sha256: "80184af8b6cb3e5c1c4ec6d8544d27711700bc3e6d2efad04238c7b5290889f0" + url: "https://pub.dev" + source: hosted + version: "2.4.1" + build_config: + dependency: transitive + description: + name: build_config + sha256: "4ae2de3e1e67ea270081eaee972e1bd8f027d459f249e0f1186730784c2e7e33" + url: "https://pub.dev" + source: hosted + version: "1.1.2" + build_daemon: + dependency: transitive + description: + name: build_daemon + sha256: fd754058c342243718d5171a95f352cfc9fcf0cba8cfa26df67cb13a5836db78 + url: "https://pub.dev" + source: hosted + version: "4.1.2" + build_resolvers: + dependency: transitive + description: + name: build_resolvers + sha256: "339086358431fa15d7eca8b6a36e5d783728cf025e559b834f4609a1fcfb7b0a" + url: "https://pub.dev" + source: hosted + version: "2.4.2" + build_runner: + dependency: "direct dev" + description: + name: build_runner + sha256: "028819cfb90051c6b5440c7e574d1896f8037e3c96cf17aaeb054c9311cfbf4d" + url: "https://pub.dev" + source: hosted + version: "2.4.13" + build_runner_core: + dependency: transitive + description: + name: build_runner_core + sha256: f8126682b87a7282a339b871298cc12009cb67109cfa1614d6436fb0289193e0 + url: "https://pub.dev" + source: hosted + version: "7.3.2" + built_collection: + dependency: transitive + description: + name: built_collection + sha256: "376e3dd27b51ea877c28d525560790aee2e6fbb5f20e2f85d5081027d94e2100" + url: "https://pub.dev" + source: hosted + version: "5.1.1" + built_value: + dependency: transitive + description: + name: built_value + sha256: "31b24be6615ec7fcf70b3aa5a7469fe35826485e639a16dd7eb83ba30e4cc6a8" + url: "https://pub.dev" + source: hosted + version: "8.12.7" + cached_network_image: + dependency: "direct main" + description: + name: cached_network_image + sha256: "7c1183e361e5c8b0a0f21a28401eecdbde252441106a9816400dd4c2b2424916" + url: "https://pub.dev" + source: hosted + version: "3.4.1" + cached_network_image_platform_interface: + dependency: transitive + description: + name: cached_network_image_platform_interface + sha256: "35814b016e37fbdc91f7ae18c8caf49ba5c88501813f73ce8a07027a395e2829" + url: "https://pub.dev" + source: hosted + version: "4.1.1" + cached_network_image_web: + dependency: transitive + description: + name: cached_network_image_web + sha256: "980842f4e8e2535b8dbd3d5ca0b1f0ba66bf61d14cc3a17a9b4788a3685ba062" + url: "https://pub.dev" + source: hosted + version: "1.3.1" + characters: + dependency: transitive + description: + name: characters + sha256: f71061c654a3380576a52b451dd5532377954cf9dbd272a78fc8479606670803 + url: "https://pub.dev" + source: hosted + version: "1.4.0" + checked_yaml: + dependency: transitive + description: + name: checked_yaml + sha256: "959525d3162f249993882720d52b7e0c833978df229be20702b33d48d91de70f" + url: "https://pub.dev" + source: hosted + version: "2.0.4" + chewie: + dependency: "direct main" + description: + name: chewie + sha256: "5b8f00a9373252ddd2296d0d734b3b754380e6b4728ba848aff40e97fabad339" + url: "https://pub.dev" + source: hosted + version: "1.14.1" + clock: + dependency: transitive + description: + name: clock + sha256: fddb70d9b5277016c77a80201021d40a2247104d9f4aa7bab7157b7e3f05b84b + url: "https://pub.dev" + source: hosted + version: "1.1.2" + code_assets: + dependency: transitive + description: + name: code_assets + sha256: bf394f466ba9205f1812a0433b392d6af280f155f56651eda7c18cc32ed493b8 + url: "https://pub.dev" + source: hosted + version: "1.2.1" + code_builder: + dependency: transitive + description: + name: code_builder + sha256: "6a6cab2ba4680d6423f34a9b972a4c9a94ebe1b62ecec4e1a1f2cba91fd1319d" + url: "https://pub.dev" + source: hosted + version: "4.11.1" + collection: + dependency: transitive + description: + name: collection + sha256: "2f5709ae4d3d59dd8f7cd309b4e023046b57d8a6c82130785d2b0e5868084e76" + url: "https://pub.dev" + source: hosted + version: "1.19.1" + convert: + dependency: transitive + description: + name: convert + sha256: b30acd5944035672bc15c6b7a8b47d773e41e2f17de064350988c5d02adb1c68 + url: "https://pub.dev" + source: hosted + version: "3.1.2" + crypto: + dependency: "direct main" + description: + name: crypto + sha256: c8ea0233063ba03258fbcf2ca4d6dadfefe14f02fab57702265467a19f27fadf + url: "https://pub.dev" + source: hosted + version: "3.0.7" + csslib: + dependency: transitive + description: + name: csslib + sha256: "09bad715f418841f976c77db72d5398dc1253c21fb9c0c7f0b0b985860b2d58e" + url: "https://pub.dev" + source: hosted + version: "1.0.2" + cupertino_icons: + dependency: transitive + description: + name: cupertino_icons + sha256: "41e005c33bd814be4d3096aff55b1908d419fde52ca656c8c47719ec745873cd" + url: "https://pub.dev" + source: hosted + version: "1.0.9" + custom_lint_core: + dependency: transitive + description: + name: custom_lint_core + sha256: a85e8f78f4c52f6c63cdaf8c872eb573db0231dcdf3c3a5906d493c1f8bc20e6 + url: "https://pub.dev" + source: hosted + version: "0.6.3" + dart_style: + dependency: transitive + description: + name: dart_style + sha256: "99e066ce75c89d6b29903d788a7bb9369cf754f7b24bf70bf4b6d6d6b26853b9" + url: "https://pub.dev" + source: hosted + version: "2.3.6" + dbus: + dependency: transitive + description: + name: dbus + sha256: a48d5da28e89bd02196e80d81ed8d7954923d00a0f4a68cc20b575038f023383 + url: "https://pub.dev" + source: hosted + version: "0.7.15" + dio: + dependency: "direct main" + description: + name: dio + sha256: "0df44ebba85e503958eb75d07eedd3c86275a58c1d3eda2f2ce8f0a2c3abbb3c" + url: "https://pub.dev" + source: hosted + version: "5.11.0" + dio_cache_interceptor: + dependency: "direct main" + description: + name: dio_cache_interceptor + sha256: "1346705a2057c265014d7696e3e2318b560bfb00b484dac7f9b01e2ceaebb07d" + url: "https://pub.dev" + source: hosted + version: "3.5.1" + dio_web_adapter: + dependency: transitive + description: + name: dio_web_adapter + sha256: "0786d0b7295a373de356fc0af4f6f1d0ab2844ed31b19dfc5e7556b70e24212c" + url: "https://pub.dev" + source: hosted + version: "2.2.1" + equatable: + dependency: "direct main" + description: + name: equatable + sha256: "3bce007a596ff8b3119c45d68aaef631272537c03d30e5d4534dd24bf4c5eaa2" + url: "https://pub.dev" + source: hosted + version: "2.1.0" + fake_async: + dependency: transitive + description: + name: fake_async + sha256: "5368f224a74523e8d2e7399ea1638b37aecfca824a3cc4dfdf77bf1fa905ac44" + url: "https://pub.dev" + source: hosted + version: "1.3.3" + ffi: + dependency: transitive + description: + name: ffi + sha256: "6d7fd89431262d8f3125e81b50d3847a091d846eafcd4fdb88dd06f36d705a45" + url: "https://pub.dev" + source: hosted + version: "2.2.0" + ffi_leak_tracker: + dependency: transitive + description: + name: ffi_leak_tracker + sha256: "4093d4ef9ca06ffe2786e73bfb25e22aa92112b9bb4ec941f11e3e6b61489a97" + url: "https://pub.dev" + source: hosted + version: "0.1.2" + file: + dependency: transitive + description: + name: file + sha256: a3b4f84adafef897088c160faf7dfffb7696046cb13ae90b508c2cbc95d3b8d4 + url: "https://pub.dev" + source: hosted + version: "7.0.1" + fixnum: + dependency: transitive + description: + name: fixnum + sha256: b6dc7065e46c974bc7c5f143080a6764ec7a4be6da1285ececdc37be96de53be + url: "https://pub.dev" + source: hosted + version: "1.1.1" + flutter: + dependency: "direct main" + description: flutter + source: sdk + version: "0.0.0" + flutter_cache_manager: + dependency: transitive + description: + name: flutter_cache_manager + sha256: "1de7849213b4c73c85aca7e0ac687a9a5d82ccdb594366b9dcc26cb6a2189cd2" + url: "https://pub.dev" + source: hosted + version: "3.4.2" + flutter_lints: + dependency: "direct dev" + description: + name: flutter_lints + sha256: "9e8c3858111da373efc5aa341de011d9bd23e2c5c5e0c62bccf32438e192d7b1" + url: "https://pub.dev" + source: hosted + version: "3.0.2" + flutter_riverpod: + dependency: "direct main" + description: + name: flutter_riverpod + sha256: "9532ee6db4a943a1ed8383072a2e3eeda041db5657cdf6d2acecf3c21ecbe7e1" + url: "https://pub.dev" + source: hosted + version: "2.6.1" + flutter_test: + dependency: "direct dev" + description: flutter + source: sdk + version: "0.0.0" + flutter_web_plugins: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" + freezed_annotation: + dependency: transitive + description: + name: freezed_annotation + sha256: c2e2d632dd9b8a2b7751117abcfc2b4888ecfe181bd9fca7170d9ef02e595fe2 + url: "https://pub.dev" + source: hosted + version: "2.4.4" + frontend_server_client: + dependency: transitive + description: + name: frontend_server_client + sha256: f64a0333a82f30b0cca061bc3d143813a486dc086b574bfb233b7c1372427694 + url: "https://pub.dev" + source: hosted + version: "4.0.0" + glob: + dependency: transitive + description: + name: glob + sha256: c3f1ee72c96f8f78935e18aa8cecced9ab132419e8625dc187e1c2408efc20de + url: "https://pub.dev" + source: hosted + version: "2.1.3" + go_router: + dependency: "direct main" + description: + name: go_router + sha256: b465e99ce64ba75e61c8c0ce3d87b66d8ac07f0b35d0a7e0263fcfc10f99e836 + url: "https://pub.dev" + source: hosted + version: "13.2.5" + google_fonts: + dependency: "direct main" + description: + name: google_fonts + sha256: ba03d03bcaa2f6cb7bd920e3b5027181db75ab524f8891c8bc3aa603885b8055 + url: "https://pub.dev" + source: hosted + version: "6.3.3" + graphs: + dependency: transitive + description: + name: graphs + sha256: "741bbf84165310a68ff28fe9e727332eef1407342fca52759cb21ad8177bb8d0" + url: "https://pub.dev" + source: hosted + version: "2.3.2" + hive: + dependency: "direct main" + description: + name: hive + sha256: "8dcf6db979d7933da8217edcec84e9df1bdb4e4edc7fc77dbd5aa74356d6d941" + url: "https://pub.dev" + source: hosted + version: "2.2.3" + hive_flutter: + dependency: "direct main" + description: + name: hive_flutter + sha256: dca1da446b1d808a51689fb5d0c6c9510c0a2ba01e22805d492c73b68e33eecc + url: "https://pub.dev" + source: hosted + version: "1.1.0" + hive_generator: + dependency: "direct dev" + description: + name: hive_generator + sha256: "06cb8f58ace74de61f63500564931f9505368f45f98958bd7a6c35ba24159db4" + url: "https://pub.dev" + source: hosted + version: "2.0.1" + hooks: + dependency: transitive + description: + name: hooks + sha256: "9a62a50b50b769a737bc0a8ff381f333529df3ab746b2f6b02e83760231455ba" + url: "https://pub.dev" + source: hosted + version: "2.0.2" + html: + dependency: transitive + description: + name: html + sha256: "6d1264f2dffa1b1101c25a91dff0dc2daee4c18e87cd8538729773c073dbf602" + url: "https://pub.dev" + source: hosted + version: "0.15.6" + http: + dependency: "direct main" + description: + name: http + sha256: "87721a4a50b19c7f1d49001e51409bddc46303966ce89a65af4f4e6004896412" + url: "https://pub.dev" + source: hosted + version: "1.6.0" + http_multi_server: + dependency: transitive + description: + name: http_multi_server + sha256: aa6199f908078bb1c5efb8d8638d4ae191aac11b311132c3ef48ce352fb52ef8 + url: "https://pub.dev" + source: hosted + version: "3.2.2" + http_parser: + dependency: transitive + description: + name: http_parser + sha256: "178d74305e7866013777bab2c3d8726205dc5a4dd935297175b19a23a2e66571" + url: "https://pub.dev" + source: hosted + version: "4.1.2" + intl: + dependency: "direct main" + description: + name: intl + sha256: d6f56758b7d3014a48af9701c085700aac781a92a87a62b1333b46d8879661cf + url: "https://pub.dev" + source: hosted + version: "0.19.0" + io: + dependency: transitive + description: + name: io + sha256: dfd5a80599cf0165756e3181807ed3e77daf6dd4137caaad72d0b7931597650b + url: "https://pub.dev" + source: hosted + version: "1.0.5" + jni: + dependency: transitive + description: + name: jni + sha256: f038e58b4dc2c9037f50e233175086337e0b305e356d28211bf55f21c504cbd3 + url: "https://pub.dev" + source: hosted + version: "1.0.3" + jni_flutter: + dependency: transitive + description: + name: jni_flutter + sha256: "7b717011ea40d04fd47c2731d3d1d36eb99eba3435c2753d62489e8c3c9991d5" + url: "https://pub.dev" + source: hosted + version: "1.0.2" + jni_util: + dependency: transitive + description: + name: jni_util + sha256: "1ba86da04a5f2bf18fde2edb235587e70c5b0fc5bd4ba955f46b00942c3fc35f" + url: "https://pub.dev" + source: hosted + version: "1.0.0" + js: + dependency: transitive + description: + name: js + sha256: "53385261521cc4a0c4658fd0ad07a7d14591cf8fc33abbceae306ddb974888dc" + url: "https://pub.dev" + source: hosted + version: "0.7.2" + json_annotation: + dependency: transitive + description: + name: json_annotation + sha256: "2a743920d81b7910627f68ee2c9ac1fc0bfee32b9fc3403587d7c6791ca12f80" + url: "https://pub.dev" + source: hosted + version: "4.12.0" + leak_tracker: + dependency: transitive + description: + name: leak_tracker + sha256: "33e2e26bdd85a0112ec15400c8cbffea70d0f9c3407491f672a2fad47915e2de" + url: "https://pub.dev" + source: hosted + version: "11.0.2" + leak_tracker_flutter_testing: + dependency: transitive + description: + name: leak_tracker_flutter_testing + sha256: "1dbc140bb5a23c75ea9c4811222756104fbcd1a27173f0c34ca01e16bea473c1" + url: "https://pub.dev" + source: hosted + version: "3.0.10" + leak_tracker_testing: + dependency: transitive + description: + name: leak_tracker_testing + sha256: "8d5a2d49f4a66b49744b23b018848400d23e54caf9463f4eb20df3eb8acb2eb1" + url: "https://pub.dev" + source: hosted + version: "3.0.2" + lints: + dependency: transitive + description: + name: lints + sha256: cbf8d4b858bb0134ef3ef87841abdf8d63bfc255c266b7bf6b39daa1085c4290 + url: "https://pub.dev" + source: hosted + version: "3.0.0" + logging: + dependency: transitive + description: + name: logging + sha256: c8245ada5f1717ed44271ed1c26b8ce85ca3228fd2ffdb75468ab01979309d61 + url: "https://pub.dev" + source: hosted + version: "1.3.0" + lottie: + dependency: "direct main" + description: + name: lottie + sha256: a93542cc2d60a7057255405f62252533f8e8956e7e06754955669fd32fb4b216 + url: "https://pub.dev" + source: hosted + version: "2.7.0" + matcher: + dependency: transitive + description: + name: matcher + sha256: dc58c723c3c24bf8d3e2d3ad3f2f9d7bd9cf43ec6feaa64181775e60190153f2 + url: "https://pub.dev" + source: hosted + version: "0.12.17" + material_color_utilities: + dependency: transitive + description: + name: material_color_utilities + sha256: f7142bb1154231d7ea5f96bc7bde4bda2a0945d2806bb11670e30b850d56bdec + url: "https://pub.dev" + source: hosted + version: "0.11.1" + meta: + dependency: transitive + description: + name: meta + sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394" + url: "https://pub.dev" + source: hosted + version: "1.17.0" + mime: + dependency: transitive + description: + name: mime + sha256: "41a20518f0cb1256669420fdba0cd90d21561e560ac240f26ef8322e45bb7ed6" + url: "https://pub.dev" + source: hosted + version: "2.0.0" + nested: + dependency: transitive + description: + name: nested + sha256: "03bac4c528c64c95c722ec99280375a6f2fc708eec17c7b3f07253b626cd2a20" + url: "https://pub.dev" + source: hosted + version: "1.0.0" + objective_c: + dependency: transitive + description: + name: objective_c + sha256: b7fb95a6d9a4f009edd63dc5ac69f07420b23a16161c6dd8660290b59c602e8e + url: "https://pub.dev" + source: hosted + version: "9.5.0" + octo_image: + dependency: transitive + description: + name: octo_image + sha256: "34faa6639a78c7e3cbe79be6f9f96535867e879748ade7d17c9b1ae7536293bd" + url: "https://pub.dev" + source: hosted + version: "2.1.0" + package_config: + dependency: transitive + description: + name: package_config + sha256: f096c55ebb7deb7e384101542bfba8c52696c1b56fca2eb62827989ef2353bbc + url: "https://pub.dev" + source: hosted + version: "2.2.0" + package_info_plus: + dependency: transitive + description: + name: package_info_plus + sha256: "127e1751e37ffb2ff4658beeaca77bad0c27bf5f932bd3a501c2296926d4b481" + url: "https://pub.dev" + source: hosted + version: "10.2.1" + package_info_plus_platform_interface: + dependency: transitive + description: + name: package_info_plus_platform_interface + sha256: db762cb2f4f25ee60fb6359773861b0f199e00b90d237bd85a76a1e806b46ef4 + url: "https://pub.dev" + source: hosted + version: "4.1.0" + path: + dependency: transitive + description: + name: path + sha256: "75cca69d1490965be98c73ceaea117e8a04dd21217b37b292c9ddbec0d955bc5" + url: "https://pub.dev" + source: hosted + version: "1.9.1" + path_provider: + dependency: transitive + description: + name: path_provider + sha256: a7f4874f987173da295a61c181b8ee71dab59b332a486b391babf26a1b884825 + url: "https://pub.dev" + source: hosted + version: "2.1.6" + path_provider_android: + dependency: transitive + description: + name: path_provider_android + sha256: "69cbd515a62b94d32a7944f086b2f82b4ac40a1d45bebfc00813a430ab2dabcd" + url: "https://pub.dev" + source: hosted + version: "2.3.1" + path_provider_foundation: + dependency: transitive + description: + name: path_provider_foundation + sha256: "2a376b7d6392d80cd3705782d2caa734ca4727776db0b6ec36ef3f1855197699" + url: "https://pub.dev" + source: hosted + version: "2.6.0" + path_provider_linux: + dependency: transitive + description: + name: path_provider_linux + sha256: "58c2005f147315b11e9b4a7bc889cd5203e250cba8e3f012dae259b4972b5c16" + url: "https://pub.dev" + source: hosted + version: "2.2.2" + path_provider_platform_interface: + dependency: transitive + description: + name: path_provider_platform_interface + sha256: "484838772624c3a4b94f1e44a3e19897fee738f2d5c4ce448443b0417f7c9dda" + url: "https://pub.dev" + source: hosted + version: "2.1.3" + path_provider_windows: + dependency: transitive + description: + name: path_provider_windows + sha256: bd6f00dbd873bfb70d0761682da2b3a2c2fccc2b9e84c495821639601d81afe7 + url: "https://pub.dev" + source: hosted + version: "2.3.0" + percent_indicator: + dependency: "direct main" + description: + name: percent_indicator + sha256: "157d29133bbc6ecb11f923d36e7960a96a3f28837549a20b65e5135729f0f9fd" + url: "https://pub.dev" + source: hosted + version: "4.2.5" + petitparser: + dependency: transitive + description: + name: petitparser + sha256: "91bd59303e9f769f108f8df05e371341b15d59e995e6806aefab827b58336675" + url: "https://pub.dev" + source: hosted + version: "7.0.2" + platform: + dependency: transitive + description: + name: platform + sha256: "5d6b1b0036a5f331ebc77c850ebc8506cbc1e9416c27e59b439f917a902a4984" + url: "https://pub.dev" + source: hosted + version: "3.1.6" + plugin_platform_interface: + dependency: transitive + description: + name: plugin_platform_interface + sha256: "4820fbfdb9478b1ebae27888254d445073732dae3d6ea81f0b7e06d5dedc3f02" + url: "https://pub.dev" + source: hosted + version: "2.1.8" + pointer_interceptor: + dependency: "direct main" + description: + name: pointer_interceptor + sha256: "57210410680379aea8b1b7ed6ae0c3ad349bfd56fe845b8ea934a53344b9d523" + url: "https://pub.dev" + source: hosted + version: "0.10.1+2" + pointer_interceptor_ios: + dependency: transitive + description: + name: pointer_interceptor_ios + sha256: "03c5fa5896080963ab4917eeffda8d28c90f22863a496fb5ba13bc10943e40e4" + url: "https://pub.dev" + source: hosted + version: "0.10.1+1" + pointer_interceptor_platform_interface: + dependency: transitive + description: + name: pointer_interceptor_platform_interface + sha256: "0597b0560e14354baeb23f8375cd612e8bd4841bf8306ecb71fcd0bb78552506" + url: "https://pub.dev" + source: hosted + version: "0.10.0+1" + pointer_interceptor_web: + dependency: transitive + description: + name: pointer_interceptor_web + sha256: "460b600e71de6fcea2b3d5f662c92293c049c4319e27f0829310e5a953b3ee2a" + url: "https://pub.dev" + source: hosted + version: "0.10.3" + pool: + dependency: transitive + description: + name: pool + sha256: "978783255c543aa3586a1b3c21f6e9d720eb315376a915872c61ef8b5c20177d" + url: "https://pub.dev" + source: hosted + version: "1.5.2" + provider: + dependency: transitive + description: + name: provider + sha256: "4e82183fa20e5ca25703ead7e05de9e4cceed1fbd1eadc1ac3cb6f565a09f272" + url: "https://pub.dev" + source: hosted + version: "6.1.5+1" + pub_semver: + dependency: transitive + description: + name: pub_semver + sha256: "5bfcf68ca79ef689f8990d1160781b4bad40a3bd5e5218ad4076ddb7f4081585" + url: "https://pub.dev" + source: hosted + version: "2.2.0" + pubspec_parse: + dependency: transitive + description: + name: pubspec_parse + sha256: "0560ba233314abbed0a48a2956f7f022cce7c3e1e73df540277da7544cad4082" + url: "https://pub.dev" + source: hosted + version: "1.5.0" + record_use: + dependency: transitive + description: + name: record_use + sha256: "2551bd8eecfe95d14ae75f6021ad0248be5c27f138c2ec12fcb52b500b3ba1ed" + url: "https://pub.dev" + source: hosted + version: "0.6.0" + riverpod: + dependency: transitive + description: + name: riverpod + sha256: "59062512288d3056b2321804332a13ffdd1bf16df70dcc8e506e411280a72959" + url: "https://pub.dev" + source: hosted + version: "2.6.1" + riverpod_analyzer_utils: + dependency: transitive + description: + name: riverpod_analyzer_utils + sha256: "8b71f03fc47ae27d13769496a1746332df4cec43918aeba9aff1e232783a780f" + url: "https://pub.dev" + source: hosted + version: "0.5.1" + riverpod_annotation: + dependency: "direct main" + description: + name: riverpod_annotation + sha256: e14b0bf45b71326654e2705d462f21b958f987087be850afd60578fcd502d1b8 + url: "https://pub.dev" + source: hosted + version: "2.6.1" + riverpod_generator: + dependency: "direct dev" + description: + name: riverpod_generator + sha256: d451608bf17a372025fc36058863737636625dfdb7e3cbf6142e0dfeb366ab22 + url: "https://pub.dev" + source: hosted + version: "2.4.0" + rxdart: + dependency: transitive + description: + name: rxdart + sha256: "5c3004a4a8dbb94bd4bf5412a4def4acdaa12e12f269737a5751369e12d1a962" + url: "https://pub.dev" + source: hosted + version: "0.28.0" + shared_preferences: + dependency: "direct main" + description: + name: shared_preferences + sha256: c3025c5534b01739267eb7d76959bbc25a6d10f6988e1c2a3036940133dd10bf + url: "https://pub.dev" + source: hosted + version: "2.5.5" + shared_preferences_android: + dependency: transitive + description: + name: shared_preferences_android + sha256: e8d4762b1e2e8578fc4d0fd548cebf24afd24f49719c08974df92834565e2c53 + url: "https://pub.dev" + source: hosted + version: "2.4.23" + shared_preferences_foundation: + dependency: transitive + description: + name: shared_preferences_foundation + sha256: "4e7eaffc2b17ba398759f1151415869a34771ba11ebbccd1b0145472a619a64f" + url: "https://pub.dev" + source: hosted + version: "2.5.6" + shared_preferences_linux: + dependency: transitive + description: + name: shared_preferences_linux + sha256: "580abfd40f415611503cae30adf626e6656dfb2f0cee8f465ece7b6defb40f2f" + url: "https://pub.dev" + source: hosted + version: "2.4.1" + shared_preferences_platform_interface: + dependency: transitive + description: + name: shared_preferences_platform_interface + sha256: "649dc798a33931919ea356c4305c2d1f81619ea6e92244070b520187b5140ef9" + url: "https://pub.dev" + source: hosted + version: "2.4.2" + shared_preferences_web: + dependency: transitive + description: + name: shared_preferences_web + sha256: c49bd060261c9a3f0ff445892695d6212ff603ef3115edbb448509d407600019 + url: "https://pub.dev" + source: hosted + version: "2.4.3" + shared_preferences_windows: + dependency: transitive + description: + name: shared_preferences_windows + sha256: "94ef0f72b2d71bc3e700e025db3710911bd51a71cefb65cc609dd0d9a982e3c1" + url: "https://pub.dev" + source: hosted + version: "2.4.1" + shelf: + dependency: transitive + description: + name: shelf + sha256: e7dd780a7ffb623c57850b33f43309312fc863fb6aa3d276a754bb299839ef12 + url: "https://pub.dev" + source: hosted + version: "1.4.2" + shelf_web_socket: + dependency: transitive + description: + name: shelf_web_socket + sha256: cc36c297b52866d203dbf9332263c94becc2fe0ceaa9681d07b6ef9807023b67 + url: "https://pub.dev" + source: hosted + version: "2.0.1" + sky_engine: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" + source_gen: + dependency: transitive + description: + name: source_gen + sha256: "14658ba5f669685cd3d63701d01b31ea748310f7ab854e471962670abcf57832" + url: "https://pub.dev" + source: hosted + version: "1.5.0" + source_helper: + dependency: transitive + description: + name: source_helper + sha256: "86d247119aedce8e63f4751bd9626fc9613255935558447569ad42f9f5b48b3c" + url: "https://pub.dev" + source: hosted + version: "1.3.5" + source_span: + dependency: transitive + description: + name: source_span + sha256: "56a02f1f4cd1a2d96303c0144c93bd6d909eea6bee6bf5a0e0b685edbd4c47ab" + url: "https://pub.dev" + source: hosted + version: "1.10.2" + sqflite: + dependency: transitive + description: + name: sqflite + sha256: "564cfed0746fe53140c23b70b308e045c3b31f17778f2f326ccb7d804ea0250a" + url: "https://pub.dev" + source: hosted + version: "2.4.2+1" + sqflite_android: + dependency: transitive + description: + name: sqflite_android + sha256: "881e28efdcc9950fd8e9bb42713dcf1103e62a2e7168f23c9338d82db13dec40" + url: "https://pub.dev" + source: hosted + version: "2.4.2+3" + sqflite_common: + dependency: transitive + description: + name: sqflite_common + sha256: "1581ffbf7a0e333b380d6a30737d78516b826cb35beb7fb0bf8a3ea0c678b465" + url: "https://pub.dev" + source: hosted + version: "2.5.8" + sqflite_darwin: + dependency: transitive + description: + name: sqflite_darwin + sha256: "279832e5cde3fe99e8571879498c9211f3ca6391b0d818df4e17d9fff5c6ccb3" + url: "https://pub.dev" + source: hosted + version: "2.4.2" + sqflite_platform_interface: + dependency: transitive + description: + name: sqflite_platform_interface + sha256: "8dd4515c7bdcae0a785b0062859336de775e8c65db81ae33dd5445f35be61920" + url: "https://pub.dev" + source: hosted + version: "2.4.0" + stack_trace: + dependency: transitive + description: + name: stack_trace + sha256: "8b27215b45d22309b5cddda1aa2b19bdfec9df0e765f2de506401c071d38d1b1" + url: "https://pub.dev" + source: hosted + version: "1.12.1" + state_notifier: + dependency: transitive + description: + name: state_notifier + sha256: b8677376aa54f2d7c58280d5a007f9e8774f1968d1fb1c096adcb4792fba29bb + url: "https://pub.dev" + source: hosted + version: "1.0.0" + stream_channel: + dependency: transitive + description: + name: stream_channel + sha256: "969e04c80b8bcdf826f8f16579c7b14d780458bd97f56d107d3950fdbeef059d" + url: "https://pub.dev" + source: hosted + version: "2.1.4" + stream_transform: + dependency: transitive + description: + name: stream_transform + sha256: ad47125e588cfd37a9a7f86c7d6356dde8dfe89d071d293f80ca9e9273a33871 + url: "https://pub.dev" + source: hosted + version: "2.1.1" + string_scanner: + dependency: transitive + description: + name: string_scanner + sha256: "921cd31725b72fe181906c6a94d987c78e3b98c2e205b397ea399d4054872b43" + url: "https://pub.dev" + source: hosted + version: "1.4.1" + subtitle: + dependency: "direct main" + description: + name: subtitle + sha256: "29115fbaa5d87c5909ffa477cbfdeec2c6ee8ac9a5ef3dce9b2db731afc79f26" + url: "https://pub.dev" + source: hosted + version: "0.1.4" + synchronized: + dependency: transitive + description: + name: synchronized + sha256: c254ade258ec8282947a0acbbc90b9575b4f19673533ee46f2f6e9b3aeefd7c0 + url: "https://pub.dev" + source: hosted + version: "3.4.0" + term_glyph: + dependency: transitive + description: + name: term_glyph + sha256: "7f554798625ea768a7518313e58f83891c7f5024f88e46e7182a4558850a4b8e" + url: "https://pub.dev" + source: hosted + version: "1.2.2" + test_api: + dependency: transitive + description: + name: test_api + sha256: ab2726c1a94d3176a45960b6234466ec367179b87dd74f1611adb1f3b5fb9d55 + url: "https://pub.dev" + source: hosted + version: "0.7.7" + timing: + dependency: transitive + description: + name: timing + sha256: "62ee18aca144e4a9f29d212f5a4c6a053be252b895ab14b5821996cff4ed90fe" + url: "https://pub.dev" + source: hosted + version: "1.0.2" + typed_data: + dependency: transitive + description: + name: typed_data + sha256: f9049c039ebfeb4cf7a7104a675823cd72dba8297f264b6637062516699fa006 + url: "https://pub.dev" + source: hosted + version: "1.4.0" + universal_io: + dependency: transitive + description: + name: universal_io + sha256: ba9dde5f7c6d8ec7ed856bef0eb01c425881acae7748f77dac4d5cc46f2edf04 + url: "https://pub.dev" + source: hosted + version: "2.2.3" + uuid: + dependency: "direct main" + description: + name: uuid + sha256: "9b129329f58692f6e6578329498a8fe9fbe98f090beb764ffbb8ee2eadd01dcd" + url: "https://pub.dev" + source: hosted + version: "4.6.0" + vector_math: + dependency: transitive + description: + name: vector_math + sha256: d530bd74fea330e6e364cda7a85019c434070188383e1cd8d9777ee586914c5b + url: "https://pub.dev" + source: hosted + version: "2.2.0" + video_player: + dependency: "direct main" + description: + name: video_player + sha256: "48a7bdaa38a3d50ec10c78627abdbfad863fdf6f0d6e08c7c3c040cfd80ae36f" + url: "https://pub.dev" + source: hosted + version: "2.11.1" + video_player_android: + dependency: transitive + description: + name: video_player_android + sha256: "877a6c7ba772456077d7bfd71314629b3fe2b73733ce503fc77c3314d43a0ca0" + url: "https://pub.dev" + source: hosted + version: "2.9.5" + video_player_avfoundation: + dependency: transitive + description: + name: video_player_avfoundation + sha256: c238f5f0a26845cd0bcc2956049b63065a4d3c40ddfc22c3414cd170bef7fff9 + url: "https://pub.dev" + source: hosted + version: "2.11.0" + video_player_platform_interface: + dependency: transitive + description: + name: video_player_platform_interface + sha256: "92c0fbabe20c788e71fd10d26cea998d0d253282e65d145aed0818731cf593ce" + url: "https://pub.dev" + source: hosted + version: "6.9.0" + video_player_web: + dependency: "direct main" + description: + name: video_player_web + sha256: "9f3c00be2ef9b76a95d94ac5119fb843dca6f2c69e6c9968f6f2b6c9e7afbdeb" + url: "https://pub.dev" + source: hosted + version: "2.4.0" + video_player_web_hls: + dependency: "direct main" + description: + name: video_player_web_hls + sha256: "0b494ad7a185d4a4e6ab998e342d408ee1fa0938fec0e27ee6ce2b361d6b458a" + url: "https://pub.dev" + source: hosted + version: "1.3.0" + video_player_win: + dependency: "direct main" + description: + name: video_player_win + sha256: a4caca55ead1eb469d10060592e7ecdcbcd4493c6e2b63e4e666ff5446c790f1 + url: "https://pub.dev" + source: hosted + version: "3.2.2" + vm_service: + dependency: transitive + description: + name: vm_service + sha256: "5f37239c4851efcef929cea7824e76df7f2f0970aef85d66bbc430afa40e72f0" + url: "https://pub.dev" + source: hosted + version: "15.3.0" + wakelock_plus: + dependency: transitive + description: + name: wakelock_plus + sha256: "824c5bba0f800e86d32e57d3d1843c531f090005cc89d9a837933e6601093d53" + url: "https://pub.dev" + source: hosted + version: "1.6.1" + wakelock_plus_platform_interface: + dependency: transitive + description: + name: wakelock_plus_platform_interface + sha256: b13f99e992e7ae6a152e16c5559d3c07ff445b13330192662494e614ca3e7d7b + url: "https://pub.dev" + source: hosted + version: "1.5.1" + watcher: + dependency: transitive + description: + name: watcher + sha256: "1398c9f081a753f9226febe8900fce8f7d0a67163334e1c94a2438339d79d635" + url: "https://pub.dev" + source: hosted + version: "1.2.1" + web: + dependency: transitive + description: + name: web + sha256: "868d88a33d8a87b18ffc05f9f030ba328ffefba92d6c127917a2ba740f9cfe4a" + url: "https://pub.dev" + source: hosted + version: "1.1.1" + web_socket: + dependency: transitive + description: + name: web_socket + sha256: "34d64019aa8e36bf9842ac014bb5d2f5586ca73df5e4d9bf5c936975cae6982c" + url: "https://pub.dev" + source: hosted + version: "1.0.1" + web_socket_channel: + dependency: transitive + description: + name: web_socket_channel + sha256: d645757fb0f4773d602444000a8131ff5d48c9e47adfe9772652dd1a4f2d45c8 + url: "https://pub.dev" + source: hosted + version: "3.0.3" + win32: + dependency: transitive + description: + name: win32 + sha256: a0b93865d5644f11cf6a8c3f6db909f1ec168958b5805f6cc684adea957cd63d + url: "https://pub.dev" + source: hosted + version: "6.4.0" + xdg_directories: + dependency: transitive + description: + name: xdg_directories + sha256: "7a3f37b05d989967cdddcbb571f1ea834867ae2faa29725fd085180e0883aa15" + url: "https://pub.dev" + source: hosted + version: "1.1.0" + xml: + dependency: transitive + description: + name: xml + sha256: "971043b3a0d3da28727e40ed3e0b5d18b742fa5a68665cca88e74b7876d5e025" + url: "https://pub.dev" + source: hosted + version: "6.6.1" + yaml: + dependency: transitive + description: + name: yaml + sha256: b9da305ac7c39faa3f030eccd175340f968459dae4af175130b3fc47e40d76ce + url: "https://pub.dev" + source: hosted + version: "3.1.3" +sdks: + dart: ">=3.10.3 <4.0.0" + flutter: ">=3.38.4"