diff --git a/Dockerfile b/Dockerfile index d666be6..379786b 100644 --- a/Dockerfile +++ b/Dockerfile @@ -78,6 +78,8 @@ RUN apt-get update && apt-get install -y --no-install-recommends \ wget \ xz-utils \ gosu \ + fonts-dejavu-core \ + tzdata \ && rm -rf /var/lib/apt/lists/* # FFmpeg with NVENC (cached unless this RUN changes) diff --git a/lib/features/iptv/widgets/recordings_tab.dart b/lib/features/iptv/widgets/recordings_tab.dart index 801450c..f278962 100644 --- a/lib/features/iptv/widgets/recordings_tab.dart +++ b/lib/features/iptv/widgets/recordings_tab.dart @@ -17,8 +17,9 @@ 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 +// Quatre vues : Guide TV (programmer depuis l'EPG), Enregistrements (liste), +// Season Passes (enregistrements récurrents), Auto NBA (matchs en direct +// sur beIN Sports FR). _EpgGuideView et // _SeasonPassesView existaient déjà mais n'étaient plus instanciés depuis // une refonte : les fonctions étaient codées mais inaccessibles. // ═══════════════════════════════════════════════════════════════════════════ @@ -40,7 +41,7 @@ class _RecordingsTabState extends State 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); + _tabController = TabController(length: 4, vsync: this, initialIndex: 1); } @override @@ -95,6 +96,10 @@ class _RecordingsTabState extends State icon: Icon(Icons.repeat, size: 18), text: 'Season Passes', ), + Tab( + icon: Icon(Icons.sports_basketball, size: 18), + text: 'Auto NBA', + ), ], ), ], @@ -107,6 +112,7 @@ class _RecordingsTabState extends State _EpgGuideView(playlist: widget.playlist), _RecordingsListView(playlist: widget.playlist), const _SeasonPassesView(), + const _AutoNbaView(), ], ), ), @@ -1306,6 +1312,33 @@ class _RecordingsListViewState extends ConsumerState<_RecordingsListView> { trailing: Row( mainAxisSize: MainAxisSize.min, children: [ + if (rec['source_rule_id'] != null) ...[ + Container( + padding: const EdgeInsets.symmetric( + horizontal: 10, + vertical: 4, + ), + decoration: BoxDecoration( + color: AppColors.tertiary + .withOpacity(0.15), + borderRadius: + BorderRadius.circular(20), + border: Border.all( + color: AppColors.tertiary + .withOpacity(0.4), + ), + ), + child: const Text( + 'Auto NBA', + style: TextStyle( + color: AppColors.tertiary, + fontWeight: FontWeight.bold, + fontSize: 11, + ), + ), + ), + const SizedBox(width: 4), + ], Container( padding: const EdgeInsets.symmetric( horizontal: 10, @@ -1693,3 +1726,736 @@ class _SeasonPassesViewState extends State<_SeasonPassesView> { ); } } + +// ═══════════════════════════════════════════════════════════════════════════ +// ONGLET 4 — AUTO NBA +// Règle serveur `/api/auto-rules` : enregistre chaque match NBA diffusé EN +// DIRECT sur les beIN Sports françaises (jamais les rediffusions). Le +// calendrier NBA donne les matchs des semaines à l'avance ; l'EPG confirme +// la chaîne ~48 h avant. Chaque match peut être accepté ou refusé d'avance. +// ═══════════════════════════════════════════════════════════════════════════ + +const _jours = ['lun.', 'mar.', 'mer.', 'jeu.', 'ven.', 'sam.', 'dim.']; +const _mois = [ + 'janv.', + 'févr.', + 'mars', + 'avr.', + 'mai', + 'juin', + 'juil.', + 'août', + 'sept.', + 'oct.', + 'nov.', + 'déc.', +]; + +class _AutoNbaView extends StatefulWidget { + const _AutoNbaView(); + @override + State<_AutoNbaView> createState() => _AutoNbaViewState(); +} + +class _AutoNbaViewState extends State<_AutoNbaView> { + Map? _rule; + List _games = []; + bool _isLoading = true; + bool _busy = false; + String? _error; + bool _showOffBein = false; + + @override + void initState() { + super.initState(); + _load(); + } + + Future _load() async { + setState(() { + _isLoading = _rule == null; + _error = null; + }); + try { + final r = await AuthedHttp.get(Uri.parse('/api/auto-rules')); + if (r.statusCode != 200) throw Exception('HTTP ${r.statusCode}'); + final rules = (json.decode(r.body) as List) + .where((x) => x['kind'] == 'nba') + .toList(); + if (rules.isEmpty) { + if (!mounted) return; + setState(() { + _rule = null; + _games = []; + _isLoading = false; + }); + return; + } + final rule = rules.first as Map; + final g = await AuthedHttp.get( + Uri.parse('/api/auto-rules/${rule['id']}/games?days=21'), + ); + if (g.statusCode != 200) throw Exception('HTTP ${g.statusCode}'); + if (!mounted) return; + setState(() { + _rule = rule; + _games = json.decode(g.body)['games'] as List; + _isLoading = false; + }); + } catch (e) { + if (!mounted) return; + setState(() { + _error = '$e'; + _isLoading = false; + }); + } + } + + void _snack(String text) { + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(text))); + } + + Future _createRule() async { + setState(() => _busy = true); + final r = await AuthedHttp.post( + Uri.parse('/api/auto-rules'), + headers: {'Content-Type': 'application/json'}, + body: json.encode({}), + ); + if (!mounted) return; + setState(() => _busy = false); + if (r.statusCode == 201) { + final channels = (json.decode(r.body)['channel_ids'] as List).length; + _snack('✅ Auto NBA activé · $channels chaîne(s) beIN Sports FR'); + await _load(); + // Le calendrier se charge en tâche de fond côté serveur. + Future.delayed(const Duration(seconds: 8), () { + if (mounted) _load(); + }); + } else { + _snack('❌ ${r.body}'); + } + } + + Future _updateRule(Map changes) async { + final id = _rule?['id']; + if (id == null) return; + final r = await AuthedHttp.put( + Uri.parse('/api/auto-rules/$id'), + headers: {'Content-Type': 'application/json'}, + body: json.encode(changes), + ); + if (!mounted) return; + if (r.statusCode == 200) { + setState(() => _rule = json.decode(r.body) as Map); + // La réconciliation tourne en tâche de fond : relire peu après. + Future.delayed(const Duration(seconds: 3), () { + if (mounted) _load(); + }); + } else { + _snack('❌ ${r.body}'); + } + } + + Future _refreshNow() async { + final id = _rule?['id']; + if (id == null) return; + setState(() => _busy = true); + final r = await AuthedHttp.post(Uri.parse('/api/auto-rules/$id/refresh')); + if (!mounted) return; + setState(() => _busy = false); + if (r.statusCode == 200) { + setState(() => _games = json.decode(r.body)['games'] as List); + notifyRecordingsChanged(); + } else { + _snack('❌ ${r.body}'); + } + } + + Future _decide(Map game, bool yes) async { + final id = _rule?['id']; + if (id == null) return; + final r = await AuthedHttp.post( + Uri.parse('/api/auto-rules/$id/games/${game['game_id']}/decision'), + headers: {'Content-Type': 'application/json'}, + body: json.encode({'decision': yes ? 'accepted' : 'refused'}), + ); + if (!mounted) return; + if (r.statusCode == 200) { + final updated = json.decode(r.body); + setState(() { + _games = [ + for (final g in _games) + g['game_id'] == updated['game_id'] ? updated : g, + ]; + }); + notifyRecordingsChanged(); + } else { + _snack('❌ ${r.body}'); + } + } + + // ── Réglages ──────────────────────────────────────────────────────────── + + Future _showSettings() async { + final rule = _rule; + if (rule == null) return; + List channels = []; + final r = await AuthedHttp.get( + Uri.parse('/api/auto-rules/${rule['id']}/channels'), + ); + if (r.statusCode == 200) { + channels = json.decode(r.body)['channels'] as List; + } + if (!mounted) return; + + final selected = { + for (final c in (rule['channel_ids'] as List)) c.toString(), + }; + var askFirst = rule['default_decision'] == 'ask'; + final beforeCtrl = + TextEditingController(text: '${rule['pad_before_min'] ?? 5}'); + final afterCtrl = + TextEditingController(text: '${rule['pad_after_min'] ?? 45}'); + final teamsCtrl = TextEditingController( + text: (rule['team_filter'] as List).join(', '), + ); + final keepCtrl = TextEditingController( + text: rule['keep_days'] == null ? '' : '${rule['keep_days']}', + ); + // Françaises d'abord ; les autres restent sélectionnables en cas + // d'erreur de détection. + final fr = channels.where((c) => c['is_fr'] == true).toList(); + final others = channels.where((c) => c['is_fr'] != true).toList(); + + await showDialog( + context: context, + builder: (ctx) => StatefulBuilder( + builder: (ctx, setDialog) { + Widget channelRow(dynamic c) => SwitchListTile( + dense: true, + contentPadding: EdgeInsets.zero, + title: Text( + c['label'] ?? c['name'], + style: GoogleFonts.fraunces( + color: AppColors.onSurface, + fontSize: 13, + ), + ), + subtitle: Text( + c['name'] ?? '', + style: const TextStyle( + color: AppColors.onSurface38, + fontSize: 11, + ), + ), + value: selected.contains(c['stream_id'].toString()), + activeThumbColor: AppColors.primaryContainer, + onChanged: (v) => setDialog(() { + final id = c['stream_id'].toString(); + v ? selected.add(id) : selected.remove(id); + }), + ); + return AlertDialog( + backgroundColor: AppColors.surfaceContainer, + title: Row( + children: [ + const Icon(Icons.sports_basketball, color: AppColors.secondary), + const SizedBox(width: 8), + Text( + 'Réglages Auto NBA', + style: GoogleFonts.fraunces(color: AppColors.onSurface), + ), + ], + ), + content: SizedBox( + width: 460, + child: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SwitchListTile( + contentPadding: EdgeInsets.zero, + title: Text( + 'Demander avant chaque match', + style: GoogleFonts.fraunces( + color: AppColors.onSurface, + fontSize: 14, + ), + ), + subtitle: Text( + askFirst + ? "Rien n'est enregistré sans votre oui" + : 'Tout est enregistré, vous pouvez refuser', + style: const TextStyle( + color: AppColors.onSurface54, + fontSize: 12, + ), + ), + value: askFirst, + activeThumbColor: AppColors.primaryContainer, + onChanged: (v) => setDialog(() => askFirst = v), + ), + const SizedBox(height: 8), + Row( + children: [ + Expanded( + child: _underlineField( + beforeCtrl, + 'Marge avant (min)', + number: true, + ), + ), + const SizedBox(width: 16), + Expanded( + child: _underlineField( + afterCtrl, + 'Marge après (min)', + number: true, + ), + ), + ], + ), + const SizedBox(height: 8), + _underlineField( + teamsCtrl, + 'Équipes suivies (vide = toutes, ex: Lakers, Celtics)', + ), + const SizedBox(height: 8), + _underlineField( + keepCtrl, + 'Garder les matchs (jours, vide = toujours)', + number: true, + ), + const SizedBox(height: 16), + Text( + 'Chaînes beIN Sports françaises', + style: GoogleFonts.fraunces( + color: AppColors.onSurface54, + fontSize: 13, + ), + ), + if (fr.isEmpty) + const Padding( + padding: EdgeInsets.only(top: 8), + child: Text( + 'Aucune beIN Sports FR détectée dans la playlist', + style: TextStyle( + color: AppColors.warning, + fontSize: 12, + ), + ), + ), + ...fr.map(channelRow), + if (others.isNotEmpty) + ExpansionTile( + tilePadding: EdgeInsets.zero, + title: Text( + 'Autres beIN (${others.length})', + style: GoogleFonts.fraunces( + color: AppColors.onSurface54, + fontSize: 13, + ), + ), + children: others.map(channelRow).toList(), + ), + ], + ), + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(ctx), + child: const Text('Annuler'), + ), + ElevatedButton( + style: ElevatedButton.styleFrom( + backgroundColor: AppColors.secondaryContainer, + foregroundColor: AppColors.onSecondaryContainer, + ), + onPressed: () { + Navigator.pop(ctx); + final keep = int.tryParse(keepCtrl.text.trim()); + _updateRule({ + 'default_decision': askFirst ? 'ask' : 'auto', + 'channel_ids': selected.toList(), + 'pad_before_min': + int.tryParse(beforeCtrl.text.trim()) ?? 5, + 'pad_after_min': int.tryParse(afterCtrl.text.trim()) ?? 45, + 'team_filter': teamsCtrl.text + .split(',') + .map((t) => t.trim()) + .where((t) => t.isNotEmpty) + .toList(), + 'keep_days': (keep != null && keep > 0) ? keep : null, + }); + }, + child: const Text('Enregistrer'), + ), + ], + ); + }, + ), + ); + } + + Widget _underlineField( + TextEditingController c, + String label, { + bool number = false, + }) { + return TextField( + controller: c, + keyboardType: number ? TextInputType.number : TextInputType.text, + style: const TextStyle(color: AppColors.onSurface), + decoration: InputDecoration( + labelText: label, + labelStyle: const TextStyle(color: AppColors.onSurface54, fontSize: 12), + enabledBorder: const UnderlineInputBorder( + borderSide: BorderSide(color: AppColors.onSurface24), + ), + focusedBorder: const UnderlineInputBorder( + borderSide: BorderSide(color: AppColors.secondary), + ), + ), + ); + } + + // ── Présentation ──────────────────────────────────────────────────────── + + (String, Color) _status(Map g) { + if (g['conflict'] == true) return ('Conflit', AppColors.warning); + if (g['decision'] == 'refused' && + !['recorded', 'missed'].contains(g['status'])) { + return ('Refusé', AppColors.onSurface38); + } + return switch (g['status']) { + 'scheduled' => ('Programmé', AppColors.primaryContainer), + 'confirmed' => ('Confirmé', AppColors.tertiary), + 'recorded' => ('Enregistré', AppColors.success), + 'missed' => ('Manqué', AppColors.warning), + 'not_on_bein' => ('Pas sur beIN', AppColors.onSurface38), + _ => ('Prévu', AppColors.onSurface38), + }; + } + + /// Nuit de diffusion : un match à 02:30 appartient à la nuit de la veille. + String _nightLabel(DateTime local) { + final d = local.subtract(const Duration(hours: 12)); + return 'Nuit du ${_jours[d.weekday - 1]} ${d.day} ${_mois[d.month - 1]}'; + } + + String _hhmm(DateTime d) => + '${d.hour.toString().padLeft(2, '0')}:${d.minute.toString().padLeft(2, '0')}'; + + Widget _pill(String label, Color color) => Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4), + decoration: BoxDecoration( + color: color.withOpacity(0.15), + borderRadius: BorderRadius.circular(20), + border: Border.all(color: color.withOpacity(0.4)), + ), + child: Text( + label, + style: TextStyle( + color: color, + fontWeight: FontWeight.bold, + fontSize: 11, + ), + ), + ); + + Widget _gameCard(Map g) { + final tipoff = DateTime.parse(g['tipoff_utc']).toLocal(); + final (label, color) = _status(g); + final locked = + ['recorded', 'missed', 'not_on_bein'].contains(g['status']) || + tipoff.isBefore(DateTime.now()); + final channel = g['channel_name'] as String?; + return Container( + margin: const EdgeInsets.only(bottom: 8), + decoration: BoxDecoration( + color: AppColors.onSurface.withOpacity(0.05), + borderRadius: BorderRadius.circular(12), + border: Border.all(color: AppColors.onSurface.withOpacity(0.1)), + ), + child: ListTile( + contentPadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + leading: ClipRRect( + borderRadius: BorderRadius.circular(8), + child: Image.network( + g['artwork_url'], + width: 128, + height: 72, + cacheWidth: 256, + fit: BoxFit.cover, + errorBuilder: (_, __, ___) => const SizedBox( + width: 128, + height: 72, + child: Icon( + Icons.sports_basketball, + color: AppColors.onSurface24, + ), + ), + ), + ), + title: Text( + '${g['away']} @ ${g['home']}', + style: GoogleFonts.fraunces( + color: AppColors.onSurface, + fontWeight: FontWeight.w600, + ), + ), + subtitle: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + '${_hhmm(tipoff)} · ${channel ?? 'chaîne confirmée ~48 h avant'}', + style: const TextStyle( + color: AppColors.onSurface54, + fontSize: 12, + ), + ), + if (g['epg_title'] != null) + Text( + 'Guide : ${g['epg_title']}', + style: const TextStyle( + color: AppColors.onSurface38, + fontSize: 11, + ), + ), + ], + ), + trailing: Row( + mainAxisSize: MainAxisSize.min, + children: [ + _pill(label, color), + const SizedBox(width: 8), + Tooltip( + message: g['wanted'] == true + ? 'Sera enregistré — désactiver pour refuser' + : 'Ne sera pas enregistré — activer pour accepter', + child: Switch( + value: g['wanted'] == true, + activeThumbColor: AppColors.primaryContainer, + onChanged: locked ? null : (v) => _decide(g, v), + ), + ), + ], + ), + ), + ); + } + + Widget _emptyState( + IconData icon, + String title, + String subtitle, { + Widget? action, + }) { + return Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(icon, size: 64, color: AppColors.glassLevel1Border), + const SizedBox(height: 16), + Text( + title, + style: GoogleFonts.fraunces( + color: AppColors.onSurface38, + fontSize: 16, + ), + ), + const SizedBox(height: 8), + Text( + subtitle, + style: GoogleFonts.fraunces( + color: AppColors.onSurface24, + fontSize: 12, + ), + textAlign: TextAlign.center, + ), + if (action != null) ...[const SizedBox(height: 16), action], + ], + ), + ); + } + + Widget _body() { + if (_isLoading) return const Center(child: CircularProgressIndicator()); + if (_error != null) { + return Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Text( + 'Impossible de charger les matchs NBA', + style: TextStyle(color: AppColors.live), + ), + const SizedBox(height: 12), + OutlinedButton.icon( + icon: const Icon(Icons.refresh, size: 18), + label: const Text('Réessayer'), + onPressed: _load, + ), + ], + ), + ); + } + if (_rule == null) { + return _emptyState( + Icons.sports_basketball, + 'Auto NBA désactivé', + 'Enregistre chaque match NBA diffusé en direct sur beIN Sports FR, ' + 'sans les rediffusions.', + action: ElevatedButton.icon( + style: ElevatedButton.styleFrom( + backgroundColor: AppColors.secondaryContainer, + foregroundColor: AppColors.onSecondaryContainer, + ), + icon: const Icon(Icons.add, size: 18), + label: const Text('Activer'), + onPressed: _busy ? null : _createRule, + ), + ); + } + final visible = _games + .where((g) => _showOffBein || g['status'] != 'not_on_bein') + .toList(); + if (visible.isEmpty) { + return _emptyState( + Icons.event_busy, + 'Aucun match à venir', + 'Le calendrier NBA est rechargé toutes les 12 h', + ); + } + // Liste aplatie : intertitre de nuit puis ses matchs. + final items = []; + String? night; + for (final g in visible) { + final label = _nightLabel(DateTime.parse(g['tipoff_utc']).toLocal()); + if (label != night) { + items.add(label); + night = label; + } + items.add(g); + } + return ListView.builder( + itemCount: items.length, + itemBuilder: (ctx, i) { + final item = items[i]; + if (item is String) { + return Padding( + padding: EdgeInsets.only(top: i == 0 ? 0 : 12, bottom: 8), + child: Text( + item, + style: GoogleFonts.fraunces( + color: AppColors.onSurface54, + fontSize: 13, + ), + ), + ); + } + return _gameCard(item as Map); + }, + ); + } + + @override + Widget build(BuildContext context) { + final rule = _rule; + return Padding( + padding: const EdgeInsets.all(16), + child: Column( + children: [ + Row( + children: [ + Expanded( + child: Text( + 'Matchs NBA en direct sur beIN Sports FR', + style: GoogleFonts.fraunces( + color: AppColors.onSurface54, + fontSize: 13, + ), + ), + ), + if (rule != null) ...[ + Tooltip( + message: rule['enabled'] == true + ? 'Auto NBA actif' + : 'Auto NBA en pause', + child: Switch( + value: rule['enabled'] == true, + activeThumbColor: AppColors.primaryContainer, + onChanged: (v) => _updateRule({'enabled': v}), + ), + ), + IconButton( + icon: Icon( + _showOffBein ? Icons.visibility : Icons.visibility_off, + color: AppColors.onSurfaceVariant, + ), + tooltip: _showOffBein + ? 'Masquer les matchs hors beIN' + : 'Afficher les matchs hors beIN', + onPressed: () => setState(() => _showOffBein = !_showOffBein), + ), + IconButton( + icon: const Icon( + Icons.refresh, + color: AppColors.onSurfaceVariant, + ), + tooltip: 'Rafraîchir maintenant', + onPressed: _busy ? null : _refreshNow, + ), + IconButton( + icon: const Icon( + Icons.tune, + color: AppColors.onSurfaceVariant, + ), + tooltip: 'Réglages', + onPressed: _showSettings, + ), + ], + ], + ), + const SizedBox(height: 12), + Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: AppColors.secondary.withOpacity(0.08), + borderRadius: BorderRadius.circular(10), + border: Border.all(color: AppColors.secondary.withOpacity(0.2)), + ), + child: Row( + children: [ + const Icon( + Icons.info_outline, + color: AppColors.secondary, + size: 18, + ), + const SizedBox(width: 12), + Expanded( + child: Text( + 'Seule la diffusion qui démarre avec le match est ' + 'enregistrée : les rediffusions sont ignorées. La chaîne ' + 'est confirmée par le guide TV environ 48 h avant ; vous ' + 'pouvez refuser un match à tout moment avant son début.', + style: GoogleFonts.fraunces( + color: AppColors.secondaryFixed, + fontSize: 12, + ), + ), + ), + ], + ), + ), + const SizedBox(height: 16), + Expanded(child: _body()), + ], + ), + ); + } +} diff --git a/server/Cargo.lock b/server/Cargo.lock index 01c2d21..e027dab 100644 --- a/server/Cargo.lock +++ b/server/Cargo.lock @@ -2,6 +2,22 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "ab_glyph" +version = "0.2.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "01c0457472c38ea5bd1c3b5ada5e368271cb550be7a4ca4a0b4634e9913f6cc2" +dependencies = [ + "ab_glyph_rasterizer", + "owned_ttf_parser", +] + +[[package]] +name = "ab_glyph_rasterizer" +version = "0.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "366ffbaa4442f4684d91e2cd7c5ea7c4ed8add41959a31447066e279e432b618" + [[package]] name = "adler2" version = "2.0.1" @@ -168,12 +184,24 @@ version = "3.20.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" +[[package]] +name = "bytemuck" +version = "1.25.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95832e849adfb21180ccb6826a99da14e5d266ae5c2e668e1602cf234f153797" + [[package]] name = "byteorder" version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" +[[package]] +name = "byteorder-lite" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495" + [[package]] name = "bytes" version = "1.12.1" @@ -337,6 +365,15 @@ version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a" +[[package]] +name = "fdeflate" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e6853b52649d4ac5c0bd02320cddc5ba956bdb407c4b75a2c6b75bf51500f8c" +dependencies = [ + "simd-adler32", +] + [[package]] name = "find-msvc-tools" version = "0.1.14" @@ -350,7 +387,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6e634e2e0ebac1ee034020da1ca582e17ffe4e0f5e985823721e168928136dcb" dependencies = [ "crc32fast", - "miniz_oxide", + "miniz_oxide 0.9.1", "zlib-rs", ] @@ -763,6 +800,21 @@ dependencies = [ "icu_properties", ] +[[package]] +name = "image" +version = "0.25.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85ab80394333c02fe689eaf900ab500fbd0c2213da414687ebf995a65d5a6104" +dependencies = [ + "bytemuck", + "byteorder-lite", + "moxcms", + "num-traits", + "png", + "zune-core", + "zune-jpeg", +] + [[package]] name = "indexmap" version = "2.14.0" @@ -887,6 +939,16 @@ dependencies = [ "unicase", ] +[[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", + "simd-adler32", +] + [[package]] name = "miniz_oxide" version = "0.9.1" @@ -908,6 +970,16 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "moxcms" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb85c154ba489f01b25c0d36ae69a87e4a1c73a72631fc6c0eb6dde34a73e44b" +dependencies = [ + "num-traits", + "pxfm", +] + [[package]] name = "num-traits" version = "0.2.19" @@ -923,6 +995,15 @@ version = "1.21.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" +[[package]] +name = "owned_ttf_parser" +version = "0.25.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "36820e9051aca1014ddc75770aab4d68bc1e9e632f0f5627c4086bc216fb583b" +dependencies = [ + "ttf-parser", +] + [[package]] name = "parking_lot" version = "0.12.5" @@ -964,6 +1045,19 @@ version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548" +[[package]] +name = "png" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61" +dependencies = [ + "bitflags", + "crc32fast", + "fdeflate", + "flate2", + "miniz_oxide 0.8.9", +] + [[package]] name = "potential_utf" version = "0.1.6" @@ -982,6 +1076,12 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "pxfm" +version = "0.1.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d55d956fa96f5ec02be2e13af0e20391a5aa83d6a074e3ad368959d0fab299ea" + [[package]] name = "quick-xml" version = "0.37.5" @@ -1613,6 +1713,12 @@ version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" +[[package]] +name = "ttf-parser" +version = "0.25.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2df906b07856748fa3f6e0ad0cbaa047052d4a7dd609e231c4f72cee8c36f31" + [[package]] name = "typenum" version = "1.20.1" @@ -1942,6 +2048,7 @@ checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc" name = "xtremflow-server" version = "0.1.0" dependencies = [ + "ab_glyph", "async-stream", "axum", "base64 0.22.1", @@ -1950,6 +2057,7 @@ dependencies = [ "chrono", "flate2", "futures", + "image", "libc", "md-5", "mime_guess", @@ -2061,3 +2169,18 @@ name = "zmij" version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" + +[[package]] +name = "zune-core" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d56377fd46368984a170bc5aac5567e52ca5da874caa60bea39fcbca78fb658b" + +[[package]] +name = "zune-jpeg" +version = "0.5.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27bc9d5b815bc103f142aa054f561d9187d191692ec7c2d1e2b4737f8dbd7296" +dependencies = [ + "zune-core", +] diff --git a/server/Cargo.toml b/server/Cargo.toml index 3728851..f90ea37 100644 --- a/server/Cargo.toml +++ b/server/Cargo.toml @@ -6,6 +6,7 @@ description = "Serveur XtremFlow : API, proxy Xtream, streaming FFmpeg, enregist publish = false [dependencies] +ab_glyph = "0.2.32" async-stream = "0.3.6" axum = "0.8.9" base64 = "0.22" @@ -14,6 +15,7 @@ bytes = "1.12.1" chrono = "0.4.45" flate2 = "1.1.10" futures = "0.3.34" +image = { version = "0.25.10", default-features = false, features = ["png", "jpeg"] } libc = "0.2.190" md-5 = "0.10" mime_guess = "2.0.5" diff --git a/server/src/api/auto_rules.rs b/server/src/api/auto_rules.rs new file mode 100644 index 0000000..7436d5e --- /dev/null +++ b/server/src/api/auto_rules.rs @@ -0,0 +1,349 @@ +//! `/api/auto-rules` : enregistrement automatique des matchs NBA en direct +//! sur beIN Sports FR. +//! +//! - `GET /api/auto-rules` règles de l'utilisateur +//! - `POST /api/auto-rules` crée la règle NBA +//! - `PATCH /api/auto-rules/{id}` réglages (PUT accepté) +//! - `DELETE /api/auto-rules/{id}` supprime la règle +//! - `GET /api/auto-rules/{id}/games?days=21` matchs à venir +//! - `POST /api/auto-rules/{id}/games/{game}/decision` oui / non / auto +//! - `GET /api/auto-rules/{id}/games/{game}/artwork` vignette (`?kind=poster`) +//! - `GET /api/auto-rules/{id}/channels` beIN détectées +//! - `POST /api/auto-rules/{id}/refresh` recharge maintenant + +use crate::http::{self, Req, Resp, json, segments}; +use crate::models::{AutoRule, NbaGame, User}; +use crate::services::recording_scheduler::recordings_dir_path; +use crate::services::{bein_channels, nba_artwork}; +use crate::state::SharedState; +use chrono::{Duration, Utc}; +use serde_json::{Map, Value, json}; +use std::path::PathBuf; + +fn can_access(user: Option<&User>, rule: &AutoRule) -> bool { + user.is_some_and(|u| u.is_admin || rule.user_id == u.id) +} + +pub async fn auto_rules(state: &SharedState, req: &Req, rest: &str) -> Resp { + let Some(user) = req.user.clone() else { + return json(401, &json!({"error": "Authentification requise"})); + }; + let parts = segments(rest); + if let ("GET" | "HEAD", []) = (req.method.as_str(), parts.as_slice()) { + let rules = state.db.get_auto_rules_for_user(&user.id); + return json(200, &json!(rules)); + } + if let ("POST", []) = (req.method.as_str(), parts.as_slice()) { + return create(state, req, &user).await; + } + let Some(id) = parts.first() else { + return http::not_found(); + }; + let Some(rule) = state.db.get_auto_rule(id) else { + return json(404, &json!({"error": "Règle non trouvée"})); + }; + if !can_access(Some(&user), &rule) { + return json(403, &json!({"error": "Accès refusé"})); + } + match (req.method.as_str(), &parts[1..]) { + ("PATCH" | "PUT", []) => update(state, req, rule).await, + ("DELETE", []) => { + state.db.delete_auto_rule(&rule.id); + json(200, &json!({"message": "Règle supprimée"})) + } + ("GET" | "HEAD", ["games"]) => games(state, req, &rule), + ("POST", ["games", game_id, "decision"]) => decision(state, req, &rule, game_id).await, + ("GET" | "HEAD", ["games", game_id, "artwork"]) => { + artwork(state, req, &rule, game_id).await + } + ("GET" | "HEAD", ["channels"]) => channels(state, req, &rule).await, + ("POST", ["refresh"]) => { + state.nba.refresh_rule(&rule).await; + state.nba.reconcile(&rule).await; + games(state, req, &rule) + } + _ => http::not_found(), + } +} + +fn body_map(req: &Req) -> Result, Resp> { + if req.body.is_empty() { + return Ok(Map::new()); + } + match serde_json::from_slice::(&req.body) { + Ok(Value::Object(map)) => Ok(map), + _ => Err(json(400, &json!({"error": "Corps JSON invalide"}))), + } +} + +fn string_list(v: &Value) -> Option> { + v.as_array().map(|a| { + a.iter() + .filter_map(|x| match x { + Value::String(s) => Some(s.trim().to_string()), + Value::Number(n) => Some(n.to_string()), + _ => None, + }) + .filter(|s| !s.is_empty()) + .collect() + }) +} + +/// Applique les champs présents du corps à la règle. Erreur 400 si invalide. +fn apply_fields(rule: &mut AutoRule, body: &Map) -> Result<(), Resp> { + let bad = |msg: &str| Err(json(400, &json!({"error": msg}))); + if let Some(v) = body.get("enabled") { + let Some(b) = v.as_bool() else { + return bad("enabled doit être un booléen"); + }; + rule.enabled = b; + } + if let Some(v) = body.get("default_decision") { + match v.as_str() { + Some(d @ ("auto" | "ask")) => rule.default_decision = d.into(), + _ => return bad("default_decision : 'auto' ou 'ask'"), + } + } + if let Some(v) = body.get("channel_ids") { + let Some(list) = string_list(v) else { + return bad("channel_ids doit être une liste"); + }; + rule.channel_ids = list; + } + if let Some(v) = body.get("team_filter") { + let Some(list) = string_list(v) else { + return bad("team_filter doit être une liste"); + }; + rule.team_filter = list; + } + for (key, slot) in [ + ("pad_before_min", &mut rule.pad_before_min), + ("pad_after_min", &mut rule.pad_after_min), + ] { + if let Some(v) = body.get(key) { + match v.as_i64() { + Some(n) if (0..=180).contains(&n) => *slot = n, + _ => return bad("marges : entier entre 0 et 180 minutes"), + } + } + } + if let Some(v) = body.get("keep_days") { + rule.keep_days = match v { + Value::Null => None, + other => match other.as_i64() { + Some(n) if n > 0 => Some(n), + _ => return bad("keep_days : entier positif ou null"), + }, + }; + } + Ok(()) +} + +async fn create(state: &SharedState, req: &Req, user: &User) -> Resp { + let body = match body_map(req) { + Ok(b) => b, + Err(r) => return r, + }; + if state + .db + .get_auto_rules_for_user(&user.id) + .iter() + .any(|r| r.kind == "nba") + { + return json(409, &json!({"error": "La règle NBA existe déjà"})); + } + let mut rule = AutoRule { + id: String::new(), + user_id: user.id.clone(), + kind: "nba".into(), + enabled: true, + default_decision: "auto".into(), + channel_ids: Vec::new(), + pad_before_min: 5, + pad_after_min: 45, + team_filter: Vec::new(), + keep_days: None, + created_at: String::new(), + updated_at: String::new(), + }; + if let Err(r) = apply_fields(&mut rule, &body) { + return r; + } + // Sans liste explicite : les beIN Sports FR détectées, meilleure qualité. + if !body.contains_key("channel_ids") + && let Some(playlist) = state.get_playlist(req) + { + let table = bein_channels::fetch_live_table(&state.http, &playlist).await; + rule.channel_ids = bein_channels::preferred_fr(&bein_channels::detect(&table)) + .into_iter() + .map(|c| c.stream_id) + .collect(); + } + let rule = match state.db.create_auto_rule(&rule) { + Ok(r) => r, + Err(e) => return json(500, &json!({"error": format!("Création impossible : {e}")})), + }; + // Calendrier et première réconciliation en tâche de fond (≈ 20 requêtes). + let nba = state.nba.clone(); + let spawned = rule.clone(); + tokio::spawn(async move { + nba.refresh_rule(&spawned).await; + nba.reconcile(&spawned).await; + }); + json(201, &json!(rule)) +} + +async fn update(state: &SharedState, req: &Req, mut rule: AutoRule) -> Resp { + let body = match body_map(req) { + Ok(b) => b, + Err(r) => return r, + }; + let was_enabled = rule.enabled; + if let Err(r) = apply_fields(&mut rule, &body) { + return r; + } + if let Err(e) = state.db.update_auto_rule(&rule) { + return json( + 500, + &json!({"error": format!("Mise à jour impossible : {e}")}), + ); + } + let nba = state.nba.clone(); + let spawned = rule.clone(); + tokio::spawn(async move { + if spawned.enabled && !was_enabled { + nba.refresh_rule(&spawned).await; + } + nba.reconcile(&spawned).await; + }); + json(200, &json!(rule)) +} + +fn game_json(rule: &AutoRule, g: &NbaGame) -> Value { + let rule_id = &rule.id; + let mut v = json!(g); + if let Value::Object(map) = &mut v { + map.insert( + "artwork_url".into(), + json!(format!( + "/api/auto-rules/{rule_id}/games/{}/artwork", + g.game_id + )), + ); + // Oui / non effectif (décision, mode de la règle, filtre d'équipes). + map.insert( + "wanted".into(), + json!(crate::services::nba_autorec::wants(rule, g)), + ); + map.insert( + "title".into(), + json!(crate::services::nba_autorec::recording_title(g)), + ); + } + v +} + +fn games(state: &SharedState, req: &Req, rule: &AutoRule) -> Resp { + let days = req + .query_param("days") + .and_then(|d| d.parse::().ok()) + .unwrap_or(21) + .clamp(1, 60); + let now = Utc::now(); + let list: Vec = state + .db + .get_nba_games( + &rule.id, + now - Duration::hours(6), + now + Duration::days(days), + ) + .iter() + .map(|g| game_json(rule, g)) + .collect(); + json(200, &json!({"rule": rule, "games": list})) +} + +async fn decision(state: &SharedState, req: &Req, rule: &AutoRule, game_id: &str) -> Resp { + let body = match body_map(req) { + Ok(b) => b, + Err(r) => return r, + }; + let decision = match body.get("decision").and_then(Value::as_str) { + Some(d @ ("accepted" | "refused" | "auto")) => d, + _ => { + return json( + 400, + &json!({"error": "decision : 'accepted', 'refused' ou 'auto'"}), + ); + } + }; + if !state.db.set_nba_decision(&rule.id, game_id, decision) { + return json(404, &json!({"error": "Match non trouvé"})); + } + // Appliquer tout de suite (programmer ou supprimer l'enregistrement), + // dans la limite de 20 s : l'EPG est normalement en cache. + let _ = tokio::time::timeout( + std::time::Duration::from_secs(20), + state.nba.reconcile(rule), + ) + .await; + match state.db.get_nba_game(&rule.id, game_id) { + Some(g) => json(200, &game_json(rule, &g)), + None => json(404, &json!({"error": "Match non trouvé"})), + } +} + +async fn artwork(state: &SharedState, req: &Req, rule: &AutoRule, game_id: &str) -> Resp { + let Some(game) = state.db.get_nba_game(&rule.id, game_id) else { + return json(404, &json!({"error": "Match non trouvé"})); + }; + let poster = req.query_param("kind").as_deref() == Some("poster"); + let dir = PathBuf::from(recordings_dir_path()); + let safe_id: String = game_id + .chars() + .filter(|c| c.is_ascii_alphanumeric()) + .collect(); + let cached = nba_artwork::artwork_dir(&dir).join(format!( + "{safe_id}-{}.jpg", + if poster { "poster" } else { "thumb" } + )); + let path = if cached.exists() { + Some(cached) + } else { + nba_artwork::ensure_artwork(&state.http, &dir, &game) + .await + .map(|(t, p)| if poster { p } else { t }) + }; + match path.map(std::fs::read) { + Some(Ok(bytes)) => http::with_headers( + http::response(200, bytes), + &[ + ("content-type", "image/jpeg"), + ("cache-control", "private, max-age=3600"), + ], + ), + _ => json(404, &json!({"error": "Vignette indisponible"})), + } +} + +async fn channels(state: &SharedState, req: &Req, rule: &AutoRule) -> Resp { + let Some(playlist) = state.get_playlist(req) else { + return json(403, &json!({"error": "Playlist non trouvée"})); + }; + let table = bein_channels::fetch_live_table(&state.http, &playlist).await; + let detected = bein_channels::detect(&table); + let list: Vec = detected + .iter() + .map(|c| { + let mut v = json!(c); + if let Value::Object(m) = &mut v { + m.insert( + "selected".into(), + json!(rule.channel_ids.contains(&c.stream_id)), + ); + } + v + }) + .collect(); + json(200, &json!({"channels": list})) +} diff --git a/server/src/api/epg.rs b/server/src/api/epg.rs index 467d2c2..def06cb 100644 --- a/server/src/api/epg.rs +++ b/server/src/api/epg.rs @@ -236,11 +236,58 @@ impl EpgApi { ); } + let (programmes, source, found) = self.programmes_with_source(&playlist, channel_id).await; + + let body = json!({"channel_id": channel_id, "programmes": programmes}).to_string(); + // 30 min pour un guide trouvé ; 5 min pour un guide vide, qui vient + // souvent d'une source momentanément indisponible. + let ttl = Duration::from_secs(if found { 30 * 60 } else { 5 * 60 }); + self.inner + .cache + .lock() + .unwrap() + .insert(cache_key, (body.clone(), Instant::now() + ttl)); + + http::with_headers( + http::response(200, body), + &[ + ("content-type", "application/json"), + ("x-cache", "MISS"), + ("x-epg-source", source), + ], + ) + } + + /// Programmes en cours et à venir d'une chaîne, toutes sources + /// confondues (même cascade que `GET /api/epg`, sans le cache HTTP). + pub async fn programmes(&self, playlist: &PlaylistConfig, channel_id: &str) -> Vec { + self.programmes_with_source(playlist, channel_id).await.0 + } + + /// Nom de la chaîne dans la table du panneau (cache 6 h). + pub async fn channel_name( + &self, + playlist: &PlaylistConfig, + channel_id: &str, + ) -> Option { + self.channel_map_for(playlist) + .await + .names + .get(channel_id) + .cloned() + } + + async fn programmes_with_source( + &self, + playlist: &PlaylistConfig, + channel_id: &str, + ) -> (Vec, &'static str, bool) { + let playlist = playlist.clone(); // Sources par ordre de préférence. On s'arrête à la première qui // contient un programme en cours ou à venir ; à défaut, la première // non vide sert de repli. let mut programmes: Vec = Vec::new(); - let mut source = "xtream"; + let mut source: &'static str = "xtream"; let mut found = false; for name in ["panel-xmltv", "xmltv", "xtream"] { let candidate = match name { @@ -264,25 +311,7 @@ impl EpgApi { break; } } - - let body = json!({"channel_id": channel_id, "programmes": programmes}).to_string(); - // 30 min pour un guide trouvé ; 5 min pour un guide vide, qui vient - // souvent d'une source momentanément indisponible. - let ttl = Duration::from_secs(if found { 30 * 60 } else { 5 * 60 }); - self.inner - .cache - .lock() - .unwrap() - .insert(cache_key, (body.clone(), Instant::now() + ttl)); - - http::with_headers( - http::response(200, body), - &[ - ("content-type", "application/json"), - ("x-cache", "MISS"), - ("x-epg-source", source), - ], - ) + (programmes, source, found) } async fn panel_programmes(&self, playlist: &PlaylistConfig, channel_id: &str) -> Vec { diff --git a/server/src/api/mod.rs b/server/src/api/mod.rs index bcb439e..0495850 100644 --- a/server/src/api/mod.rs +++ b/server/src/api/mod.rs @@ -1,5 +1,6 @@ pub mod account; pub mod admin; +pub mod auto_rules; pub mod epg; pub mod logo; pub mod player_log; diff --git a/server/src/db.rs b/server/src/db.rs index 004cbc5..d0c6ca0 100644 --- a/server/src/db.rs +++ b/server/src/db.rs @@ -5,7 +5,7 @@ //! démarrage du serveur Rust. use crate::dart_time::{DartDateTime, now_local_iso, utc_iso}; -use crate::models::{Playlist, Recording, Session, User}; +use crate::models::{AutoRule, NbaGame, Playlist, Recording, ScheduledGame, Session, User}; use crate::util::password; use chrono::{DateTime, Duration, Utc}; use regex::Regex; @@ -66,11 +66,65 @@ fn recording_from_row(row: &Row) -> DbResult { .unwrap_or_else(|| "scheduled".into()), file_path: row.get("file_path")?, error_reason: row.get("error_reason")?, + source_rule_id: row.get("source_rule_id")?, created_at: parse_date(row.get("created_at")?), updated_at: parse_date(row.get("updated_at")?), }) } +fn json_list(raw: Option) -> Vec { + raw.and_then(|r| serde_json::from_str(&r).ok()) + .unwrap_or_default() +} + +fn auto_rule_from_row(row: &Row) -> DbResult { + Ok(AutoRule { + id: row.get("id")?, + user_id: row.get("user_id")?, + kind: row.get("kind")?, + enabled: row.get::<_, i64>("enabled")? == 1, + default_decision: row.get("default_decision")?, + channel_ids: json_list(row.get("channel_ids")?), + pad_before_min: row.get("pad_before_min")?, + pad_after_min: row.get("pad_after_min")?, + team_filter: json_list(row.get("team_filter")?), + keep_days: row.get("keep_days")?, + created_at: row + .get::<_, Option>("created_at")? + .unwrap_or_default(), + updated_at: row + .get::<_, Option>("updated_at")? + .unwrap_or_default(), + }) +} + +fn nba_game_from_row(row: &Row) -> DbResult { + Ok(NbaGame { + game_id: row.get("game_id")?, + rule_id: row.get("rule_id")?, + home: row.get("home")?, + away: row.get("away")?, + home_id: row.get("home_id")?, + away_id: row.get("away_id")?, + home_tri: row.get("home_tri")?, + away_tri: row.get("away_tri")?, + tipoff_utc: row.get("tipoff_utc")?, + status: row.get("status")?, + decision: row.get("decision")?, + channel_id: row.get("channel_id")?, + channel_name: row.get("channel_name")?, + epg_title: row.get("epg_title")?, + epg_start: row.get("epg_start")?, + epg_end: row.get("epg_end")?, + recording_id: row.get("recording_id")?, + confidence: row.get("confidence")?, + conflict: row.get::<_, i64>("conflict")? == 1, + updated_at: row + .get::<_, Option>("updated_at")? + .unwrap_or_default(), + }) +} + /// Ligne brute → objet JSON (colonnes dans l'ordre de la table). fn row_to_json(row: &Row) -> DbResult> { let mut map = Map::new(); @@ -199,6 +253,45 @@ impl AppDatabase { created_at TEXT DEFAULT CURRENT_TIMESTAMP, FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE ); + CREATE TABLE IF NOT EXISTS auto_rules ( + id TEXT PRIMARY KEY, + user_id TEXT NOT NULL, + kind TEXT NOT NULL DEFAULT 'nba', + enabled INTEGER NOT NULL DEFAULT 1, + default_decision TEXT NOT NULL DEFAULT 'auto', + channel_ids TEXT NOT NULL DEFAULT '[]', + pad_before_min INTEGER NOT NULL DEFAULT 5, + pad_after_min INTEGER NOT NULL DEFAULT 45, + team_filter TEXT NOT NULL DEFAULT '[]', + keep_days INTEGER, + created_at TEXT, + updated_at TEXT, + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE + ); + CREATE TABLE IF NOT EXISTS nba_games ( + game_id TEXT NOT NULL, + rule_id TEXT NOT NULL, + home TEXT NOT NULL DEFAULT '', + away TEXT NOT NULL DEFAULT '', + home_id TEXT NOT NULL DEFAULT '', + away_id TEXT NOT NULL DEFAULT '', + home_tri TEXT NOT NULL DEFAULT '', + away_tri TEXT NOT NULL DEFAULT '', + tipoff_utc TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'provisional', + decision TEXT NOT NULL DEFAULT 'auto', + channel_id TEXT, + channel_name TEXT, + epg_title TEXT, + epg_start TEXT, + epg_end TEXT, + recording_id TEXT, + confidence INTEGER, + conflict INTEGER NOT NULL DEFAULT 0, + updated_at TEXT, + PRIMARY KEY (game_id, rule_id), + FOREIGN KEY (rule_id) REFERENCES auto_rules(id) ON DELETE CASCADE + ); CREATE INDEX IF NOT EXISTS idx_sessions_token ON sessions(token); CREATE INDEX IF NOT EXISTS idx_sessions_expires ON sessions(expires_at); CREATE INDEX IF NOT EXISTS idx_playlists_user ON playlists(user_id); @@ -206,6 +299,8 @@ impl AppDatabase { CREATE INDEX IF NOT EXISTS idx_recordings_user ON tv_recordings(user_id); CREATE INDEX IF NOT EXISTS idx_recordings_start ON tv_recordings(start_time); CREATE INDEX IF NOT EXISTS idx_season_passes_user ON season_passes(user_id); + CREATE INDEX IF NOT EXISTS idx_auto_rules_user ON auto_rules(user_id); + CREATE INDEX IF NOT EXISTS idx_nba_games_tipoff ON nba_games(rule_id, tipoff_utc); "#, ) } @@ -227,7 +322,7 @@ impl AppDatabase { |r| r.get(0), )?; - let migrations: [(i64, &str, &str, &str); 2] = [ + let migrations: [(i64, &str, &str, &str); 3] = [ // v1 : colonne error_reason absente des bases d'avant son introduction. (1, "tv_recordings", "error_reason", "TEXT"), // v2 : mode de correspondance des season passes. 'contains' pour les @@ -239,6 +334,8 @@ impl AppDatabase { "match_mode", "TEXT NOT NULL DEFAULT 'contains'", ), + // v3 : lien vers la règle automatique (badge « Auto NBA », sidecars). + (3, "tv_recordings", "source_rule_id", "TEXT"), ]; for (version, table, column, definition) in migrations { if version <= current { @@ -623,6 +720,7 @@ impl AppDatabase { status: "scheduled".into(), file_path: None, error_reason: None, + source_rule_id: None, created_at: now, updated_at: now, }) @@ -800,6 +898,205 @@ impl AppDatabase { .into_iter() .next() } + /// Marque un enregistrement comme créé par une règle automatique. + pub fn set_recording_source(&self, id: &str, rule_id: &str) { + let _ = self.lock().execute( + "UPDATE tv_recordings SET source_rule_id = ? WHERE id = ?", + params![rule_id, id], + ); + } + + // ==================== Règles automatiques ==================== + + pub fn create_auto_rule(&self, rule: &AutoRule) -> DbResult { + let mut rule = rule.clone(); + rule.id = uuid::Uuid::new_v4().to_string(); + let now = utc_iso(Utc::now()); + rule.created_at = now.clone(); + rule.updated_at = now; + self.lock().execute( + "INSERT INTO auto_rules (id, user_id, kind, enabled, default_decision, channel_ids, pad_before_min, pad_after_min, team_filter, keep_days, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", + params![ + rule.id, + rule.user_id, + rule.kind, + rule.enabled as i64, + rule.default_decision, + serde_json::to_string(&rule.channel_ids).unwrap_or_else(|_| "[]".into()), + rule.pad_before_min, + rule.pad_after_min, + serde_json::to_string(&rule.team_filter).unwrap_or_else(|_| "[]".into()), + rule.keep_days, + rule.created_at, + rule.updated_at + ], + )?; + Ok(rule) + } + + /// Réécrit les réglages modifiables d'une règle (id et propriétaire fixes). + pub fn update_auto_rule(&self, rule: &AutoRule) -> DbResult<()> { + self.lock().execute( + "UPDATE auto_rules SET enabled = ?, default_decision = ?, channel_ids = ?, pad_before_min = ?, pad_after_min = ?, team_filter = ?, keep_days = ?, updated_at = ? WHERE id = ?", + params![ + rule.enabled as i64, + rule.default_decision, + serde_json::to_string(&rule.channel_ids).unwrap_or_else(|_| "[]".into()), + rule.pad_before_min, + rule.pad_after_min, + serde_json::to_string(&rule.team_filter).unwrap_or_else(|_| "[]".into()), + rule.keep_days, + utc_iso(Utc::now()), + rule.id + ], + )?; + Ok(()) + } + + fn query_auto_rules(&self, sql: &str, args: &[&str]) -> Vec { + let conn = self.lock(); + let Ok(mut stmt) = conn.prepare(sql) else { + return Vec::new(); + }; + stmt.query_map(rusqlite::params_from_iter(args.iter()), auto_rule_from_row) + .map(|rows| rows.filter_map(Result::ok).collect()) + .unwrap_or_default() + } + + /// Règles actives (pour le scheduler). + pub fn get_enabled_auto_rules(&self) -> Vec { + self.query_auto_rules( + "SELECT * FROM auto_rules WHERE enabled = 1 ORDER BY created_at ASC", + &[], + ) + } + + pub fn get_auto_rules_for_user(&self, user_id: &str) -> Vec { + self.query_auto_rules( + "SELECT * FROM auto_rules WHERE user_id = ? ORDER BY created_at ASC", + &[user_id], + ) + } + + pub fn get_auto_rule(&self, id: &str) -> Option { + self.query_auto_rules("SELECT * FROM auto_rules WHERE id = ?", &[id]) + .into_iter() + .next() + } + + /// Supprime la règle et ses matchs suivis (cascade). Les enregistrements + /// déjà créés restent : ils appartiennent à l'utilisateur. + pub fn delete_auto_rule(&self, id: &str) { + let _ = self + .lock() + .execute("DELETE FROM auto_rules WHERE id = ?", [id]); + } + + // ==================== Matchs NBA suivis ==================== + + /// Ajoute un match du calendrier, ou met à jour équipes et coup d'envoi + /// d'un match déjà connu sans toucher à la décision de l'utilisateur ni + /// à l'état de programmation. + pub fn upsert_scheduled_game(&self, rule_id: &str, g: &ScheduledGame) -> DbResult<()> { + self.lock().execute( + "INSERT INTO nba_games (game_id, rule_id, home, away, home_id, away_id, home_tri, away_tri, tipoff_utc, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(game_id, rule_id) DO UPDATE SET + home = excluded.home, away = excluded.away, + home_id = excluded.home_id, away_id = excluded.away_id, + home_tri = excluded.home_tri, away_tri = excluded.away_tri, + tipoff_utc = excluded.tipoff_utc, updated_at = excluded.updated_at", + params![ + g.game_id, + rule_id, + g.home, + g.away, + g.home_id, + g.away_id, + g.home_tri, + g.away_tri, + utc_iso(g.tipoff_utc), + utc_iso(Utc::now()) + ], + )?; + Ok(()) + } + + fn query_nba_games(&self, sql: &str, args: &[&str]) -> Vec { + let conn = self.lock(); + let Ok(mut stmt) = conn.prepare(sql) else { + return Vec::new(); + }; + stmt.query_map(rusqlite::params_from_iter(args.iter()), nba_game_from_row) + .map(|rows| rows.filter_map(Result::ok).collect()) + .unwrap_or_default() + } + + /// Matchs d'une règle dont le coup d'envoi tombe dans [from, to]. + pub fn get_nba_games( + &self, + rule_id: &str, + from: DateTime, + to: DateTime, + ) -> Vec { + self.query_nba_games( + "SELECT * FROM nba_games WHERE rule_id = ? AND tipoff_utc BETWEEN ? AND ? ORDER BY tipoff_utc ASC", + &[rule_id, &utc_iso(from), &utc_iso(to)], + ) + } + + pub fn get_nba_game(&self, rule_id: &str, game_id: &str) -> Option { + self.query_nba_games( + "SELECT * FROM nba_games WHERE rule_id = ? AND game_id = ?", + &[rule_id, game_id], + ) + .into_iter() + .next() + } + + pub fn get_nba_game_by_recording(&self, recording_id: &str) -> Option { + self.query_nba_games( + "SELECT * FROM nba_games WHERE recording_id = ? LIMIT 1", + &[recording_id], + ) + .into_iter() + .next() + } + + /// Réécrit l'état de réconciliation d'un match (statut, chaîne, EPG, + /// enregistrement lié). La décision a sa propre fonction. + pub fn update_nba_game_state(&self, g: &NbaGame) -> DbResult<()> { + self.lock().execute( + "UPDATE nba_games SET status = ?, channel_id = ?, channel_name = ?, epg_title = ?, epg_start = ?, epg_end = ?, recording_id = ?, confidence = ?, conflict = ?, updated_at = ? WHERE rule_id = ? AND game_id = ?", + params![ + g.status, + g.channel_id, + g.channel_name, + g.epg_title, + g.epg_start, + g.epg_end, + g.recording_id, + g.confidence, + g.conflict as i64, + utc_iso(Utc::now()), + g.rule_id, + g.game_id + ], + )?; + Ok(()) + } + + /// Oui / non de l'utilisateur sur un match : 'accepted', 'refused' ou + /// 'auto' (revenir au comportement par défaut de la règle). + pub fn set_nba_decision(&self, rule_id: &str, game_id: &str, decision: &str) -> bool { + self.lock() + .execute( + "UPDATE nba_games SET decision = ?, updated_at = ? WHERE rule_id = ? AND game_id = ?", + params![decision, utc_iso(Utc::now()), rule_id, game_id], + ) + .map(|n| n > 0) + .unwrap_or(false) + } } #[cfg(test)] @@ -904,6 +1201,118 @@ mod tests { assert!(db.is_nvidia_gpu_enabled()); } + fn nba_rule(db: &AppDatabase, user_id: &str) -> AutoRule { + db.create_auto_rule(&AutoRule { + id: String::new(), + user_id: user_id.into(), + kind: "nba".into(), + enabled: true, + default_decision: "auto".into(), + channel_ids: vec!["101".into(), "102".into()], + pad_before_min: 5, + pad_after_min: 45, + team_filter: vec![], + keep_days: None, + created_at: String::new(), + updated_at: String::new(), + }) + .unwrap() + } + + fn game(id: &str, tipoff: &str) -> ScheduledGame { + ScheduledGame { + game_id: id.into(), + home: "Los Angeles Lakers".into(), + away: "Boston Celtics".into(), + home_id: "1610612747".into(), + away_id: "1610612738".into(), + home_tri: "LAL".into(), + away_tri: "BOS".into(), + tipoff_utc: DartDateTime::parse(tipoff).unwrap().to_utc(), + } + } + + #[test] + fn regle_auto_aller_retour() { + let (db, _path) = temp_db(); + let user = db.create_user("u", "x", false).unwrap(); + let mut rule = nba_rule(&db, &user.id); + assert_eq!(db.get_enabled_auto_rules().len(), 1); + rule.enabled = false; + rule.team_filter = vec!["Lakers".into()]; + db.update_auto_rule(&rule).unwrap(); + let back = db.get_auto_rule(&rule.id).unwrap(); + assert!(!back.enabled); + assert_eq!(back.team_filter, vec!["Lakers".to_string()]); + assert_eq!(back.channel_ids, vec!["101".to_string(), "102".to_string()]); + assert!(db.get_enabled_auto_rules().is_empty()); + } + + #[test] + fn upsert_du_calendrier_preserve_la_decision() { + let (db, _path) = temp_db(); + let user = db.create_user("u", "x", false).unwrap(); + let rule = nba_rule(&db, &user.id); + db.upsert_scheduled_game(&rule.id, &game("0022600001", "2026-10-21T23:30:00Z")) + .unwrap(); + assert!(db.set_nba_decision(&rule.id, "0022600001", "refused")); + // Le calendrier décale le match d'une heure : la décision reste. + db.upsert_scheduled_game(&rule.id, &game("0022600001", "2026-10-22T00:30:00Z")) + .unwrap(); + let g = db.get_nba_game(&rule.id, "0022600001").unwrap(); + assert_eq!(g.decision, "refused"); + assert_eq!(g.status, "provisional"); + assert_eq!(g.tipoff_utc, "2026-10-22T00:30:00.000Z"); + let from = DartDateTime::parse("2026-10-21T00:00:00Z") + .unwrap() + .to_utc(); + assert_eq!( + db.get_nba_games(&rule.id, from, from + Duration::days(2)) + .len(), + 1 + ); + // Supprimer la règle supprime ses matchs suivis. + db.delete_auto_rule(&rule.id); + assert!(db.get_nba_game(&rule.id, "0022600001").is_none()); + } + + #[test] + fn etat_de_match_et_lien_enregistrement() { + let (db, _path) = temp_db(); + let user = db.create_user("u", "x", false).unwrap(); + let rule = nba_rule(&db, &user.id); + db.upsert_scheduled_game(&rule.id, &game("g1", "2026-10-21T23:30:00Z")) + .unwrap(); + let now = Utc::now(); + let rec = db + .create_recording( + &user.id, + "101", + "/api/live/101.ts", + "NBA", + now, + now + Duration::hours(3), + ) + .unwrap(); + db.set_recording_source(&rec.id, &rule.id); + let mut g = db.get_nba_game(&rule.id, "g1").unwrap(); + g.status = "scheduled".into(); + g.recording_id = Some(rec.id.clone()); + g.channel_name = Some("beIN SPORTS 1".into()); + g.conflict = true; + db.update_nba_game_state(&g).unwrap(); + let back = db.get_nba_game_by_recording(&rec.id).unwrap(); + assert_eq!(back.status, "scheduled"); + assert!(back.conflict); + assert_eq!( + db.get_recording_by_id(&rec.id) + .unwrap() + .source_rule_id + .as_deref(), + Some(rule.id.as_str()) + ); + } + #[test] fn mot_de_passe_genere() { let p = generate_password(16); diff --git a/server/src/main.rs b/server/src/main.rs index 1eb1423..0b918e2 100644 --- a/server/src/main.rs +++ b/server/src/main.rs @@ -21,6 +21,7 @@ use crate::server::RateLimiter; use crate::services::cleanup::CleanupService; use crate::services::ffmpeg_sessions::FfmpegSessionManager; use crate::services::logo_catalog::LogoCatalog; +use crate::services::nba_autorec::NbaAutoRecorder; use crate::services::recording_scheduler::RecordingScheduler; use crate::services::xmltv::{XmltvConfig, XmltvEpgService}; use crate::state::AppState; @@ -127,6 +128,15 @@ async fn main() { }); let epg = EpgApi::new(xmltv, http.clone(), default_panel_xmltv(http.clone())); + // Enregistrement automatique des matchs NBA en direct sur beIN Sports FR. + let nba = Arc::new(NbaAutoRecorder::new( + db.clone(), + http.clone(), + epg.clone(), + scheduler.clone(), + )); + nba.start(); + // Purge des temporaires. Jamais le dossier temporaire système en // récursif : il contient les sessions HLS. let cleanup = Arc::new(CleanupService::new()); @@ -166,6 +176,7 @@ async fn main() { http: http.clone(), http_manual, scheduler: scheduler.clone(), + nba, streaming, proxy: ProxyHandler::new(), epg, diff --git a/server/src/models.rs b/server/src/models.rs index 44f8e0b..727f788 100644 --- a/server/src/models.rs +++ b/server/src/models.rs @@ -89,6 +89,8 @@ pub struct Recording { pub status: String, pub file_path: Option, pub error_reason: Option, + /// Règle automatique à l'origine de l'enregistrement (None = manuel/season pass). + pub source_rule_id: Option, pub created_at: DartDateTime, pub updated_at: DartDateTime, } @@ -106,6 +108,7 @@ impl Recording { map.insert("status".into(), json!(self.status)); map.insert("file_path".into(), json!(self.file_path)); map.insert("error_reason".into(), json!(self.error_reason)); + map.insert("source_rule_id".into(), json!(self.source_rule_id)); map.insert("created_at".into(), json!(self.created_at.to_iso())); map.insert("updated_at".into(), json!(self.updated_at.to_iso())); map @@ -119,3 +122,67 @@ impl Recording { self.end_time.to_utc() } } + +/// Règle d'enregistrement automatique (aujourd'hui : NBA sur beIN Sports FR). +#[derive(Clone, Debug, serde::Serialize, serde::Deserialize, PartialEq)] +pub struct AutoRule { + pub id: String, + pub user_id: String, + /// Type de règle ; seul 'nba' existe pour l'instant. + pub kind: String, + pub enabled: bool, + /// 'auto' : programmé sans validation (veto possible) ; 'ask' : attend un oui. + pub default_decision: String, + /// stream_ids des chaînes surveillées (beIN Sports FR retenus). + pub channel_ids: Vec, + pub pad_before_min: i64, + pub pad_after_min: i64, + /// Noms d'équipes à suivre ; vide = toutes. + pub team_filter: Vec, + /// Rétention en jours des fichiers créés par la règle ; None = illimitée. + pub keep_days: Option, + pub created_at: String, + pub updated_at: String, +} + +/// Un match NBA connu d'une règle, de l'annonce au calendrier jusqu'au fichier. +#[derive(Clone, Debug, serde::Serialize, PartialEq)] +pub struct NbaGame { + pub game_id: String, + pub rule_id: String, + pub home: String, + pub away: String, + pub home_id: String, + pub away_id: String, + pub home_tri: String, + pub away_tri: String, + /// Coup d'envoi officiel, ISO UTC. + pub tipoff_utc: String, + /// provisional | confirmed | scheduled | recorded | missed | not_on_bein + pub status: String, + /// auto | accepted | refused + pub decision: String, + pub channel_id: Option, + pub channel_name: Option, + pub epg_title: Option, + pub epg_start: Option, + pub epg_end: Option, + pub recording_id: Option, + pub confidence: Option, + /// Pas de créneau d'enregistrement libre (MAX_CONCURRENT_RECORDINGS). + pub conflict: bool, + pub updated_at: String, +} + +/// Match tel que publié par le calendrier officiel (avant toute décision). +#[derive(Clone, Debug, PartialEq)] +pub struct ScheduledGame { + pub game_id: String, + pub home: String, + pub away: String, + pub home_id: String, + pub away_id: String, + pub home_tri: String, + pub away_tri: String, + pub tipoff_utc: chrono::DateTime, +} diff --git a/server/src/server.rs b/server/src/server.rs index b7347f8..f93cd9d 100644 --- a/server/src/server.rs +++ b/server/src/server.rs @@ -1,7 +1,9 @@ //! Point d'entrée HTTP : middlewares puis cascade API → proxy Xtream → //! streaming → fichiers statiques, à l'identique de l'ancien serveur. -use crate::api::{account, admin, logo, player_log, recordings, static_files, xtream_api}; +use crate::api::{ + account, admin, auto_rules, logo, player_log, recordings, static_files, xtream_api, +}; use crate::dart_time::now_local_iso; use crate::http::{self, Req, Resp, client_ip, extract_token, is_miss, mounted, socket_ip}; use crate::state::SharedState; @@ -322,6 +324,10 @@ async fn api_router(state: &SharedState, req: &Req) -> Resp { _ => http::not_found(), }; } + if let Some(rest) = mounted(path, "api/auto-rules") { + let req = authed!(state, req); + return auto_rules::auto_rules(state, &req, rest).await; + } if let Some(rest) = mounted(path, "api/season-passes") { let req = authed!(state, req); return recordings::season_passes(state, &req, rest); diff --git a/server/src/services/bein_channels.rs b/server/src/services/bein_channels.rs new file mode 100644 index 0000000..90ee77a --- /dev/null +++ b/server/src/services/bein_channels.rs @@ -0,0 +1,343 @@ +//! Repérage des chaînes beIN Sports françaises dans la liste du panneau. +//! +//! POURQUOI : un panneau Xtream propose souvent des dizaines de « beIN » : +//! France, MENA, Turquie, Espagne, USA, Australie…, chacune en plusieurs +//! qualités (FHD, HD, backup). L'enregistrement automatique ne doit viser que +//! les chaînes françaises, une seule fois par chaîne logique (beIN SPORTS 1, +//! 2, 3, MAX 4…), dans la meilleure qualité disponible. + +use crate::models::PlaylistConfig; +use crate::services::nba_teams::normalize; +use regex::Regex; +use serde::Serialize; +use serde_json::Value; +use std::collections::HashMap; +use std::sync::LazyLock; + +/// Une chaîne beIN trouvée dans la liste du panneau. +#[derive(Clone, Debug, PartialEq, Serialize)] +pub struct BeinChannel { + pub stream_id: String, + /// Nom brut du panneau (« FR - BEIN SPORTS 1 FHD »). + pub name: String, + pub category: String, + /// Chaîne logique, identique pour toutes les qualités (« bein-sports-1 »). + pub logical: String, + /// Libellé d'affichage (« beIN SPORTS 1 »). + pub label: String, + /// Chaîne française ? + pub is_fr: bool, + /// Plus grand = meilleur flux pour enregistrer. + pub quality_rank: i32, +} + +/// « bein sports max 4 », « beinsports 2 », « bein sport 1 », « bein sports xtra ». +static BEIN: LazyLock = LazyLock::new(|| { + Regex::new(r"\bbein ?sports?\b(?: (max|xtra|premium|news))?(?: ?(\d{1,2}))?\b").unwrap() +}); + +/// Préfixe pays du nom brut : `FR - `, `FR| `, `|FR| `, `[FR] `, `AR: `… +static COUNTRY_PREFIX: LazyLock = + LazyLock::new(|| Regex::new(r"^\s*[\|\[\(]?\s*([A-Za-z]{2,4})\s*[\|\]\):\-]").unwrap()); + +/// Marqueurs d'une version étrangère (dans le nom ou la catégorie). +const FOREIGN: &[&str] = &[ + "ar", + "arab", + "arabic", + "arabia", + "mena", + "me", + "qa", + "qatar", + "tr", + "turk", + "turkey", + "turkiye", + "es", + "esp", + "espana", + "spain", + "us", + "usa", + "en", + "eng", + "english", + "uk", + "au", + "aus", + "australia", + "nz", + "asia", + "ca", + "canada", + "my", + "malaysia", + "id", + "indonesia", + "th", + "thailand", + "hk", + "ph", + "sg", + "afr", + "africa", + "de", + "it", + "pt", + "ru", +]; + +/// Marqueurs d'une version française. +const FRENCH: &[&str] = &[ + "fr", + "fra", + "france", + "french", + "francais", + "francaise", + "vf", +]; + +fn words(text: &str) -> Vec { + normalize(text).split(' ').map(str::to_string).collect() +} + +fn quality_rank(norm_name: &str) -> i32 { + let w: Vec<&str> = norm_name.split(' ').collect(); + let has = |k: &str| w.contains(&k); + let mut rank = if has("fhd") || has("1080") || has("1080p") { + 4 + } else if has("hd") || has("720") || has("720p") { + 3 + } else if has("4k") || has("uhd") { + // Souvent instable chez les revendeurs : derrière le FHD. + 2 + } else if has("sd") || has("lq") { + 1 + } else { + 2 + }; + if has("backup") || has("bk") || has("raw") { + rank -= 2; + } + rank +} + +/// Analyse un nom de chaîne. `None` si ce n'est pas une beIN Sports. +pub fn classify(stream_id: &str, name: &str, category: &str) -> Option { + let norm = normalize(name); + let caps = BEIN.captures(&norm)?; + let variant = caps.get(1).map(|m| m.as_str()); + let number = caps.get(2).map(|m| m.as_str()); + let (logical, label) = match (variant, number) { + (Some(v), Some(n)) => ( + format!("bein-sports-{v}-{n}"), + format!("beIN SPORTS {} {n}", v.to_uppercase()), + ), + (Some(v), None) => ( + format!("bein-sports-{v}"), + format!("beIN SPORTS {}", v.to_uppercase()), + ), + (None, Some(n)) => (format!("bein-sports-{n}"), format!("beIN SPORTS {n}")), + (None, None) => ("bein-sports".to_string(), "beIN SPORTS".to_string()), + }; + + let prefix = COUNTRY_PREFIX + .captures(name) + .and_then(|c| c.get(1)) + .map(|m| m.as_str().to_ascii_lowercase()); + // Le préfixe du nom fait foi ; sinon les mots du nom (hors « bein sports + // N ») puis ceux de la catégorie. + let rest_of_name = BEIN.replace(&norm, " "); + let name_words = words(&rest_of_name); + let cat_words = words(category); + let is_marked = |list: &[&str], ws: &[String]| ws.iter().any(|w| list.contains(&w.as_str())); + let is_fr = match prefix.as_deref() { + Some(p) if FRENCH.contains(&p) => true, + Some(p) if FOREIGN.contains(&p) => false, + _ => { + if is_marked(FOREIGN, &name_words) { + false + } else if is_marked(FRENCH, &name_words) { + true + } else { + is_marked(FRENCH, &cat_words) && !is_marked(FOREIGN, &cat_words) + } + } + }; + + Some(BeinChannel { + stream_id: stream_id.to_string(), + name: name.to_string(), + category: category.to_string(), + logical, + label, + is_fr, + quality_rank: quality_rank(&norm), + }) +} + +/// Toutes les beIN de la liste, triées : françaises d'abord, puis par +/// chaîne logique, meilleure qualité en tête. +pub fn detect(items: &[(String, String, String)]) -> Vec { + let mut found: Vec = items + .iter() + .filter_map(|(id, name, cat)| classify(id, name, cat)) + .collect(); + found.sort_by(|a, b| { + b.is_fr + .cmp(&a.is_fr) + .then_with(|| natural_key(&a.logical).cmp(&natural_key(&b.logical))) + .then_with(|| b.quality_rank.cmp(&a.quality_rank)) + .then_with(|| a.stream_id.cmp(&b.stream_id)) + }); + found +} + +/// « bein-sports-10 » après « bein-sports-2 ». +fn natural_key(logical: &str) -> (String, u32) { + let (head, tail) = logical.rsplit_once('-').unwrap_or((logical, "")); + match tail.parse::() { + Ok(n) => (head.to_string(), n), + Err(_) => (logical.to_string(), 0), + } +} + +/// Le meilleur flux français par chaîne logique : sélection par défaut +/// d'une nouvelle règle. +pub fn preferred_fr(detected: &[BeinChannel]) -> Vec { + let mut seen = std::collections::HashSet::new(); + detected + .iter() + .filter(|c| c.is_fr && seen.insert(c.logical.clone())) + .cloned() + .collect() +} + +/// `(stream_id, nom, catégorie)` de toutes les chaînes live du panneau. +pub async fn fetch_live_table( + client: &reqwest::Client, + playlist: &PlaylistConfig, +) -> Vec<(String, String, String)> { + let api = |action: &str| { + format!( + "{}/player_api.php?username={}&password={}&action={action}", + playlist.dns, playlist.username, playlist.password + ) + }; + let get = |url: String| async move { + let r = client + .get(&url) + .timeout(std::time::Duration::from_secs(90)) + .send() + .await + .ok()?; + let body = r.bytes().await.ok()?; + tokio::task::spawn_blocking(move || serde_json::from_slice::(&body).ok()) + .await + .ok() + .flatten() + }; + let as_text = |v: Option<&Value>| match v { + Some(Value::String(s)) => s.clone(), + Some(Value::Null) | None => String::new(), + Some(other) => other.to_string(), + }; + let categories: HashMap = get(api("get_live_categories")) + .await + .and_then(|v| v.as_array().cloned()) + .unwrap_or_default() + .iter() + .map(|c| { + ( + as_text(c.get("category_id")), + as_text(c.get("category_name")), + ) + }) + .collect(); + get(api("get_live_streams")) + .await + .and_then(|v| v.as_array().cloned()) + .unwrap_or_default() + .iter() + .filter_map(|s| { + let id = as_text(s.get("stream_id")); + let name = as_text(s.get("name")); + if id.is_empty() || name.is_empty() { + return None; + } + let cat = categories + .get(&as_text(s.get("category_id"))) + .cloned() + .unwrap_or_default(); + Some((id, name, cat)) + }) + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + + fn c(name: &str, cat: &str) -> Option { + classify("1", name, cat) + } + + #[test] + fn chaines_francaises() { + let ch = c("FR - BEIN SPORTS 1 FHD", "FRANCE SPORTS").unwrap(); + assert!(ch.is_fr); + assert_eq!(ch.logical, "bein-sports-1"); + assert_eq!(ch.label, "beIN SPORTS 1"); + assert!(c("FR| beIN SPORTS MAX 4", "").unwrap().is_fr); + assert_eq!( + c("FR| beIN SPORTS MAX 4", "").unwrap().logical, + "bein-sports-max-4" + ); + assert!(c("beIN SPORTS 2 FR HD", "Sport").unwrap().is_fr); + assert!(c("BEIN SPORT 3", "FR | SPORTS").unwrap().is_fr); + assert!(c("|FR| BEINSPORTS 1 ᴴᴰ", "").unwrap().is_fr); + } + + #[test] + fn chaines_etrangeres_exclues() { + assert!(!c("AR| beIN SPORTS 1", "ARABIC SPORTS").unwrap().is_fr); + assert!(!c("TR: BEIN SPORTS 1 HD", "TURKIYE").unwrap().is_fr); + assert!(!c("beIN SPORTS MENA 1", "FR SPORTS").unwrap().is_fr); + assert!(!c("beIN SPORTS 1 (AUS)", "").unwrap().is_fr); + assert!(!c("US: beIN Sports", "USA SPORTS").unwrap().is_fr); + // Aucune indication de pays : on ne devine pas. + assert!(!c("beIN SPORTS 1", "SPORTS").unwrap().is_fr); + } + + #[test] + fn pas_une_bein() { + assert!(c("FR - CANAL+ SPORT", "FRANCE").is_none()); + assert!(c("FR - RMC SPORT 1", "FRANCE").is_none()); + } + + #[test] + fn meilleure_qualite_par_chaine_logique() { + let items: Vec<(String, String, String)> = [ + ("10", "FR - BEIN SPORTS 1 HD"), + ("11", "FR - BEIN SPORTS 1 FHD"), + ("12", "FR - BEIN SPORTS 1 FHD BACKUP"), + ("20", "FR - BEIN SPORTS 2 HD"), + ("30", "AR - BEIN SPORTS 1"), + ("40", "FR - BEIN SPORTS MAX 10"), + ("41", "FR - BEIN SPORTS MAX 4"), + ] + .iter() + .map(|(id, n)| (id.to_string(), n.to_string(), String::new())) + .collect(); + let detected = detect(&items); + assert_eq!(detected.len(), 7); + assert!(!detected.last().unwrap().is_fr); + let preferred: Vec = preferred_fr(&detected) + .into_iter() + .map(|c| c.stream_id) + .collect(); + assert_eq!(preferred, ["11", "20", "41", "40"]); + } +} diff --git a/server/src/services/mod.rs b/server/src/services/mod.rs index b074381..5ae206e 100644 --- a/server/src/services/mod.rs +++ b/server/src/services/mod.rs @@ -1,7 +1,12 @@ +pub mod bein_channels; pub mod cleanup; pub mod ffmpeg_sessions; pub mod gpu_fallback; pub mod logo_catalog; +pub mod nba_artwork; +pub mod nba_autorec; +pub mod nba_schedule; +pub mod nba_teams; pub mod recording_decision; pub mod recording_scheduler; pub mod upstream_slots; diff --git a/server/src/services/nba_artwork.rs b/server/src/services/nba_artwork.rs new file mode 100644 index 0000000..12f71e3 --- /dev/null +++ b/server/src/services/nba_artwork.rs @@ -0,0 +1,656 @@ +//! Vignettes des matchs NBA enregistrés, pour Plex et Jellyfin. +//! +//! POURQUOI : sans image, une bibliothèque Plex/Jellyfin affiche une capture +//! prise au hasard dans le fichier (souvent une pub ou un plateau télé) et +//! rien ne distingue un match d'un autre. On génère une vignette « Celtics @ +//! Lakers » avec les logos des deux équipes, déposée à côté du fichier sous +//! les noms que les deux serveurs cherchent, plus un `.nfo` (titre, date, +//! résumé) et une copie intégrée au MKV. +//! +//! Formats déposés à côté de `X.mkv` : +//! - `X.jpg`, `X-poster.jpg` : affiche 2:3 (Plex « local assets », Jellyfin) ; +//! - `X-thumb.jpg`, `X-fanart.jpg` : vignette 16:9 ; +//! - `X.nfo` : métadonnées au format Kodi « movie », lues par Jellyfin. + +use crate::dart_time::DartDateTime; +use crate::models::NbaGame; +use crate::services::nba_teams::{self, Team}; +use ab_glyph::{Font, FontVec, PxScale, ScaleFont}; +use chrono::{DateTime, Datelike, Local, Timelike, Utc}; +use image::{Rgba, RgbaImage, imageops}; +use std::path::{Path, PathBuf}; +use std::sync::LazyLock; + +const THUMB: (u32, u32) = (1280, 720); +const POSTER: (u32, u32) = (1000, 1500); + +/// Police : `ARTWORK_FONT`, sinon DejaVu (image Docker), sinon Arial (Windows). +static FONT: LazyLock> = LazyLock::new(|| { + let candidates = [ + std::env::var("ARTWORK_FONT").unwrap_or_default(), + "/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf".into(), + "/usr/share/fonts/TTF/DejaVuSans-Bold.ttf".into(), + "C:\\Windows\\Fonts\\arialbd.ttf".into(), + "/System/Library/Fonts/Supplemental/Arial Bold.ttf".into(), + ]; + let font = candidates.iter().filter(|p| !p.is_empty()).find_map(|p| { + std::fs::read(p) + .ok() + .and_then(|b| FontVec::try_from_vec(b).ok()) + }); + if font.is_none() { + println!("[NBA] Aucune police trouvée (ARTWORK_FONT) : vignettes sans texte"); + } + font +}); + +fn hex_color(hex: &str) -> [u8; 3] { + let v = u32::from_str_radix(hex.trim_start_matches('#'), 16).unwrap_or(0x333333); + [(v >> 16) as u8, (v >> 8) as u8, v as u8] +} + +fn shade(c: [u8; 3], factor: f32) -> [u8; 3] { + c.map(|v| (v as f32 * factor).clamp(0.0, 255.0) as u8) +} + +fn blend(dst: &mut Rgba, src: [u8; 3], alpha: f32) { + let a = alpha.clamp(0.0, 1.0); + for (d, s) in dst.0.iter_mut().zip(src) { + *d = (*d as f32 * (1.0 - a) + s as f32 * a) as u8; + } + dst.0[3] = 255; +} + +/// Fond : couleur de l'équipe visiteuse vers celle de l'équipe à domicile, +/// assombries pour que logos et texte ressortent. +fn background(w: u32, h: u32, from: [u8; 3], to: [u8; 3], vertical: bool) -> RgbaImage { + let (a, b) = (shade(from, 0.55), shade(to, 0.55)); + RgbaImage::from_fn(w, h, |x, y| { + let t = if vertical { + y as f32 / h as f32 + } else { + // Diagonale douce. + (x as f32 / w as f32 * 0.8 + y as f32 / h as f32 * 0.2).clamp(0.0, 1.0) + }; + let t = ((t - 0.5) * 2.2 + 0.5).clamp(0.0, 1.0); + let mix = |i: usize| (a[i] as f32 * (1.0 - t) + b[i] as f32 * t) as u8; + Rgba([mix(0), mix(1), mix(2), 255]) + }) +} + +/// Largeur d'un texte à une taille donnée. +fn text_width(font: &FontVec, text: &str, px: f32) -> f32 { + let scaled = font.as_scaled(PxScale::from(px)); + let mut width = 0.0; + let mut prev = None; + for c in text.chars() { + let id = scaled.glyph_id(c); + if let Some(p) = prev { + width += scaled.kern(p, id); + } + width += scaled.h_advance(id); + prev = Some(id); + } + width +} + +/// Texte centré sur `cx`, ligne de base à `baseline`. Réduit la taille +/// jusqu'à tenir dans `max_width`. +fn draw_text( + img: &mut RgbaImage, + text: &str, + mut px: f32, + cx: f32, + baseline: f32, + max_width: f32, + color: [u8; 3], +) { + let Some(font) = FONT.as_ref() else { return }; + while px > 12.0 && text_width(font, text, px) > max_width { + px -= 2.0; + } + let scaled = font.as_scaled(PxScale::from(px)); + let mut x = cx - text_width(font, text, px) / 2.0; + let mut prev = None; + for c in text.chars() { + let id = scaled.glyph_id(c); + if let Some(p) = prev { + x += scaled.kern(p, id); + } + let glyph = id.with_scale_and_position(px, ab_glyph::point(x, baseline)); + x += scaled.h_advance(id); + prev = Some(id); + let Some(outline) = font.outline_glyph(glyph) else { + continue; + }; + let bounds = outline.px_bounds(); + outline.draw(|gx, gy, coverage| { + let px_x = bounds.min.x as i64 + gx as i64; + let px_y = bounds.min.y as i64 + gy as i64; + if px_x >= 0 && px_y >= 0 && (px_x as u32) < img.width() && (px_y as u32) < img.height() + { + blend(img.get_pixel_mut(px_x as u32, px_y as u32), color, coverage); + } + }); + } +} + +/// Logo centré dans un carré `size` autour de (cx, cy) ; à défaut de logo, +/// un disque aux couleurs de l'équipe avec son abréviation. +fn draw_logo( + img: &mut RgbaImage, + logo: Option<&RgbaImage>, + team: &TeamArt, + cx: i64, + cy: i64, + size: u32, +) { + if let Some(logo) = logo { + let ratio = (size as f32 / logo.width().max(logo.height()) as f32).min(4.0); + let (w, h) = ( + (logo.width() as f32 * ratio) as u32, + (logo.height() as f32 * ratio) as u32, + ); + let resized = imageops::resize(logo, w.max(1), h.max(1), imageops::FilterType::Lanczos3); + imageops::overlay(img, &resized, cx - w as i64 / 2, cy - h as i64 / 2); + return; + } + let r = size as f32 / 2.0; + let color = hex_color(&team.color); + for y in (cy - r as i64).max(0)..(cy + r as i64).min(img.height() as i64) { + for x in (cx - r as i64).max(0)..(cx + r as i64).min(img.width() as i64) { + let d = (((x - cx) as f32).powi(2) + ((y - cy) as f32).powi(2)).sqrt(); + if d <= r { + blend( + img.get_pixel_mut(x as u32, y as u32), + color, + (r - d).clamp(0.0, 1.0), + ); + } + } + } + draw_text( + img, + &team.abbr, + size as f32 * 0.38, + cx as f32, + cy as f32 + size as f32 * 0.13, + size as f32 * 0.85, + [255, 255, 255], + ); +} + +/// Bandeau sombre en bas de l'image. +fn banner(img: &mut RgbaImage, height: u32) { + let (w, h) = img.dimensions(); + for y in h.saturating_sub(height)..h { + for x in 0..w { + blend(img.get_pixel_mut(x, y), [0, 0, 0], 0.62); + } + } +} + +/// Ce qu'il faut savoir d'une équipe pour la dessiner. +#[derive(Clone, Debug)] +pub struct TeamArt { + pub abbr: String, + pub nickname: String, + pub color: String, +} + +impl TeamArt { + pub fn from_abbr(abbr: &str, fallback_name: &str) -> Self { + match nba_teams::by_abbr(abbr) { + Some(Team { + abbr, + nickname, + color, + .. + }) => Self { + abbr: abbr.to_string(), + nickname: nickname.to_string(), + color: color.to_string(), + }, + None => Self { + abbr: abbr.to_uppercase(), + nickname: fallback_name.to_string(), + color: "333333".into(), + }, + } + } +} + +/// Textes de la vignette. +pub struct ArtText { + /// « Celtics @ Lakers » + pub matchup: String, + /// « mer. 22 oct. 2026 · 02:30 · beIN SPORTS 1 » + pub details: String, +} + +/// Vignette 16:9. +pub fn render_thumb( + away: &TeamArt, + home: &TeamArt, + away_logo: Option<&RgbaImage>, + home_logo: Option<&RgbaImage>, + text: &ArtText, +) -> RgbaImage { + let (w, h) = THUMB; + let mut img = background(w, h, hex_color(&away.color), hex_color(&home.color), false); + draw_text( + &mut img, + "NBA", + 44.0, + w as f32 / 2.0, + 70.0, + 400.0, + [255, 255, 255], + ); + draw_logo(&mut img, away_logo, away, 320, 300, 340); + draw_logo(&mut img, home_logo, home, 960, 300, 340); + draw_text( + &mut img, + "@", + 110.0, + w as f32 / 2.0, + 340.0, + 200.0, + [255, 255, 255], + ); + banner(&mut img, 170); + draw_text( + &mut img, + &text.matchup, + 64.0, + w as f32 / 2.0, + 625.0, + 1180.0, + [255, 255, 255], + ); + draw_text( + &mut img, + &text.details, + 34.0, + w as f32 / 2.0, + 685.0, + 1180.0, + [220, 220, 220], + ); + img +} + +/// Affiche 2:3. +pub fn render_poster( + away: &TeamArt, + home: &TeamArt, + away_logo: Option<&RgbaImage>, + home_logo: Option<&RgbaImage>, + text: &ArtText, +) -> RgbaImage { + let (w, h) = POSTER; + let mut img = background(w, h, hex_color(&away.color), hex_color(&home.color), true); + draw_text( + &mut img, + "NBA", + 64.0, + w as f32 / 2.0, + 110.0, + 600.0, + [255, 255, 255], + ); + draw_logo(&mut img, away_logo, away, 500, 390, 400); + draw_text( + &mut img, + "@", + 120.0, + w as f32 / 2.0, + 760.0, + 300.0, + [255, 255, 255], + ); + draw_logo(&mut img, home_logo, home, 500, 1000, 400); + banner(&mut img, 260); + draw_text( + &mut img, + &text.matchup, + 72.0, + w as f32 / 2.0, + 1340.0, + 920.0, + [255, 255, 255], + ); + draw_text( + &mut img, + &text.details, + 34.0, + w as f32 / 2.0, + 1420.0, + 920.0, + [220, 220, 220], + ); + img +} + +const JOURS: [&str; 7] = ["lun.", "mar.", "mer.", "jeu.", "ven.", "sam.", "dim."]; +const MOIS: [&str; 12] = [ + "janv.", "févr.", "mars", "avr.", "mai", "juin", "juil.", "août", "sept.", "oct.", "nov.", + "déc.", +]; + +/// « mer. 22 oct. 2026 · 02:30 » en heure locale du serveur (TZ). +pub fn french_date(dt: DateTime) -> String { + let l = dt.with_timezone(&Local); + format!( + "{} {} {} {} · {:02}:{:02}", + JOURS[l.weekday().num_days_from_monday() as usize], + l.day(), + MOIS[l.month0() as usize], + l.year(), + l.hour(), + l.minute() + ) +} + +fn texts(game: &NbaGame, away: &TeamArt, home: &TeamArt) -> ArtText { + let tipoff = DartDateTime::parse(&game.tipoff_utc) + .map(DartDateTime::to_utc) + .unwrap_or_else(Utc::now); + let mut details = french_date(tipoff); + if let Some(ch) = &game.channel_name { + details = format!("{details} · {ch}"); + } + ArtText { + matchup: format!("{} @ {}", away.nickname, home.nickname), + details, + } +} + +/// Dossier des vignettes et logos en cache. +pub fn artwork_dir(recordings_dir: &Path) -> PathBuf { + recordings_dir.join(".artwork") +} + +/// Logo d'équipe : cache disque, sinon CDN ESPN. +async fn team_logo(client: &reqwest::Client, dir: &Path, abbr: &str) -> Option { + let team = nba_teams::by_abbr(abbr)?; + let path = dir.join("logos").join(format!("{}.png", team.abbr)); + let bytes = match tokio::fs::read(&path).await { + Ok(b) => b, + Err(_) => { + let r = client + .get(team.logo_url()) + .timeout(std::time::Duration::from_secs(20)) + .send() + .await + .ok()?; + if !r.status().is_success() { + return None; + } + let b = r.bytes().await.ok()?.to_vec(); + let _ = tokio::fs::create_dir_all(path.parent()?).await; + let _ = tokio::fs::write(&path, &b).await; + b + } + }; + image::load_from_memory(&bytes).ok().map(|i| i.to_rgba8()) +} + +fn save_jpeg(img: &RgbaImage, path: &Path) -> std::io::Result<()> { + let rgb = image::DynamicImage::ImageRgba8(img.clone()).to_rgb8(); + let mut out = std::fs::File::create(path)?; + let mut enc = image::codecs::jpeg::JpegEncoder::new_with_quality(&mut out, 88); + enc.encode_image(&rgb).map_err(std::io::Error::other) +} + +/// Génère (ou régénère) vignette et affiche d'un match dans le cache. +/// Renvoie `(thumb, poster)`. +pub async fn ensure_artwork( + client: &reqwest::Client, + recordings_dir: &Path, + game: &NbaGame, +) -> Option<(PathBuf, PathBuf)> { + let dir = artwork_dir(recordings_dir); + tokio::fs::create_dir_all(&dir).await.ok()?; + let away = TeamArt::from_abbr(&game.away_tri, &game.away); + let home = TeamArt::from_abbr(&game.home_tri, &game.home); + let away_logo = team_logo(client, &dir, &game.away_tri).await; + let home_logo = team_logo(client, &dir, &game.home_tri).await; + let text = texts(game, &away, &home); + let safe_id: String = game + .game_id + .chars() + .filter(|c| c.is_ascii_alphanumeric()) + .collect(); + let thumb_path = dir.join(format!("{safe_id}-thumb.jpg")); + let poster_path = dir.join(format!("{safe_id}-poster.jpg")); + let (tp, pp) = (thumb_path.clone(), poster_path.clone()); + tokio::task::spawn_blocking(move || { + save_jpeg( + &render_thumb(&away, &home, away_logo.as_ref(), home_logo.as_ref(), &text), + &tp, + )?; + save_jpeg( + &render_poster(&away, &home, away_logo.as_ref(), home_logo.as_ref(), &text), + &pp, + ) + }) + .await + .ok()? + .map_err(|e| println!("[NBA] Vignette {} impossible : {e}", game.game_id)) + .ok()?; + Some((thumb_path, poster_path)) +} + +/// Chemins des fichiers annexes de `X.mkv`. +pub fn sidecar_paths(video: &Path) -> Vec { + let stem = video.with_extension(""); + let s = stem.to_string_lossy(); + [ + "{}.jpg", + "{}-poster.jpg", + "{}-thumb.jpg", + "{}-fanart.jpg", + "{}.nfo", + ] + .iter() + .map(|pattern| PathBuf::from(pattern.replace("{}", &s))) + .collect() +} + +fn xml_escape(s: &str) -> String { + s.replace('&', "&") + .replace('<', "<") + .replace('>', ">") + .replace('"', """) +} + +/// `.nfo` Kodi « movie » (Jellyfin le lit nativement). +pub fn nfo(game: &NbaGame, title: &str) -> String { + let tipoff = DartDateTime::parse(&game.tipoff_utc) + .map(DartDateTime::to_utc) + .unwrap_or_else(Utc::now); + let local = tipoff.with_timezone(&Local); + let channel = game + .channel_name + .clone() + .unwrap_or_else(|| "beIN SPORTS".into()); + let plot = format!( + "{} reçoit {} — diffusion en direct sur {}, {}.", + game.home, + game.away, + channel, + french_date(tipoff) + ); + format!( + "\n\n {}\n {} {}\n {}\n {}\n {}\n Basketball\n NBA\n {}\n NBA\n {}\n {}\n {}\n\n", + xml_escape(title), + local.format("%Y-%m-%d"), + xml_escape(title), + xml_escape(&plot), + local.format("%Y-%m-%d"), + local.year(), + xml_escape(&channel), + xml_escape(&game.home), + xml_escape(&game.away), + xml_escape(&game.game_id), + ) +} + +/// Après un enregistrement réussi : dépose vignettes et `.nfo` à côté du +/// fichier, puis intègre la vignette au MKV (pièce jointe `cover.jpg`). +pub async fn finish_recording( + client: &reqwest::Client, + recordings_dir: &Path, + game: &NbaGame, + video: &Path, + title: &str, + ffmpeg: &str, +) { + let Some((thumb, poster)) = ensure_artwork(client, recordings_dir, game).await else { + return; + }; + let paths = sidecar_paths(video); + let copies = [ + (&poster, &paths[0]), + (&poster, &paths[1]), + (&thumb, &paths[2]), + (&thumb, &paths[3]), + ]; + for (src, dst) in copies { + if let Err(e) = tokio::fs::copy(src, dst).await { + println!("[NBA] Copie de {} impossible : {e}", dst.display()); + } + } + let _ = tokio::fs::write(&paths[4], nfo(game, title)).await; + embed_cover(video, &thumb, title, ffmpeg).await; +} + +/// Remux sans réencodage avec la vignette en pièce jointe et le titre en +/// métadonnée. En cas d'échec, le fichier d'origine reste intact. +async fn embed_cover(video: &Path, cover: &Path, title: &str, ffmpeg: &str) { + let tmp = video.with_extension("cover.mkv"); + let status = tokio::process::Command::new(ffmpeg) + .args(["-hide_banner", "-nostats", "-loglevel", "error", "-y", "-i"]) + .arg(video) + .args(["-map", "0", "-c", "copy", "-attach"]) + .arg(cover) + .args([ + "-metadata:s:t", + "mimetype=image/jpeg", + "-metadata:s:t", + "filename=cover.jpg", + "-metadata", + ]) + .arg(format!("title={title}")) + .arg(&tmp) + .stdin(std::process::Stdio::null()) + .status() + .await; + let ok = + status.is_ok_and(|s| s.success()) && std::fs::metadata(&tmp).is_ok_and(|m| m.len() > 0); + if ok { + if let Err(e) = tokio::fs::rename(&tmp, video).await { + println!("[NBA] Remplacement du fichier impossible : {e}"); + let _ = tokio::fs::remove_file(&tmp).await; + } + } else { + println!("[NBA] Intégration de la vignette au MKV impossible : fichier conservé tel quel"); + let _ = tokio::fs::remove_file(&tmp).await; + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn game() -> NbaGame { + NbaGame { + game_id: "401810026".into(), + rule_id: "r".into(), + home: "Los Angeles Lakers".into(), + away: "Boston Celtics".into(), + home_id: "13".into(), + away_id: "2".into(), + home_tri: "LAL".into(), + away_tri: "BOS".into(), + tipoff_utc: "2026-10-22T02:30:00.000Z".into(), + status: "scheduled".into(), + decision: "auto".into(), + channel_id: Some("1".into()), + channel_name: Some("beIN SPORTS 1".into()), + epg_title: None, + epg_start: None, + epg_end: None, + recording_id: None, + confidence: None, + conflict: false, + updated_at: String::new(), + } + } + + #[test] + fn dimensions_et_couleurs_sans_logo() { + let away = TeamArt::from_abbr("BOS", "Boston Celtics"); + let home = TeamArt::from_abbr("LAL", "Los Angeles Lakers"); + let text = texts(&game(), &away, &home); + assert_eq!(text.matchup, "Celtics @ Lakers"); + assert!(text.details.ends_with("· beIN SPORTS 1")); + let thumb = render_thumb(&away, &home, None, None, &text); + assert_eq!(thumb.dimensions(), THUMB); + let poster = render_poster(&away, &home, None, None, &text); + assert_eq!(poster.dimensions(), POSTER); + // Le disque de repli prend la couleur de l'équipe (vert Celtics) : + // canal vert dominant au bord du disque visiteur. + let p = thumb.get_pixel(320, 300 - 150); + assert!(p.0[1] > p.0[0] && p.0[1] > p.0[2], "{p:?}"); + } + + #[test] + fn fichiers_annexes_plex_jellyfin() { + let paths = sidecar_paths(Path::new("/rec/NBA - Celtics @ Lakers.mkv")); + let names: Vec = paths + .iter() + .map(|p| p.file_name().unwrap().to_string_lossy().into_owned()) + .collect(); + assert_eq!( + names, + [ + "NBA - Celtics @ Lakers.jpg", + "NBA - Celtics @ Lakers-poster.jpg", + "NBA - Celtics @ Lakers-thumb.jpg", + "NBA - Celtics @ Lakers-fanart.jpg", + "NBA - Celtics @ Lakers.nfo", + ] + ); + } + + #[test] + fn nfo_echappe_et_complet() { + let mut g = game(); + g.channel_name = Some("beIN & co".into()); + let xml = nfo(&g, "NBA - Celtics @ Lakers"); + assert!(xml.contains("NBA - Celtics @ Lakers")); + assert!(xml.contains("beIN <SPORTS> & co")); + assert!(xml.contains("401810026")); + assert!(xml.contains("NBA")); + } + + /// Aperçu réel (réseau) : `ARTWORK_PREVIEW_DIR=... cargo test apercu -- --ignored`. + #[tokio::test] + #[ignore] + async fn apercu_avec_logos() { + let dir = PathBuf::from(std::env::var("ARTWORK_PREVIEW_DIR").unwrap()); + let (thumb, poster) = ensure_artwork(&reqwest::Client::new(), &dir, &game()) + .await + .unwrap(); + assert!(thumb.exists() && poster.exists()); + } + + #[test] + fn equipe_inconnue() { + let t = TeamArt::from_abbr("wld", "World Team"); + assert_eq!(t.abbr, "WLD"); + assert_eq!(t.nickname, "World Team"); + } +} diff --git a/server/src/services/nba_autorec.rs b/server/src/services/nba_autorec.rs new file mode 100644 index 0000000..095bd74 --- /dev/null +++ b/server/src/services/nba_autorec.rs @@ -0,0 +1,894 @@ +//! Enregistrement automatique des matchs NBA diffusés en direct sur les +//! chaînes beIN Sports françaises. +//! +//! Deux sources, une réconciliation : +//! - le calendrier NBA (ESPN) annonce les matchs des semaines à l'avance, +//! avec leur coup d'envoi exact ; +//! - l'EPG des chaînes beIN FR (≈ 48 h devant) dit quelle chaîne diffuse +//! quel match, et quand. +//! +//! POURQUOI le coup d'envoi : un match n'a qu'une diffusion en direct, celle +//! qui commence avec lui. Toute autre occurrence du même match dans le guide +//! (le lendemain matin, l'après-midi…) est une rediffusion. Ce critère ne +//! dépend ni des titres, très variables, ni des drapeaux XMLTV, rarement +//! renseignés ; ceux-ci ne servent que de signaux d'appoint. + +use crate::api::epg::EpgApi; +use crate::dart_time::{DartDateTime, utc_iso}; +use crate::db::AppDatabase; +use crate::models::{AutoRule, NbaGame, PlaylistConfig, Recording}; +use crate::services::nba_artwork; +use crate::services::nba_schedule; +use crate::services::nba_teams::{self, Team, normalize}; +use crate::services::recording_scheduler::{RecordingScheduler, recordings_dir_path}; +use chrono::{DateTime, Duration, Utc}; +use serde_json::Value; +use std::collections::HashMap; +use std::sync::{Arc, Mutex}; +use tokio::task::JoinHandle; + +/// Fenêtre autour du coup d'envoi dans laquelle doit démarrer la diffusion +/// en direct (avant-match compris). +const WINDOW_BEFORE: i64 = 45; +const WINDOW_AFTER: i64 = 20; +/// Durée minimale couverte après le coup d'envoi (match + prolongations +/// usuelles), même si l'EPG annonce un créneau plus court. +const MIN_GAME_MINUTES: i64 = 150; +/// Score minimal pour considérer un programme comme la diffusion en direct. +const MIN_CONFIDENCE: i64 = 40; +/// Jours de calendrier chargés devant. +const SCHEDULE_DAYS: i64 = 21; + +/// Programme EPG d'une chaîne surveillée. +#[derive(Clone, Debug, PartialEq)] +pub struct EpgProgramme { + pub channel_id: String, + pub channel_label: String, + pub title: String, + pub description: String, + pub start: DateTime, + pub end: DateTime, + pub is_live: bool, + pub previously_shown: bool, +} + +impl EpgProgramme { + /// Depuis le JSON de `EpgApi::programmes`. Les drapeaux sont optionnels. + pub fn from_json(channel_id: &str, channel_label: &str, v: &Value) -> Option { + let date = |k: &str| { + v.get(k) + .and_then(Value::as_str) + .and_then(DartDateTime::parse) + .map(DartDateTime::to_utc) + }; + let text = |k: &str| { + v.get(k) + .and_then(Value::as_str) + .unwrap_or_default() + .to_string() + }; + let flag = |k: &str| v.get(k).and_then(Value::as_bool).unwrap_or(false); + let sub = text("sub_title"); + let mut description = text("description"); + if !sub.is_empty() { + description = format!("{sub} {description}"); + } + Some(Self { + channel_id: channel_id.to_string(), + channel_label: channel_label.to_string(), + title: text("title"), + description, + start: date("start")?, + end: date("end")?, + is_live: flag("is_live"), + previously_shown: flag("previously_shown"), + }) + } + + fn text(&self) -> String { + format!("{} {}", self.title, self.description) + } +} + +/// Mots qui signalent une rediffusion ou un programme autour du match. +const RERUN_WORDS: &[&str] = &[ + "rediffusion", + "rediff", + "redif", + "differe", + "en differe", + "replay", + "best of", + "resume", + "resumes", + "highlights", + "temps forts", + "magazine", + "le mag", + "extra", + "inside the nba", + "previously", +]; +const LIVE_WORDS: &[&str] = &["direct", "en direct", "live"]; + +fn contains_words(hay: &str, words: &[&str]) -> bool { + let padded = format!(" {hay} "); + words.iter().any(|w| padded.contains(&format!(" {w} "))) +} + +/// Diffusion retenue pour un match. +#[derive(Clone, Debug, PartialEq)] +pub struct LiveBroadcast { + pub programme: EpgProgramme, + pub confidence: i64, +} + +/// Cherche la diffusion en direct du match `home`/`away` (abréviations ESPN) +/// prévu à `tipoff`. `other_games_nearby` : d'autres matchs commencent à +/// moins d'une heure — un programme « NBA » sans équipes lisibles est alors +/// ambigu et refusé. +pub fn find_live_broadcast( + home: &str, + away: &str, + tipoff: DateTime, + other_games_nearby: bool, + programmes: &[EpgProgramme], +) -> Option { + let wanted: Vec<&str> = [home, away] + .iter() + .filter_map(|a| nba_teams::by_abbr(a).map(|t| t.abbr)) + .collect(); + let lo = tipoff - Duration::minutes(WINDOW_BEFORE); + let hi = tipoff + Duration::minutes(WINDOW_AFTER); + programmes + .iter() + .filter(|p| p.start >= lo && p.start <= hi && p.end > p.start) + .filter_map(|p| { + let text = p.text(); + let norm = normalize(&text); + if p.previously_shown || contains_words(&norm, RERUN_WORDS) { + return None; + } + let teams: Vec<&'static Team> = nba_teams::extract_teams(&text); + // Une équipe citée qui n'est pas de ce match : c'est un autre match. + if teams.iter().any(|t| !wanted.contains(&t.abbr)) { + return None; + } + let mut score = match teams.len() { + 2.. => 60, + 1 => 30, + _ if nba_teams::mentions_nba(&text) && !other_games_nearby => 10, + _ => return None, + }; + let gap = (p.start - tipoff).num_minutes().abs(); + score += if gap <= 20 { 30 } else { 15 }; + if p.is_live || contains_words(&norm, LIVE_WORDS) { + score += 10; + } + (score >= MIN_CONFIDENCE).then(|| LiveBroadcast { + programme: p.clone(), + confidence: score, + }) + }) + .max_by_key(|b| { + ( + b.confidence, + -(b.programme.start - tipoff).num_minutes().abs(), + ) + }) +} + +/// Le match passe-t-il le filtre d'équipes de la règle (vide = tous) ? +pub fn passes_team_filter(rule: &AutoRule, home: &str, away: &str) -> bool { + if rule.team_filter.is_empty() { + return true; + } + let keys = |abbr: &str| -> Vec { + match nba_teams::by_abbr(abbr) { + Some(t) => vec![ + normalize(t.abbr), + normalize(t.nickname), + normalize(&t.full_name()), + normalize(t.city), + ], + None => vec![normalize(abbr)], + } + }; + let game_keys: Vec = keys(home).into_iter().chain(keys(away)).collect(); + rule.team_filter + .iter() + .map(|f| normalize(f)) + .any(|f| game_keys.contains(&f)) +} + +/// L'utilisateur veut-il ce match ? +pub fn wants(rule: &AutoRule, game: &NbaGame) -> bool { + match game.decision.as_str() { + "accepted" => true, + "refused" => false, + _ => { + rule.default_decision == "auto" + && passes_team_filter(rule, &game.home_tri, &game.away_tri) + } + } +} + +/// Fenêtre d'enregistrement : marge avant, au moins `MIN_GAME_MINUTES` +/// après le coup d'envoi, marge après. +pub fn recording_window( + rule: &AutoRule, + tipoff: DateTime, + b: &EpgProgramme, +) -> (DateTime, DateTime) { + let start = b.start.min(tipoff) - Duration::minutes(rule.pad_before_min.max(0)); + let end = b.end.max(tipoff + Duration::minutes(MIN_GAME_MINUTES)) + + Duration::minutes(rule.pad_after_min.max(0)); + (start, end) +} + +/// Titre lisible dans Plex/Jellyfin : « NBA - Celtics @ Lakers ». +pub fn recording_title(game: &NbaGame) -> String { + let nick = |abbr: &str, fallback: &str| { + nba_teams::by_abbr(abbr) + .map(|t| t.nickname.to_string()) + .unwrap_or_else(|| fallback.to_string()) + }; + format!( + "NBA - {} @ {}", + nick(&game.away_tri, &game.away), + nick(&game.home_tri, &game.home) + ) +} + +/// Ce que la réconciliation doit faire pour un match. +#[derive(Debug, PartialEq)] +pub enum Action { + /// Rien à changer à l'enregistrement ; seulement l'état affiché. + Status(&'static str), + Create { + start: DateTime, + end: DateTime, + }, + Realign { + start: DateTime, + end: DateTime, + }, + /// Changement de chaîne : supprimer l'enregistrement planifié et recréer. + Recreate { + start: DateTime, + end: DateTime, + }, + /// Refus de l'utilisateur : supprimer l'enregistrement encore planifié. + Cancel, +} + +/// Décision pure, testable sans base ni réseau. +pub fn plan_action( + rule: &AutoRule, + game: &NbaGame, + tipoff: DateTime, + live: Option<&LiveBroadcast>, + recording: Option<&Recording>, + now: DateTime, +) -> Action { + // Un enregistrement lancé ou terminé n'est plus remis en cause. + if let Some(rec) = recording { + match rec.status.as_str() { + "recording" => return Action::Status("scheduled"), + "completed" => return Action::Status("recorded"), + "failed" => return Action::Status("missed"), + _ => {} + } + } + let scheduled = recording.filter(|r| r.status == "scheduled"); + if !wants(rule, game) { + return match scheduled { + Some(_) => Action::Cancel, + None if live.is_some() => Action::Status("confirmed"), + None => Action::Status(past_or(game, tipoff, now, "provisional")), + }; + } + let Some(live) = live else { + // EPG muet (pas encore publié, ou programme disparu) : on garde un + // enregistrement déjà planifié plutôt que de rater le match. + return match scheduled { + Some(_) => Action::Status("scheduled"), + None => Action::Status(past_or(game, tipoff, now, "provisional")), + }; + }; + let (start, end) = recording_window(rule, tipoff, &live.programme); + if end <= now { + return Action::Status("missed"); + } + match scheduled { + None => Action::Create { start, end }, + Some(rec) if rec.channel_id != live.programme.channel_id => Action::Recreate { start, end }, + Some(rec) => { + let moved = (rec.start_utc() - start).num_seconds().abs() > 60 + || (rec.end_utc() - end).num_seconds().abs() > 60; + if moved { + Action::Realign { start, end } + } else { + Action::Status("scheduled") + } + } + } +} + +/// Match passé sans diffusion trouvée : pas sur beIN FR. +fn past_or( + game: &NbaGame, + tipoff: DateTime, + now: DateTime, + otherwise: &'static str, +) -> &'static str { + if now > tipoff + Duration::hours(3) { + if game.status == "confirmed" { + "missed" + } else { + "not_on_bein" + } + } else { + otherwise + } +} + +/// Créneaux simultanés déjà pris sur [start, end]. +fn overlapping(recordings: &[Recording], start: DateTime, end: DateTime) -> usize { + recordings + .iter() + .filter(|r| matches!(r.status.as_str(), "scheduled" | "recording")) + .filter(|r| r.start_utc() < end && r.end_utc() > start) + .count() +} + +/// Service : calendrier quotidien + réconciliation toutes les 30 minutes. +pub struct NbaAutoRecorder { + db: Arc, + http: reqwest::Client, + epg: EpgApi, + scheduler: Arc, + timers: Mutex>>, + /// Une réconciliation à la fois (timer et API peuvent se croiser). + running: tokio::sync::Mutex<()>, +} + +impl NbaAutoRecorder { + pub fn new( + db: Arc, + http: reqwest::Client, + epg: EpgApi, + scheduler: Arc, + ) -> Self { + Self { + db, + http, + epg, + scheduler, + timers: Mutex::new(Vec::new()), + running: tokio::sync::Mutex::new(()), + } + } + + pub fn start(self: &Arc) { + let mut timers = self.timers.lock().unwrap(); + let this = self.clone(); + timers.push(tokio::spawn(async move { + // Laisser démarrer le serveur et les index EPG. + tokio::time::sleep(std::time::Duration::from_secs(45)).await; + let mut tick = 0u64; + let mut interval = tokio::time::interval(std::time::Duration::from_secs(30 * 60)); + loop { + interval.tick().await; + // Calendrier au démarrage puis toutes les 12 h (24 × 30 min). + if tick.is_multiple_of(24) { + this.refresh_schedules().await; + } + this.reconcile_all().await; + tick += 1; + } + })); + } + + /// Recharge le calendrier pour toutes les règles actives. + pub async fn refresh_schedules(&self) { + let rules = self.db.get_enabled_auto_rules(); + if rules.is_empty() { + return; + } + // Journées ESPN en heure US : commencer la veille couvre les matchs + // de la nuit européenne. + let first = (Utc::now() - Duration::days(1)).date_naive(); + let games = nba_schedule::fetch_days(&self.http, first, SCHEDULE_DAYS + 1).await; + println!( + "[NBA] Calendrier : {} match(s) sur {SCHEDULE_DAYS} jours", + games.len() + ); + for rule in rules { + self.refresh_rule_with(&rule, &games); + } + } + + /// Calendrier d'une seule règle (création, réactivation). + pub async fn refresh_rule(&self, rule: &AutoRule) { + let first = (Utc::now() - Duration::days(1)).date_naive(); + let games = nba_schedule::fetch_days(&self.http, first, SCHEDULE_DAYS + 1).await; + self.refresh_rule_with(rule, &games); + } + + fn refresh_rule_with(&self, rule: &AutoRule, games: &[crate::models::ScheduledGame]) { + for g in games { + if let Err(e) = self.db.upsert_scheduled_game(&rule.id, g) { + println!("[NBA] Match {} non enregistré : {e}", g.game_id); + } + } + } + + pub async fn reconcile_all(&self) { + for rule in self.db.get_enabled_auto_rules() { + self.reconcile(&rule).await; + self.apply_retention(&rule); + } + } + + /// Supprime les matchs enregistrés par la règle depuis plus de + /// `keep_days` jours (fichiers, annexes et ligne). + fn apply_retention(&self, rule: &AutoRule) { + let Some(days) = rule.keep_days.filter(|d| *d > 0) else { + return; + }; + let limit = Utc::now() - Duration::days(days); + for rec in self.db.get_user_recordings(&rule.user_id) { + let expired = rec.source_rule_id.as_deref() == Some(rule.id.as_str()) + && matches!(rec.status.as_str(), "completed" | "failed" | "cancelled") + && rec.end_utc() < limit; + if !expired { + continue; + } + if let Some(path) = &rec.file_path { + RecordingScheduler::delete_recording_files(path); + } + self.db.delete_recording(&rec.id); + println!("[NBA] Rétention : « {} » supprimé", rec.title); + } + } + + fn playlist_for(&self, user_id: &str) -> Option { + self.db + .get_playlists(user_id) + .into_iter() + .next() + .map(|p| PlaylistConfig { + dns: p.server_url, + username: p.username, + password: p.password, + }) + } + + /// Programmes EPG de toutes les chaînes surveillées par la règle. + async fn watched_programmes( + &self, + rule: &AutoRule, + playlist: &PlaylistConfig, + ) -> Vec { + let mut out = Vec::new(); + for channel_id in &rule.channel_ids { + let label = self + .epg + .channel_name(playlist, channel_id) + .await + .map(|n| { + crate::services::bein_channels::classify(channel_id, &n, "") + .map(|c| c.label) + .unwrap_or(n) + }) + .unwrap_or_else(|| format!("Chaîne {channel_id}")); + for v in self.epg.programmes(playlist, channel_id).await { + if let Some(p) = EpgProgramme::from_json(channel_id, &label, &v) { + out.push(p); + } + } + } + out + } + + /// Réconcilie calendrier, EPG et enregistrements d'une règle. + pub async fn reconcile(&self, rule: &AutoRule) { + let _guard = self.running.lock().await; + let now = Utc::now(); + let games = self.db.get_nba_games( + &rule.id, + now - Duration::hours(6), + now + Duration::days(SCHEDULE_DAYS), + ); + if games.is_empty() { + return; + } + let Some(playlist) = self.playlist_for(&rule.user_id) else { + println!("[NBA] Règle {} : aucune playlist", rule.id); + return; + }; + // L'EPG ne couvre que quelques jours : inutile de le charger pour + // des matchs lointains seulement. + let needs_epg = games.iter().any(|g| { + DartDateTime::parse(&g.tipoff_utc).is_some_and(|t| t.to_utc() < now + Duration::days(4)) + }); + let programmes = if needs_epg && !rule.channel_ids.is_empty() { + self.watched_programmes(rule, &playlist).await + } else { + Vec::new() + }; + let tipoffs: HashMap> = games + .iter() + .filter_map(|g| { + DartDateTime::parse(&g.tipoff_utc).map(|t| (g.game_id.clone(), t.to_utc())) + }) + .collect(); + + let mut created = 0; + for game in games { + let Some(&tipoff) = tipoffs.get(&game.game_id) else { + continue; + }; + let nearby = tipoffs + .iter() + .any(|(id, t)| *id != game.game_id && (*t - tipoff).num_minutes().abs() < 60); + let live = + find_live_broadcast(&game.home_tri, &game.away_tri, tipoff, nearby, &programmes); + let recording = game + .recording_id + .as_deref() + .and_then(|id| self.db.get_recording_by_id(id)); + let action = plan_action(rule, &game, tipoff, live.as_ref(), recording.as_ref(), now); + let mut next = game.clone(); + if let Some(l) = &live { + next.channel_id = Some(l.programme.channel_id.clone()); + next.channel_name = Some(l.programme.channel_label.clone()); + next.epg_title = Some(l.programme.title.clone()); + next.epg_start = Some(utc_iso(l.programme.start)); + next.epg_end = Some(utc_iso(l.programme.end)); + next.confidence = Some(l.confidence); + } + next.conflict = false; + match action { + Action::Status(s) => next.status = s.to_string(), + Action::Cancel => { + if let Some(rec) = &recording { + self.db.delete_recording(&rec.id); + } + next.recording_id = None; + next.status = if live.is_some() { + "confirmed" + } else { + "provisional" + } + .into(); + } + Action::Realign { start, end } => { + if let Some(rec) = &recording { + self.db.update_recording_window(&rec.id, start, end); + } + next.status = "scheduled".into(); + } + Action::Create { start, end } | Action::Recreate { start, end } => { + if let Some(rec) = &recording { + self.db.delete_recording(&rec.id); + next.recording_id = None; + } + let busy = overlapping(&self.db.get_pending_recordings(), start, end); + if self.scheduler.max_concurrent > 0 && busy >= self.scheduler.max_concurrent { + next.status = "confirmed".into(); + next.conflict = true; + } else if let Some(l) = &live { + let channel = &l.programme.channel_id; + match self.db.create_recording( + &rule.user_id, + channel, + &format!("/api/live/{channel}.ts"), + &recording_title(&game), + start, + end, + ) { + Ok(rec) => { + self.db.set_recording_source(&rec.id, &rule.id); + next.recording_id = Some(rec.id); + next.status = "scheduled".into(); + created += 1; + // Vignette prête pour la liste « À venir ». + let (http, art_game) = (self.http.clone(), next.clone()); + tokio::spawn(async move { + let dir = std::path::PathBuf::from(recordings_dir_path()); + nba_artwork::ensure_artwork(&http, &dir, &art_game).await; + }); + } + Err(e) => println!("[NBA] Programmation impossible : {e}"), + } + } + } + } + if next != game + && let Err(e) = self.db.update_nba_game_state(&next) + { + println!("[NBA] État du match {} non sauvegardé : {e}", game.game_id); + } + } + if created > 0 { + println!("[NBA] Règle {} : {created} match(s) programmé(s)", rule.id); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn t(s: &str) -> DateTime { + DartDateTime::parse(s).unwrap().to_utc() + } + + fn prog(channel: &str, title: &str, start: &str, end: &str) -> EpgProgramme { + EpgProgramme { + channel_id: channel.into(), + channel_label: format!("beIN SPORTS {channel}"), + title: title.into(), + description: String::new(), + start: t(start), + end: t(end), + is_live: false, + previously_shown: false, + } + } + + fn rule() -> AutoRule { + AutoRule { + id: "r".into(), + user_id: "u".into(), + kind: "nba".into(), + enabled: true, + default_decision: "auto".into(), + channel_ids: vec!["1".into(), "2".into()], + pad_before_min: 5, + pad_after_min: 45, + team_filter: vec![], + keep_days: None, + created_at: String::new(), + updated_at: String::new(), + } + } + + fn game(decision: &str) -> NbaGame { + NbaGame { + game_id: "g".into(), + rule_id: "r".into(), + home: "Los Angeles Lakers".into(), + away: "Boston Celtics".into(), + home_id: "13".into(), + away_id: "2".into(), + home_tri: "LAL".into(), + away_tri: "BOS".into(), + tipoff_utc: "2026-10-22T02:30:00.000Z".into(), + status: "provisional".into(), + decision: decision.into(), + channel_id: None, + channel_name: None, + epg_title: None, + epg_start: None, + epg_end: None, + recording_id: None, + confidence: None, + conflict: false, + updated_at: String::new(), + } + } + + fn rec(channel: &str, status: &str, start: &str, end: &str) -> Recording { + Recording { + id: "rec".into(), + user_id: "u".into(), + channel_id: channel.into(), + stream_url: String::new(), + title: "NBA".into(), + start_time: DartDateTime::parse(start).unwrap(), + end_time: DartDateTime::parse(end).unwrap(), + status: status.into(), + file_path: None, + error_reason: None, + source_rule_id: Some("r".into()), + created_at: DartDateTime::parse(start).unwrap(), + updated_at: DartDateTime::parse(start).unwrap(), + } + } + + const TIP: &str = "2026-10-22T02:30:00Z"; + + #[test] + fn le_direct_est_retenu_la_rediffusion_ignoree() { + let progs = vec![ + prog( + "1", + "NBA : Boston / LA Lakers", + "2026-10-22T02:15:00Z", + "2026-10-22T05:00:00Z", + ), + // Même match le lendemain midi : rediffusion, hors fenêtre. + prog( + "2", + "NBA : Boston / LA Lakers", + "2026-10-22T12:00:00Z", + "2026-10-22T14:30:00Z", + ), + ]; + let live = find_live_broadcast("LAL", "BOS", t(TIP), false, &progs).unwrap(); + assert_eq!(live.programme.channel_id, "1"); + assert!(live.confidence >= 90); + // Seule la rediffusion au guide : rien. + assert!(find_live_broadcast("LAL", "BOS", t(TIP), false, &progs[1..]).is_none()); + } + + #[test] + fn mots_de_rediffusion_et_drapeau_previously_shown() { + let mut p = prog( + "1", + "NBA : Celtics - Lakers (rediffusion)", + "2026-10-22T02:30:00Z", + "2026-10-22T05:00:00Z", + ); + assert!(find_live_broadcast("LAL", "BOS", t(TIP), false, &[p.clone()]).is_none()); + p.title = "NBA : Celtics - Lakers".into(); + p.previously_shown = true; + assert!(find_live_broadcast("LAL", "BOS", t(TIP), false, &[p]).is_none()); + } + + #[test] + fn autre_match_au_meme_moment_sur_une_autre_chaine() { + let progs = vec![ + prog( + "1", + "NBA : Warriors / Suns", + "2026-10-22T02:30:00Z", + "2026-10-22T05:00:00Z", + ), + prog( + "2", + "Basket-ball NBA : Celtics @ Lakers en direct", + "2026-10-22T02:25:00Z", + "2026-10-22T05:00:00Z", + ), + ]; + let live = find_live_broadcast("LAL", "BOS", t(TIP), true, &progs).unwrap(); + assert_eq!(live.programme.channel_id, "2"); + } + + #[test] + fn titre_generique_seulement_si_pas_d_ambiguite() { + let progs = vec![prog( + "1", + "Basket-ball : NBA", + "2026-10-22T02:30:00Z", + "2026-10-22T05:00:00Z", + )]; + assert!(find_live_broadcast("LAL", "BOS", t(TIP), false, &progs).is_some()); + assert!(find_live_broadcast("LAL", "BOS", t(TIP), true, &progs).is_none()); + } + + #[test] + fn creation_avec_marges_et_duree_minimale() { + let b = LiveBroadcast { + programme: prog("1", "NBA", "2026-10-22T02:15:00Z", "2026-10-22T04:00:00Z"), + confidence: 90, + }; + let now = t("2026-10-21T10:00:00Z"); + let action = plan_action(&rule(), &game("auto"), t(TIP), Some(&b), None, now); + // Début : 02:15 - 5 min ; fin : max(04:00, 02:30 + 2h30) + 45 min. + assert_eq!( + action, + Action::Create { + start: t("2026-10-22T02:10:00Z"), + end: t("2026-10-22T05:45:00Z") + } + ); + } + + #[test] + fn refus_supprime_l_enregistrement_planifie() { + let r = rec( + "1", + "scheduled", + "2026-10-22T02:10:00Z", + "2026-10-22T05:45:00Z", + ); + let now = t("2026-10-21T10:00:00Z"); + assert_eq!( + plan_action(&rule(), &game("refused"), t(TIP), None, Some(&r), now), + Action::Cancel + ); + // Mais un enregistrement en cours n'est pas coupé par la réconciliation. + let r = rec( + "1", + "recording", + "2026-10-22T02:10:00Z", + "2026-10-22T05:45:00Z", + ); + assert_eq!( + plan_action(&rule(), &game("refused"), t(TIP), None, Some(&r), now), + Action::Status("scheduled") + ); + } + + #[test] + fn mode_validation_et_filtre_d_equipes() { + let b = LiveBroadcast { + programme: prog("1", "NBA", "2026-10-22T02:30:00Z", "2026-10-22T05:00:00Z"), + confidence: 90, + }; + let now = t("2026-10-21T10:00:00Z"); + let mut ask = rule(); + ask.default_decision = "ask".into(); + assert_eq!( + plan_action(&ask, &game("auto"), t(TIP), Some(&b), None, now), + Action::Status("confirmed") + ); + assert!(matches!( + plan_action(&ask, &game("accepted"), t(TIP), Some(&b), None, now), + Action::Create { .. } + )); + let mut only_knicks = rule(); + only_knicks.team_filter = vec!["Knicks".into()]; + assert!(!wants(&only_knicks, &game("auto"))); + only_knicks.team_filter = vec!["celtics".into()]; + assert!(wants(&only_knicks, &game("auto"))); + } + + #[test] + fn realignement_et_changement_de_chaine() { + let now = t("2026-10-21T10:00:00Z"); + let b = LiveBroadcast { + programme: prog("1", "NBA", "2026-10-22T02:15:00Z", "2026-10-22T05:00:00Z"), + confidence: 90, + }; + let same = rec( + "1", + "scheduled", + "2026-10-22T02:10:00Z", + "2026-10-22T05:45:00Z", + ); + assert_eq!( + plan_action(&rule(), &game("auto"), t(TIP), Some(&b), Some(&same), now), + Action::Status("scheduled") + ); + let moved = rec( + "1", + "scheduled", + "2026-10-22T01:00:00Z", + "2026-10-22T05:45:00Z", + ); + assert!(matches!( + plan_action(&rule(), &game("auto"), t(TIP), Some(&b), Some(&moved), now), + Action::Realign { .. } + )); + let other = rec( + "2", + "scheduled", + "2026-10-22T02:10:00Z", + "2026-10-22T05:45:00Z", + ); + assert!(matches!( + plan_action(&rule(), &game("auto"), t(TIP), Some(&b), Some(&other), now), + Action::Recreate { .. } + )); + } + + #[test] + fn match_passe_sans_diffusion() { + let now = t("2026-10-22T08:00:00Z"); + assert_eq!( + plan_action(&rule(), &game("auto"), t(TIP), None, None, now), + Action::Status("not_on_bein") + ); + } + + #[test] + fn titre_d_enregistrement() { + assert_eq!(recording_title(&game("auto")), "NBA - Celtics @ Lakers"); + } +} diff --git a/server/src/services/nba_schedule.rs b/server/src/services/nba_schedule.rs new file mode 100644 index 0000000..8281126 --- /dev/null +++ b/server/src/services/nba_schedule.rs @@ -0,0 +1,167 @@ +//! Calendrier NBA (API publique ESPN « scoreboard »). +//! +//! POURQUOI : l'EPG ne voit qu'environ 48 h devant. Le calendrier officiel +//! donne les matchs des semaines à l'avance, avec leur coup d'envoi exact : +//! c'est lui qui permet d'afficher les futurs enregistrements et de +//! distinguer la diffusion en direct (qui colle au coup d'envoi) des +//! rediffusions. Le CDN de nba.com refuse les requêtes serveur (403), ESPN +//! les accepte et fournit en plus abréviations, couleurs et logos. + +use crate::models::ScheduledGame; +use crate::services::nba_teams; +use chrono::{DateTime, Duration, NaiveDate, Utc}; +use serde_json::Value; + +const SCOREBOARD: &str = "https://site.api.espn.com/apis/site/v2/sports/basketball/nba/scoreboard"; + +/// Date ESPN (`2025-11-06T00:00Z`, sans secondes) ou RFC 3339 complète. +fn parse_espn_date(raw: &str) -> Option> { + if let Ok(dt) = DateTime::parse_from_rfc3339(raw) { + return Some(dt.with_timezone(&Utc)); + } + chrono::NaiveDateTime::parse_from_str(raw, "%Y-%m-%dT%H:%MZ") + .ok() + .map(|n| n.and_utc()) +} + +/// Extrait les matchs d'une réponse « scoreboard ». Les événements +/// incomplets (équipe inconnue, date illisible) sont ignorés. +pub fn parse_scoreboard(json: &Value) -> Vec { + let Some(events) = json.get("events").and_then(Value::as_array) else { + return Vec::new(); + }; + events + .iter() + .filter_map(|event| { + let game_id = event.get("id")?.as_str()?.to_string(); + let date = parse_espn_date(event.get("date")?.as_str()?)?; + let competitors = event + .get("competitions")? + .get(0)? + .get("competitors")? + .as_array()?; + let side = |which: &str| { + competitors + .iter() + .find(|c| c.get("homeAway").and_then(Value::as_str) == Some(which)) + .and_then(|c| c.get("team")) + }; + let (home, away) = (side("home")?, side("away")?); + let text = |team: &Value, key: &str| { + team.get(key) + .and_then(Value::as_str) + .unwrap_or_default() + .to_string() + }; + let home_abbr = text(home, "abbreviation"); + let away_abbr = text(away, "abbreviation"); + // Normalise vers la table locale (noms complets cohérents) ; + // une abréviation inconnue (match d'exhibition) garde le texte ESPN. + let name = |team: &Value, abbr: &str| { + nba_teams::by_abbr(abbr) + .map(|t| t.full_name()) + .unwrap_or_else(|| text(team, "displayName")) + }; + Some(ScheduledGame { + game_id, + home: name(home, &home_abbr), + away: name(away, &away_abbr), + home_id: text(home, "id"), + away_id: text(away, "id"), + home_tri: home_abbr, + away_tri: away_abbr, + tipoff_utc: date, + }) + }) + .collect() +} + +/// Matchs des journées ESPN `[first, first + days)`. Une journée en échec +/// est sautée : mieux vaut un calendrier partiel que rien. +pub async fn fetch_days( + client: &reqwest::Client, + first: NaiveDate, + days: i64, +) -> Vec { + let mut games = Vec::new(); + for offset in 0..days { + let day = first + Duration::days(offset); + let url = format!("{SCOREBOARD}?dates={}", day.format("%Y%m%d")); + let response = client + .get(&url) + .header("User-Agent", "Mozilla/5.0 (XtremFlow)") + .timeout(std::time::Duration::from_secs(20)) + .send() + .await; + match response { + Ok(r) if r.status().is_success() => match r + .bytes() + .await + .map_err(|e| e.to_string()) + .and_then(|b| serde_json::from_slice::(&b).map_err(|e| e.to_string())) + { + Ok(json) => games.extend(parse_scoreboard(&json)), + Err(e) => println!("[NBA] Calendrier {day} illisible: {e}"), + }, + Ok(r) => println!("[NBA] Calendrier {day}: HTTP {}", r.status()), + Err(e) => println!("[NBA] Calendrier {day} injoignable: {e}"), + } + } + games.sort_by_key(|g| g.tipoff_utc); + games.dedup_by(|a, b| a.game_id == b.game_id); + games +} + +#[cfg(test)] +mod tests { + use super::*; + + const SAMPLE: &str = r#"{ + "events": [ + { + "id": "401810026", + "date": "2025-11-06T00:00Z", + "name": "Philadelphia 76ers at Cleveland Cavaliers", + "competitions": [{ + "competitors": [ + {"homeAway": "home", "team": {"id": "5", "abbreviation": "CLE", "displayName": "Cleveland Cavaliers"}}, + {"homeAway": "away", "team": {"id": "20", "abbreviation": "PHI", "displayName": "Philadelphia 76ers"}} + ] + }] + }, + { + "id": "401810027", + "date": "2025-11-06T03:00Z", + "competitions": [{ + "competitors": [ + {"homeAway": "home", "team": {"id": "9", "abbreviation": "GS", "displayName": "Golden State Warriors"}}, + {"homeAway": "away", "team": {"id": "99", "abbreviation": "WLD", "displayName": "World Team"}} + ] + }] + }, + {"id": "broken", "date": "n/a", "competitions": []} + ] + }"#; + + #[test] + fn lit_le_scoreboard_espn() { + let json: Value = serde_json::from_str(SAMPLE).unwrap(); + let games = parse_scoreboard(&json); + assert_eq!(games.len(), 2); + let g = &games[0]; + assert_eq!(g.game_id, "401810026"); + assert_eq!(g.home, "Cleveland Cavaliers"); + assert_eq!(g.away, "Philadelphia 76ers"); + assert_eq!((g.home_tri.as_str(), g.away_tri.as_str()), ("CLE", "PHI")); + assert_eq!(g.tipoff_utc.to_rfc3339(), "2025-11-06T00:00:00+00:00"); + // Équipe inconnue de la table locale : nom ESPN conservé. + assert_eq!(games[1].away, "World Team"); + } + + #[test] + fn dates_espn() { + assert!(parse_espn_date("2026-10-21T01:30Z").is_some()); + assert!(parse_espn_date("2026-10-21T01:30:00Z").is_some()); + assert!(parse_espn_date("demain").is_none()); + } +} diff --git a/server/src/services/nba_teams.rs b/server/src/services/nba_teams.rs new file mode 100644 index 0000000..954b0bb --- /dev/null +++ b/server/src/services/nba_teams.rs @@ -0,0 +1,302 @@ +//! Les 30 franchises NBA et la reconnaissance de leurs noms dans les titres +//! et descriptions EPG. +//! +//! POURQUOI : l'EPG beIN Sports écrit les affiches de façons très variées +//! (« NBA : Boston / Detroit », « Basket-ball - Celtics - Pistons », +//! « LA Lakers @ Golden State »…). Un match du calendrier ne peut être +//! rattaché à un programme que si l'on retrouve ses deux équipes dans ce +//! texte libre. + +/// Une franchise. `abbr` est l'abréviation ESPN (source du calendrier et des +/// logos), qui diffère parfois du tricode NBA (GS, NY, SA, NO, UTAH, WSH). +#[derive(Debug, PartialEq, Eq)] +pub struct Team { + pub abbr: &'static str, + /// Ville telle qu'affichée (« Los Angeles »). + pub city: &'static str, + /// Surnom (« Lakers »). + pub nickname: &'static str, + /// Couleur principale (hex sans #) pour les vignettes. + pub color: &'static str, + /// Variantes de surnom acceptées (en plus de `nickname`). + nick_aliases: &'static [&'static str], + /// Formes de ville sans ambiguïté qui suffisent à identifier l'équipe. + city_aliases: &'static [&'static str], +} + +impl Team { + pub fn full_name(&self) -> String { + format!("{} {}", self.city, self.nickname) + } + + /// URL du logo PNG (500 px, fond transparent) sur le CDN ESPN. + pub fn logo_url(&self) -> String { + format!( + "https://a.espncdn.com/i/teamlogos/nba/500/{}.png", + self.abbr.to_ascii_lowercase() + ) + } +} + +macro_rules! team { + ($abbr:literal, $city:literal, $nick:literal, $color:literal, [$($n:literal),*], [$($c:literal),*]) => { + Team { + abbr: $abbr, + city: $city, + nickname: $nick, + color: $color, + nick_aliases: &[$($n),*], + city_aliases: &[$($c),*], + } + }; +} + +/// « Los Angeles » est volontairement absent des alias de ville : il désigne +/// deux équipes. Seules les formes « LA Lakers », « L.A. Clippers »… (qui +/// contiennent le surnom) les distinguent. +pub static TEAMS: [Team; 30] = [ + team!("ATL", "Atlanta", "Hawks", "C8102E", [], ["atlanta"]), + team!("BOS", "Boston", "Celtics", "007A33", [], ["boston"]), + team!("BKN", "Brooklyn", "Nets", "000000", [], ["brooklyn"]), + team!("CHA", "Charlotte", "Hornets", "1D1160", [], ["charlotte"]), + team!("CHI", "Chicago", "Bulls", "CE1141", [], ["chicago"]), + team!( + "CLE", + "Cleveland", + "Cavaliers", + "860038", + ["cavs"], + ["cleveland"] + ), + team!("DAL", "Dallas", "Mavericks", "00538C", ["mavs"], ["dallas"]), + team!("DEN", "Denver", "Nuggets", "0E2240", [], ["denver"]), + team!("DET", "Detroit", "Pistons", "C8102E", [], ["detroit"]), + team!( + "GS", + "Golden State", + "Warriors", + "1D428A", + ["dubs"], + ["golden state"] + ), + team!("HOU", "Houston", "Rockets", "CE1141", [], ["houston"]), + team!("IND", "Indiana", "Pacers", "002D62", [], ["indiana"]), + team!("LAC", "Los Angeles", "Clippers", "C8102E", [], []), + team!("LAL", "Los Angeles", "Lakers", "552583", [], []), + team!( + "MEM", + "Memphis", + "Grizzlies", + "5D76A9", + ["grizz"], + ["memphis"] + ), + team!("MIA", "Miami", "Heat", "98002E", [], ["miami"]), + team!("MIL", "Milwaukee", "Bucks", "00471B", [], ["milwaukee"]), + team!( + "MIN", + "Minnesota", + "Timberwolves", + "0C2340", + ["wolves", "t wolves"], + ["minnesota"] + ), + team!( + "NO", + "New Orleans", + "Pelicans", + "0C2340", + ["pels"], + ["new orleans", "nouvelle orleans"] + ), + team!("NY", "New York", "Knicks", "006BB6", [], ["new york"]), + team!( + "OKC", + "Oklahoma City", + "Thunder", + "007AC1", + [], + ["oklahoma city", "oklahoma", "okc"] + ), + team!("ORL", "Orlando", "Magic", "0077C0", [], ["orlando"]), + team!( + "PHI", + "Philadelphia", + "76ers", + "006BB6", + ["sixers", "76 ers"], + ["philadelphia", "philadelphie", "philly"] + ), + team!("PHX", "Phoenix", "Suns", "1D1160", [], ["phoenix"]), + team!( + "POR", + "Portland", + "Trail Blazers", + "E03A3E", + ["blazers", "trailblazers"], + ["portland"] + ), + team!("SAC", "Sacramento", "Kings", "5A2D81", [], ["sacramento"]), + team!("SA", "San Antonio", "Spurs", "000000", [], ["san antonio"]), + team!("TOR", "Toronto", "Raptors", "CE1141", [], ["toronto"]), + team!("UTAH", "Utah", "Jazz", "002B5C", [], ["utah"]), + team!("WSH", "Washington", "Wizards", "002B5C", [], ["washington"]), +]; + +/// Équipe par abréviation ESPN (insensible à la casse). Accepte aussi les +/// tricodes NBA officiels qui diffèrent (GSW, NYK, SAS, NOP, UTA, WAS, BRK). +pub fn by_abbr(abbr: &str) -> Option<&'static Team> { + let up = abbr.trim().to_ascii_uppercase(); + let espn = match up.as_str() { + "GSW" => "GS", + "NYK" => "NY", + "SAS" => "SA", + "NOP" => "NO", + "UTA" => "UTAH", + "WAS" => "WSH", + "BRK" => "BKN", + "PHO" => "PHX", + other => other, + }; + TEAMS.iter().find(|t| t.abbr == espn) +} + +/// Minuscules, sans accents, tout ce qui n'est pas alphanumérique devient un +/// espace unique : « L.A. Lakers » → « l a lakers ». +pub fn normalize(text: &str) -> String { + let mut out = String::with_capacity(text.len()); + let mut last_space = true; + for ch in text.chars().flat_map(char::to_lowercase) { + let ch = match ch { + 'à' | 'â' | 'ä' | 'á' | 'ã' => 'a', + 'é' | 'è' | 'ê' | 'ë' => 'e', + 'î' | 'ï' | 'í' => 'i', + 'ô' | 'ö' | 'ó' => 'o', + 'ù' | 'û' | 'ü' | 'ú' => 'u', + 'ç' => 'c', + 'ñ' => 'n', + c => c, + }; + if ch.is_ascii_alphanumeric() { + out.push(ch); + last_space = false; + } else if !last_space { + out.push(' '); + last_space = true; + } + } + out.trim_end().to_string() +} + +/// Position (en octets, dans le texte normalisé) de `needle` comme suite de +/// mots entiers. +fn find_words(hay: &str, needle: &str) -> Option { + let mut from = 0; + while let Some(i) = hay[from..].find(needle) { + let start = from + i; + let end = start + needle.len(); + let left_ok = start == 0 || hay.as_bytes()[start - 1] == b' '; + let right_ok = end == hay.len() || hay.as_bytes()[end] == b' '; + if left_ok && right_ok { + return Some(start); + } + from = start + 1; + } + None +} + +/// Équipes citées dans un texte, dans l'ordre d'apparition, sans doublon. +/// +/// Un surnom (« Lakers », « Sixers ») suffit toujours ; une ville seulement +/// quand elle est sans ambiguïté (pas « Los Angeles »). +pub fn extract_teams(text: &str) -> Vec<&'static Team> { + let hay = normalize(text); + let mut found: Vec<(usize, &'static Team)> = Vec::new(); + for team in &TEAMS { + let nick = normalize(team.nickname); + let positions = std::iter::once(nick.as_str()) + .chain(team.nick_aliases.iter().copied()) + .chain(team.city_aliases.iter().copied()) + .filter_map(|alias| find_words(&hay, alias)); + if let Some(pos) = positions.min() { + found.push((pos, team)); + } + } + found.sort_by_key(|(pos, _)| *pos); + found.into_iter().map(|(_, t)| t).collect() +} + +/// Le texte mentionne-t-il la NBA ou le basket ? +pub fn mentions_nba(text: &str) -> bool { + let hay = normalize(text); + ["nba", "basket", "basketball", "basket ball"] + .iter() + .any(|w| find_words(&hay, w).is_some()) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn abbrs(text: &str) -> Vec<&'static str> { + extract_teams(text).iter().map(|t| t.abbr).collect() + } + + #[test] + fn trente_franchises_distinctes() { + let mut set: Vec<_> = TEAMS.iter().map(|t| t.abbr).collect(); + set.sort(); + set.dedup(); + assert_eq!(set.len(), 30); + } + + #[test] + fn formes_courantes_de_l_epg() { + assert_eq!(abbrs("NBA : Boston / Detroit"), ["BOS", "DET"]); + assert_eq!(abbrs("Basket-ball - Celtics - Pistons"), ["BOS", "DET"]); + assert_eq!( + abbrs("NBA - L.A. Lakers @ Golden State Warriors"), + ["LAL", "GS"] + ); + assert_eq!(abbrs("LA Clippers vs Los Angeles Lakers"), ["LAC", "LAL"]); + assert_eq!(abbrs("Philadelphie 76ers - New York Knicks"), ["PHI", "NY"]); + assert_eq!(abbrs("Sixers / Trail Blazers"), ["PHI", "POR"]); + assert_eq!( + abbrs("Oklahoma City Thunder at San Antonio Spurs"), + ["OKC", "SA"] + ); + } + + #[test] + fn los_angeles_seul_est_ambigu() { + assert!(abbrs("Match à Los Angeles").is_empty()); + } + + #[test] + fn pas_de_faux_positif_dans_un_mot() { + // « heat » dans « heathrow », « magic » dans « magical » : refusés. + assert!(abbrs("Heathrow magical").is_empty()); + // « Kings » de Sacramento reconnu seul, mais pas dans « Vikings ». + assert_eq!(abbrs("Vikings"), Vec::<&str>::new()); + assert_eq!(abbrs("Kings"), ["SAC"]); + } + + #[test] + fn tricodes_nba_et_espn() { + assert_eq!(by_abbr("GSW").unwrap().abbr, "GS"); + assert_eq!(by_abbr("utah").unwrap().nickname, "Jazz"); + assert_eq!(by_abbr("LAL").unwrap().full_name(), "Los Angeles Lakers"); + assert!(by_abbr("XXX").is_none()); + assert_eq!( + by_abbr("NY").unwrap().logo_url(), + "https://a.espncdn.com/i/teamlogos/nba/500/ny.png" + ); + } + + #[test] + fn mention_nba() { + assert!(mentions_nba("Basket-ball : NBA")); + assert!(mentions_nba("NBA Extra")); + assert!(!mentions_nba("Ligue 1 : PSG / OM")); + } +} diff --git a/server/src/services/recording_scheduler.rs b/server/src/services/recording_scheduler.rs index c3eaecd..66c8f21 100644 --- a/server/src/services/recording_scheduler.rs +++ b/server/src/services/recording_scheduler.rs @@ -919,6 +919,23 @@ impl RecordingScheduler { error_reason, false, ); + // Matchs NBA automatiques : vignettes Plex/Jellyfin, .nfo et pochette + // intégrée au MKV. + if status == "completed" + && active.recording.source_rule_id.is_some() + && file_has_data(Some(&active.file_path)) + && let Some(game) = self.db.get_nba_game_by_recording(&active.recording.id) + { + crate::services::nba_artwork::finish_recording( + &self.http, + Path::new(&recordings_dir_path()), + &game, + Path::new(&active.file_path), + &active.recording.title, + &resolve_ffmpeg_path(), + ) + .await; + } } /// Recolle les parties issues des relances dans le fichier principal. @@ -1072,6 +1089,16 @@ impl RecordingScheduler { } paths.push(part); } + // Annexes Plex/Jellyfin des matchs NBA (vignettes, .nfo, remux en cours). + for extra in crate::services::nba_artwork::sidecar_paths(Path::new(file_path)) { + if extra.exists() { + paths.push(extra.to_string_lossy().into_owned()); + } + } + let remux = Path::new(file_path).with_extension("cover.mkv"); + if remux.exists() { + paths.push(remux.to_string_lossy().into_owned()); + } paths } @@ -1140,6 +1167,9 @@ impl RecordingScheduler { /// Nom de fichier unique et sûr. Le fragment d'id garantit l'unicité : deux /// utilisateurs enregistrant le même programme s'écrasaient (FFmpeg -y). fn file_name_for(recording: &Recording) -> String { + if recording.source_rule_id.is_some() { + return readable_file_name(recording); + } // Un caractère hors BMP comptait double (UTF-16) dans l'ancien serveur : // même nom de fichier, pour que la reprise retrouve ses parties. let mut safe_title = String::new(); @@ -1156,6 +1186,26 @@ fn file_name_for(recording: &Recording) -> String { format!("{safe_title}_{date}_{fragment}.mkv") } +/// Nom lisible pour Plex/Jellyfin (enregistrements automatiques) : +/// « NBA - Celtics @ Lakers (2026-10-22) 01234567.mkv ». Seuls les +/// caractères interdits sous Windows/Linux sont remplacés ; le fragment d'id +/// garde l'unicité. +fn readable_file_name(recording: &Recording) -> String { + let title: String = recording + .title + .chars() + .map(|c| match c { + '/' | '\\' | ':' | '*' | '?' | '"' | '<' | '>' | '|' => '-', + c if c.is_control() => ' ', + c => c, + }) + .collect(); + let date = DartDateTime::local_from_utc(recording.start_utc()).to_iso(); + let date = date.get(..10).unwrap_or(""); + let fragment: String = recording.id.chars().take(8).collect(); + format!("{} ({date}) {fragment}.mkv", title.trim()) +} + /// Chemin de la n-ième partie (la partie 1 étant le fichier principal). fn part_path(file_path: &str, attempt: u32) -> String { let without_ext = Path::new(file_path).with_extension(""); @@ -1221,11 +1271,23 @@ mod tests { status: "scheduled".into(), file_path: None, error_reason: None, + source_rule_id: None, created_at: start, updated_at: start, } } + #[test] + fn nom_lisible_pour_les_enregistrements_automatiques() { + let mut r = recording("NBA - Celtics @ Lakers"); + r.source_rule_id = Some("rule".into()); + let name = file_name_for(&r); + assert!(name.starts_with("NBA - Celtics @ Lakers ("), "{name}"); + assert!(name.ends_with(") 01234567.mkv"), "{name}"); + r.title = "A/B: C?".into(); + assert!(file_name_for(&r).starts_with("A-B- C- (")); + } + #[test] fn nom_de_fichier_identique_a_l_ancien_serveur() { assert_eq!( diff --git a/server/src/services/xmltv.rs b/server/src/services/xmltv.rs index bdafd96..90609db 100644 --- a/server/src/services/xmltv.rs +++ b/server/src/services/xmltv.rs @@ -31,16 +31,32 @@ use crate::util::log_redactor::redact_url; pub type XmltvIndex = HashMap>>; /// Un programme du guide, horaires en UTC. -#[derive(Clone, Debug, PartialEq)] +/// +/// Les indicateurs (`is_new`, `is_live`, `is_premiere`, `previously_shown`) +/// reprennent les éléments vides XMLTV ``, ``, `` et +/// `` : ils servent à distinguer un direct d'une +/// rediffusion. Leur absence ne prouve rien — beaucoup de dumps ne les +/// renseignent jamais. +#[derive(Clone, Debug, Default, PartialEq)] pub struct XmltvProgramme { pub title: String, pub description: String, pub start: DateTime, pub stop: DateTime, + /// `` (souvent l'affiche du match ou le titre d'épisode). + pub sub_title: Option, + /// ``, dans l'ordre du dump (l'élément peut se répéter). + pub categories: Vec, + pub is_new: bool, + pub is_live: bool, + pub is_premiere: bool, + pub previously_shown: bool, } impl XmltvProgramme { - /// Forme JSON servie par l'API EPG (mêmes clés que le guide du panneau). + /// Forme JSON servie par l'API EPG (mêmes clés que le guide du panneau, + /// plus les métadonnées XMLTV — absentes du guide du panneau, donc à lire + /// comme facultatives côté client). pub fn to_json(&self, channel_id: &str) -> serde_json::Value { serde_json::json!({ "title": self.title, @@ -48,10 +64,31 @@ impl XmltvProgramme { "start": utc_iso(self.start), "end": utc_iso(self.stop), "channel_id": channel_id, + "sub_title": self.sub_title, + "categories": self.categories, + "is_new": self.is_new, + "is_live": self.is_live, + "is_premiere": self.is_premiere, + "previously_shown": self.previously_shown, }) } } +/// Horizon d'indexation par défaut, en heures. +const DEFAULT_HORIZON_HOURS: u64 = 48; +/// Bornes de `EPG_HORIZON_HOURS` : en dessous de 12 h le guide se vide en +/// cours de soirée, au-delà de 14 jours aucun dump ne publie de programmes. +const MIN_HORIZON_HOURS: u64 = 12; +const MAX_HORIZON_HOURS: u64 = 336; + +/// Horizon en heures lu depuis la valeur brute de `EPG_HORIZON_HOURS` : +/// défaut si absente ou illisible, ramenée dans les bornes sinon. +fn horizon_hours(raw: Option<&str>) -> u64 { + raw.and_then(|v| v.trim().parse::().ok()) + .unwrap_or(DEFAULT_HORIZON_HOURS) + .clamp(MIN_HORIZON_HOURS, MAX_HORIZON_HOURS) +} + /// Réglages du service. #[derive(Clone, Debug)] pub struct XmltvConfig { @@ -90,12 +127,16 @@ pub struct XmltvConfig { impl XmltvConfig { /// Réglages par défaut : rafraîchissement 6 h, rétention 6 h, horizon /// 48 h, recul 15 min après échec, téléchargement plafonné à 5 min. + /// + /// L'horizon se règle avec `EPG_HORIZON_HOURS` (12 à 336 h) : voir plus + /// loin les matchs à enregistrer coûte de la mémoire d'index. pub fn new(source_urls: Vec) -> Self { + let horizon = horizon_hours(std::env::var("EPG_HORIZON_HOURS").ok().as_deref()); Self { source_urls, refresh_interval: Duration::from_secs(6 * 3600), retention: Duration::from_secs(6 * 3600), - horizon: Duration::from_secs(48 * 3600), + horizon: Duration::from_secs(horizon * 3600), retry_backoff: Duration::from_secs(15 * 60), download_timeout: Duration::from_secs(5 * 60), } @@ -324,10 +365,9 @@ impl XmltvEpgService { .map(|hits| { hits.iter() .map(|p| XmltvProgramme { - title: p.title.clone(), - description: p.description.clone(), start: p.start + hour, stop: p.stop + hour, + ..p.clone() }) .collect() }) @@ -739,9 +779,23 @@ fn decode_and_parse( enum TextTag { Title, Desc, + SubTitle, + Category, DisplayName, } +/// Métadonnées XMLTV d'un `` en cours de lecture. +#[derive(Default)] +struct ProgrammeMeta { + sub_title: String, + category: String, + categories: Vec, + is_new: bool, + is_live: bool, + is_premiere: bool, + previously_shown: bool, +} + /// Valeur d'un attribut (entités décodées), ou chaîne vide s'il est absent. fn attr(event: &BytesStart<'_>, name: &[u8]) -> String { let mut attributes = event.attributes(); @@ -848,6 +902,7 @@ fn parse(xml: &str, retention: Duration, horizon: Duration) -> Result = None; let mut title = String::new(); let mut desc = String::new(); + let mut meta = ProgrammeMeta::default(); let mut display_name = String::new(); // Les programmes d'une chaîne se suivent : mémoriser la dernière clé // évite de renormaliser le même identifiant des milliers de fois. @@ -882,10 +937,23 @@ fn parse(xml: &str, retention: Duration, horizon: Duration) -> Result current_tag = Some(TextTag::Title), b"desc" => current_tag = Some(TextTag::Desc), + b"sub-title" => current_tag = Some(TextTag::SubTitle), + b"category" => { + meta.category.clear(); + current_tag = Some(TextTag::Category); + } b"display-name" => current_tag = Some(TextTag::DisplayName), + // Indicateurs : vides en principe (``), mais + // `` porte parfois un texte et `` + // des attributs — seule la présence compte. + b"new" => meta.is_new = true, + b"live" => meta.is_live = true, + b"premiere" => meta.is_premiere = true, + b"previously-shown" => meta.previously_shown = true, _ => {} } if matches!(event, Event::Empty(_)) { @@ -899,6 +967,8 @@ fn parse(xml: &str, retention: Duration, horizon: Duration) -> Result title.push_str(&text), TextTag::Desc => desc.push_str(&text), + TextTag::SubTitle => meta.sub_title.push_str(&text), + TextTag::Category => meta.category.push_str(&text), TextTag::DisplayName => display_name.push_str(&text), } } @@ -909,6 +979,8 @@ fn parse(xml: &str, retention: Duration, horizon: Duration) -> Result title.push_str(&text), TextTag::Desc => desc.push_str(&text), + TextTag::SubTitle => meta.sub_title.push_str(&text), + TextTag::Category => meta.category.push_str(&text), TextTag::DisplayName => display_name.push_str(&text), } } @@ -918,6 +990,13 @@ fn parse(xml: &str, retention: Duration, horizon: Duration) -> Result { display_names.push(std::mem::take(&mut display_name)); } + b"category" => { + let category = dart_trim(&meta.category); + if !category.is_empty() { + meta.categories.push(category.to_string()); + } + meta.category.clear(); + } b"channel" => { let id = XmltvEpgService::normalize_key(channel_id.as_deref()); for raw in &display_names { @@ -954,11 +1033,20 @@ fn parse(xml: &str, retention: Duration, horizon: Duration) -> Result`), avec + /// attributs et texte là où certains dumps en mettent. + fn metadata_fixture(start: DateTime) -> String { + let s = |h: i64| stamp(start + TimeDelta::hours(h)); + format!( + r#" + + beIN SPORTS 1 + + NBA + Los Angeles Lakers - Boston Celtics + Saison régulière + Sport + Basketball + + + + + + + NBA + + Sport & Basket + + + Première diffusion + + + + Rediffusion + + + + +"#, + s(0), + s(2), + s(2), + s(4), + s(4), + s(6) + ) + } + + fn bein_programmes(start: DateTime) -> Vec { + let index = offline_service().parse_for_test(&metadata_fixture(start)); + let key = Svc::normalize_key(Some("beINSports1.fr")); + index.get(&key).expect("chaîne indexée").as_ref().clone() + } + + #[test] + fn lit_sous_titre_categories_et_indicateurs_en_elements_vides() { + let start = now_s() + TimeDelta::hours(1); + let programmes = bein_programmes(start); + assert_eq!(programmes.len(), 3); + let live = &programmes[0]; + assert_eq!(live.title, "NBA"); + assert_eq!(live.description, "Saison régulière"); + assert_eq!( + live.sub_title.as_deref(), + Some("Los Angeles Lakers - Boston Celtics") + ); + // Catégorie vide écartée, espaces retirés, ordre conservé. + assert_eq!(live.categories, vec!["Sport", "Basketball"]); + assert!(live.is_new); + assert!(live.is_live); + assert!(live.is_premiere); + assert!(!live.previously_shown); + } + + #[test] + fn lit_les_indicateurs_en_paires_ouvrante_fermante() { + let start = now_s() + TimeDelta::hours(1); + let programmes = bein_programmes(start); + let paired = &programmes[1]; + assert_eq!(paired.sub_title.as_deref(), Some("Lakers & Celtics")); + assert_eq!(paired.categories, vec!["Sport & Basket"]); + assert!(paired.is_new); + assert!(paired.is_live); + assert!(paired.is_premiere); + assert!(paired.previously_shown); + // Le texte de ne déborde ni sur le titre ni sur la + // description. + assert_eq!(paired.title, "NBA"); + assert_eq!(paired.description, ""); + } + + #[test] + fn indicateurs_remis_a_zero_entre_programmes() { + let start = now_s() + TimeDelta::hours(1); + let programmes = bein_programmes(start); + let rerun = &programmes[2]; + assert_eq!(rerun.title, "Rediffusion"); + assert_eq!(rerun.sub_title, None); + assert!(rerun.categories.is_empty()); + assert!(!rerun.is_new); + assert!(!rerun.is_live); + assert!(!rerun.is_premiere); + assert!(rerun.previously_shown); + } + + #[tokio::test] + async fn decalage_plus_un_garde_les_metadonnees() { + let start = now_s() + TimeDelta::hours(1); + let (svc, _) = service_with(metadata_fixture(start)).await; + let shifted = svc.programmes_for(None, Some("beIN SPORTS 1 +1")).await; + assert_eq!(shifted.len(), 3); + assert_eq!(shifted[0].start, start + TimeDelta::hours(1)); + assert!(shifted[0].is_live); + assert_eq!(shifted[0].categories, vec!["Sport", "Basketball"]); + } + // --- journalisation (xmltv_epg_log_redaction_test.dart) --- /// URL du dump `xmltv.php` d'un panneau Xtream : les identifiants de diff --git a/server/src/state.rs b/server/src/state.rs index ea87544..869491e 100644 --- a/server/src/state.rs +++ b/server/src/state.rs @@ -9,6 +9,7 @@ use crate::models::PlaylistConfig; use crate::server::RateLimiter; use crate::services::cleanup::CleanupService; use crate::services::logo_catalog::LogoCatalog; +use crate::services::nba_autorec::NbaAutoRecorder; use crate::services::recording_scheduler::RecordingScheduler; use std::collections::HashMap; use std::sync::Arc; @@ -23,6 +24,7 @@ pub struct AppState { /// Client HTTP sans suivi des redirections (revalidées à la main). pub http_manual: reqwest::Client, pub scheduler: Arc, + pub nba: Arc, pub streaming: Arc, pub proxy: ProxyHandler, pub epg: EpgApi,