mirror of
https://github.com/R0m1k3/xtremflow.git
synced 2026-10-19 13:08:05 +02:00
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:
1 parent
530bf82b04
commit
f291b66087
20 files changed
+4486
-32
No files matched your search
@@ -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
@@ -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,5 +1,6 @@
|
||||
pub mod account;
|
||||
pub mod admin;
|
||||
pub mod auto_rules;
|
||||
pub mod epg;
|
||||
pub mod logo;
|
||||
pub mod player_log;
|
||||
|
||||
Reference in new issue
Block a user