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

@@ -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!(