Enregistrements : auto-enregistrement des matchs NBA en direct sur beIN Sports FR

Le calendrier NBA (API ESPN, 21 jours) annonce les matchs à l'avance ;
l'EPG des beIN Sports françaises confirme la chaîne ~48 h avant. Seule la
diffusion qui démarre avec le coup d'envoi est retenue : les rediffusions
sont ignorées, sans dépendre des titres. Le CDN nba.com refuse les
requêtes serveur (403), d'où ESPN.

- Serveur : tables auto_rules / nba_games (migration v3 : source_rule_id),
  réconciliation toutes les 30 min, marges, filtre d'équipes, conflits de
  créneaux, rétention, API /api/auto-rules (oui/non par match).
- Détection des beIN FR (hors MENA, TR, ES…), meilleure qualité par chaîne.
- XMLTV : sous-titre, catégories, drapeaux live/new/premiere/
  previously-shown, horizon EPG_HORIZON_HOURS.
- Plex/Jellyfin : vignette et affiche avec logos des équipes, .nfo,
  pochette intégrée au MKV, nom de fichier lisible.
- UI : onglet « Auto NBA » et pastille sur les enregistrements.
- Docker : fonts-dejavu-core (texte des vignettes) et tzdata.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
MichaelandClaude Opus 5.5 committed 2026-10-18 11:31:23 +02:00
1 parent 530bf82b04
commit f291b66087
20 files changed
+4486 -32

No files matched your search

+2
View File
@@ -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)
+769 -3
View File
@@ -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<RecordingsTab>
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<RecordingsTab>
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<RecordingsTab>
_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<String, dynamic>? _rule;
List<dynamic> _games = [];
bool _isLoading = true;
bool _busy = false;
String? _error;
bool _showOffBein = false;
@override
void initState() {
super.initState();
_load();
}
Future<void> _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<String, dynamic>;
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<void> _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<void> _updateRule(Map<String, dynamic> 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<String, dynamic>);
// 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<void> _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<void> _decide(Map<String, dynamic> 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<void> _showSettings() async {
final rule = _rule;
if (rule == null) return;
List<dynamic> 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 = <String>{
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<String, dynamic> 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<String, dynamic> 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 = <Object>[];
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<String, dynamic>);
},
);
}
@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()),
],
),
);
}
}
+124 -1
View File
@@ -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",
]
+2
View File
@@ -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"
+349
View File
@@ -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<Map<String, Value>, Resp> {
if req.body.is_empty() {
return Ok(Map::new());
}
match serde_json::from_slice::<Value>(&req.body) {
Ok(Value::Object(map)) => Ok(map),
_ => Err(json(400, &json!({"error": "Corps JSON invalide"}))),
}
}
fn string_list(v: &Value) -> Option<Vec<String>> {
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<String, Value>) -> 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::<i64>().ok())
.unwrap_or(21)
.clamp(1, 60);
let now = Utc::now();
let list: Vec<Value> = 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<Value> = 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}))
}
+49 -20
View File
@@ -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<Value> {
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<String> {
self.channel_map_for(playlist)
.await
.names
.get(channel_id)
.cloned()
}
async fn programmes_with_source(
&self,
playlist: &PlaylistConfig,
channel_id: &str,
) -> (Vec<Value>, &'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<Value> = 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<Value> {
+1
View File
@@ -1,5 +1,6 @@
pub mod account;
pub mod admin;
pub mod auto_rules;
pub mod epg;
pub mod logo;
pub mod player_log;
+411 -2
View File
@@ -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<Recording> {
.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<String>) -> Vec<String> {
raw.and_then(|r| serde_json::from_str(&r).ok())
.unwrap_or_default()
}
fn auto_rule_from_row(row: &Row) -> DbResult<AutoRule> {
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<String>>("created_at")?
.unwrap_or_default(),
updated_at: row
.get::<_, Option<String>>("updated_at")?
.unwrap_or_default(),
})
}
fn nba_game_from_row(row: &Row) -> DbResult<NbaGame> {
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<String>>("updated_at")?
.unwrap_or_default(),
})
}
/// Ligne brute → objet JSON (colonnes dans l'ordre de la table).
fn row_to_json(row: &Row) -> DbResult<Map<String, Value>> {
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<AutoRule> {
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<AutoRule> {
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<AutoRule> {
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<AutoRule> {
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<AutoRule> {
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<NbaGame> {
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<Utc>,
to: DateTime<Utc>,
) -> Vec<NbaGame> {
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<NbaGame> {
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<NbaGame> {
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);
+11
View File
@@ -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,
+67
View File
@@ -89,6 +89,8 @@ pub struct Recording {
pub status: String,
pub file_path: Option<String>,
pub error_reason: Option<String>,
/// Règle automatique à l'origine de l'enregistrement (None = manuel/season pass).
pub source_rule_id: Option<String>,
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<String>,
pub pad_before_min: i64,
pub pad_after_min: i64,
/// Noms d'équipes à suivre ; vide = toutes.
pub team_filter: Vec<String>,
/// Rétention en jours des fichiers créés par la règle ; None = illimitée.
pub keep_days: Option<i64>,
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<String>,
pub channel_name: Option<String>,
pub epg_title: Option<String>,
pub epg_start: Option<String>,
pub epg_end: Option<String>,
pub recording_id: Option<String>,
pub confidence: Option<i64>,
/// 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<chrono::Utc>,
}
+7 -1
View File
@@ -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);
+343
View File
@@ -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<Regex> = 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<Regex> =
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<String> {
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<BeinChannel> {
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<BeinChannel> {
let mut found: Vec<BeinChannel> = 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::<u32>() {
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<BeinChannel> {
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::<Value>(&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<String, String> = 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<BeinChannel> {
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<String> = preferred_fr(&detected)
.into_iter()
.map(|c| c.stream_id)
.collect();
assert_eq!(preferred, ["11", "20", "41", "40"]);
}
}
+5
View File
@@ -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;
+656
View File
@@ -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<Option<FontVec>> = 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<u8>, 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<Utc>) -> 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<RgbaImage> {
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<PathBuf> {
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('&', "&amp;")
.replace('<', "&lt;")
.replace('>', "&gt;")
.replace('"', "&quot;")
}
/// `.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!(
"<?xml version=\"1.0\" encoding=\"UTF-8\" standalone=\"yes\"?>\n<movie>\n <title>{}</title>\n <sorttitle>{} {}</sorttitle>\n <plot>{}</plot>\n <premiered>{}</premiered>\n <year>{}</year>\n <genre>Basketball</genre>\n <genre>NBA</genre>\n <studio>{}</studio>\n <tag>NBA</tag>\n <tag>{}</tag>\n <tag>{}</tag>\n <uniqueid type=\"espn\" default=\"true\">{}</uniqueid>\n</movie>\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<String> = 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 <SPORTS> & co".into());
let xml = nfo(&g, "NBA - Celtics @ Lakers");
assert!(xml.contains("<title>NBA - Celtics @ Lakers</title>"));
assert!(xml.contains("beIN &lt;SPORTS&gt; &amp; co"));
assert!(xml.contains("<uniqueid type=\"espn\" default=\"true\">401810026</uniqueid>"));
assert!(xml.contains("<genre>NBA</genre>"));
}
/// 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");
}
}
+894
View File
@@ -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<Utc>,
pub end: DateTime<Utc>,
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<Self> {
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<Utc>,
other_games_nearby: bool,
programmes: &[EpgProgramme],
) -> Option<LiveBroadcast> {
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<String> {
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<String> = 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<Utc>,
b: &EpgProgramme,
) -> (DateTime<Utc>, DateTime<Utc>) {
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<Utc>,
end: DateTime<Utc>,
},
Realign {
start: DateTime<Utc>,
end: DateTime<Utc>,
},
/// Changement de chaîne : supprimer l'enregistrement planifié et recréer.
Recreate {
start: DateTime<Utc>,
end: DateTime<Utc>,
},
/// 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<Utc>,
live: Option<&LiveBroadcast>,
recording: Option<&Recording>,
now: DateTime<Utc>,
) -> 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<Utc>,
now: DateTime<Utc>,
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<Utc>, end: DateTime<Utc>) -> 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<AppDatabase>,
http: reqwest::Client,
epg: EpgApi,
scheduler: Arc<RecordingScheduler>,
timers: Mutex<Vec<JoinHandle<()>>>,
/// Une réconciliation à la fois (timer et API peuvent se croiser).
running: tokio::sync::Mutex<()>,
}
impl NbaAutoRecorder {
pub fn new(
db: Arc<AppDatabase>,
http: reqwest::Client,
epg: EpgApi,
scheduler: Arc<RecordingScheduler>,
) -> Self {
Self {
db,
http,
epg,
scheduler,
timers: Mutex::new(Vec::new()),
running: tokio::sync::Mutex::new(()),
}
}
pub fn start(self: &Arc<Self>) {
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<PlaylistConfig> {
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<EpgProgramme> {
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<String, DateTime<Utc>> = 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<Utc> {
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");
}
}
+167
View File
@@ -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<DateTime<Utc>> {
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<ScheduledGame> {
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<ScheduledGame> {
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::<Value>(&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());
}
}
+302
View File
@@ -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<usize> {
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"));
}
}
@@ -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!(
+263 -5
View File
@@ -31,16 +31,32 @@ use crate::util::log_redactor::redact_url;
pub type XmltvIndex = HashMap<String, Arc<Vec<XmltvProgramme>>>;
/// 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 `<new/>`, `<live/>`, `<premiere/>` et
/// `<previously-shown/>` : 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<Utc>,
pub stop: DateTime<Utc>,
/// `<sub-title>` (souvent l'affiche du match ou le titre d'épisode).
pub sub_title: Option<String>,
/// `<category>`, dans l'ordre du dump (l'élément peut se répéter).
pub categories: Vec<String>,
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::<u64>().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<String>) -> 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 `<programme>` en cours de lecture.
#[derive(Default)]
struct ProgrammeMeta {
sub_title: String,
category: String,
categories: Vec<String>,
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<XmltvIndex
let mut current_tag: Option<TextTag> = 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<XmltvIndex
stop = XmltvEpgService::parse_xmltv_date(Some(&attr(e, b"stop")));
title.clear();
desc.clear();
meta = ProgrammeMeta::default();
}
b"title" => 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 (`<new/>`), mais
// `<premiere>` porte parfois un texte et `<previously-shown>`
// 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<XmltvIndex
match tag {
TextTag::Title => 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<XmltvIndex
match tag {
TextTag::Title => 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<XmltvIndex
b"display-name" => {
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<XmltvIndex
}
};
if !key.is_empty() {
let meta = std::mem::take(&mut meta);
let sub_title = dart_trim(&meta.sub_title);
by_channel.entry(key).or_default().push(XmltvProgramme {
title: dart_trim(&title).to_string(),
description: dart_trim(&desc).to_string(),
start: s,
stop: e,
sub_title: (!sub_title.is_empty())
.then(|| sub_title.to_string()),
categories: meta.categories,
is_new: meta.is_new,
is_live: meta.is_live,
is_premiere: meta.is_premiere,
previously_shown: meta.previously_shown,
});
}
}
@@ -1356,6 +1444,7 @@ mod tests {
description: "D".into(),
start: Utc.with_ymd_and_hms(2026, 8, 11, 18, 0, 0).unwrap(),
stop: Utc.with_ymd_and_hms(2026, 8, 11, 19, 0, 0).unwrap(),
..Default::default()
};
assert_eq!(
p.to_json("42"),
@@ -1365,10 +1454,39 @@ mod tests {
"start": "2026-08-11T18:00:00.000Z",
"end": "2026-08-11T19:00:00.000Z",
"channel_id": "42",
"sub_title": null,
"categories": [],
"is_new": false,
"is_live": false,
"is_premiere": false,
"previously_shown": false,
})
);
}
#[test]
fn to_json_expose_les_metadonnees_xmltv() {
let p = XmltvProgramme {
title: "NBA".into(),
sub_title: Some("Lakers - Celtics".into()),
categories: vec!["Sport".into(), "Basket-ball".into()],
is_live: true,
is_new: true,
..Default::default()
};
let json = p.to_json("beinsports1.fr");
assert_eq!(json["sub_title"], "Lakers - Celtics");
assert_eq!(
json["categories"],
serde_json::json!(["Sport", "Basket-ball"])
);
assert_eq!(json["is_live"], true);
assert_eq!(json["is_new"], true);
assert_eq!(json["is_premiere"], false);
assert_eq!(json["previously_shown"], false);
assert_eq!(json["title"], "NBA");
}
// --- ensureFresh ---
#[tokio::test]
@@ -1535,6 +1653,146 @@ mod tests {
);
}
#[test]
fn horizon_lu_depuis_l_environnement_borne() {
assert_eq!(horizon_hours(None), 48);
assert_eq!(horizon_hours(Some("")), 48);
assert_eq!(horizon_hours(Some("abc")), 48);
assert_eq!(horizon_hours(Some("-5")), 48);
assert_eq!(horizon_hours(Some(" 168 ")), 168);
assert_eq!(horizon_hours(Some("1")), 12);
assert_eq!(horizon_hours(Some("12")), 12);
assert_eq!(horizon_hours(Some("336")), 336);
assert_eq!(horizon_hours(Some("9999")), 336);
}
#[test]
fn horizon_long_garde_les_matchs_a_une_semaine() {
let mut config = XmltvConfig::new(vec![]);
config.horizon = Duration::from_secs(horizon_hours(Some("168")) * 3600);
let svc = Svc::new(config, reqwest::Client::new());
let far = Utc::now() + TimeDelta::days(5);
assert!(
!svc.parse_for_test(&fixture(far, far + TimeDelta::hours(1)))
.is_empty()
);
}
// --- métadonnées XMLTV (direct / rediffusion) ---
/// Deux matchs sur beIN : un direct balisé en éléments vides, une
/// rediffusion balisée en paires ouvrante/fermante (`<new></new>`), avec
/// attributs et texte là où certains dumps en mettent.
fn metadata_fixture(start: DateTime<Utc>) -> String {
let s = |h: i64| stamp(start + TimeDelta::hours(h));
format!(
r#"<?xml version="1.0" encoding="utf-8" ?>
<tv>
<channel id="beINSports1.fr"><display-name>beIN SPORTS 1</display-name></channel>
<programme start="{}" stop="{}" channel="beINSports1.fr">
<title lang="fr">NBA</title>
<sub-title lang="fr">Los Angeles Lakers - Boston Celtics</sub-title>
<desc lang="fr">Saison régulière</desc>
<category lang="fr">Sport</category>
<category lang="en"> Basketball </category>
<category lang="fr"/>
<new/>
<live/>
<premiere/>
</programme>
<programme start="{}" stop="{}" channel="beINSports1.fr">
<title lang="fr">NBA</title>
<sub-title lang="fr"><![CDATA[Lakers &amp; Celtics]]></sub-title>
<category lang="fr">Sport &amp; Basket</category>
<new></new>
<live></live>
<premiere lang="fr">Première diffusion</premiere>
<previously-shown start="20261017020000 +0000" channel="beINSports1.fr"></previously-shown>
</programme>
<programme start="{}" stop="{}" channel="beINSports1.fr">
<title lang="fr">Rediffusion</title>
<sub-title/>
<previously-shown start="20261017020000 +0000"/>
</programme>
</tv>
"#,
s(0),
s(2),
s(2),
s(4),
s(4),
s(6)
)
}
fn bein_programmes(start: DateTime<Utc>) -> Vec<XmltvProgramme> {
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 &amp; 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 <premiere> 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
+2
View File
@@ -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<RecordingScheduler>,
pub nba: Arc<NbaAutoRecorder>,
pub streaming: Arc<Streaming>,
pub proxy: ProxyHandler,
pub epg: EpgApi,