Files
xtremflow/server/src/api/recordings.rs
T
MichaelandClaude Opus 5.5 3ea6fa0ae3 Serveur réécrit en Rust
Le serveur Dart (bin/) est remplacé par un serveur Rust (server/) :
tokio + axum, reqwest/rustls, SQLite embarqué via rusqlite. Même API,
mêmes routes et mêmes réponses : le front Flutter ne change pas.

La base de production est reprise telle quelle : schéma, migrations et
formats de dates (DateTime.toIso8601String de Dart) sont reproduits à
l'identique, les sessions et hachages existants restent valides.

Comportement conservé : cascade de routage, anti-SSRF, logs masqués,
rate limits, transcodage HLS/turbo.ts avec repli GPU, quotas fournisseur,
enregistrements (reprise, relances, fusion), season passes, EPG, logos de
repli, arrêt gracieux sur SIGTERM. Tests Dart portés (132 tests Rust) ;
la CI exécute fmt, test et clippy -D warnings, l'image Docker compile le
serveur dans un étage rust:1.98.1.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 00:29:57 +02:00

380 lines
14 KiB
Rust

//! `/api/recordings` : liste, programmation, arrêt, suppression, logs.
//! `/api/season-passes` : enregistrements répétés par titre d'émission.
use crate::dart_time::DartDateTime;
use crate::http::{self, Req, Resp, json, segments};
use crate::models::{Recording, User};
use crate::services::recording_scheduler::{RecordingScheduler, recordings_dir_path};
use crate::state::SharedState;
use crate::util::{media_probe, safe_path};
use chrono::{DateTime, Utc};
use regex::Regex;
use serde_json::{Map, Value, json};
use std::path::Path;
use std::sync::LazyLock;
/// L'utilisateur peut-il agir sur cet enregistrement ?
fn can_access(user: Option<&User>, recording: &Recording) -> bool {
user.is_some_and(|u| u.is_admin || recording.user_id == u.id)
}
pub async fn recordings(state: &SharedState, req: &Req, rest: &str) -> Resp {
match (req.method.as_str(), segments(rest).as_slice()) {
("GET" | "HEAD", []) => list(state, req).await,
("POST", []) => create(state, req),
("DELETE", [id]) => delete(state, req, id),
("POST", ["stop", id]) => stop(state, req, id),
("GET" | "HEAD", ["logs", id]) => logs(state, req, id).await,
_ => http::not_found(),
}
}
async fn logs(state: &SharedState, req: &Req, id: &str) -> Resp {
let Some(recording) = state.db.get_recording_by_id(id) else {
return json(404, &json!({"error": "Enregistrement non trouvé"}));
};
if !can_access(req.user.as_ref(), &recording) {
return json(403, &json!({"error": "Accès refusé"}));
}
let Some(file_path) = &recording.file_path else {
return json(
404,
&json!({"error": "Aucun fichier ni log associé pour le moment."}),
);
};
// Enregistrements en .mkv, log .log à côté.
let log_path = Path::new(file_path).with_extension("log");
// Anti traversée : le log doit rester dans le dossier des enregistrements.
let Some(safe) = safe_path::resolve_within(&recordings_dir_path(), &log_path.to_string_lossy())
else {
return json(403, &json!({"error": "Chemin de log invalide"}));
};
match tokio::fs::read(&safe).await {
Ok(bytes) => json(200, &json!({"logs": String::from_utf8_lossy(&bytes)})),
Err(_) => json(404, &json!({"error": "Le fichier de log est introuvable."})),
}
}
/// Liste les enregistrements de l'utilisateur (tous pour un admin),
/// enrichis du suivi : taille, progression, relances, durée réelle.
async fn list(state: &SharedState, req: &Req) -> Resp {
let recordings = match &req.user {
Some(user) if !user.is_admin => state.db.get_user_recordings(&user.id),
_ => state.db.get_all_recordings(),
};
let now = Utc::now();
let mut enriched = Vec::with_capacity(recordings.len());
for r in &recordings {
let mut map = enrich(&state.scheduler, r, now);
// Durée réelle du média : `end_time - start_time` n'est que la durée
// programmée, fausse pour un enregistrement arrêté ou tronqué.
if let Some(duration) = probe_duration(r).await {
map.insert("duration_seconds".into(), json!(duration));
}
enriched.push(Value::Object(map));
}
json(200, &Value::Array(enriched))
}
/// Une capture en cours grossit en permanence : le cache de ffprobe serait
/// invalidé à chaque appel. Seuls les enregistrements terminés sont mesurés.
async fn probe_duration(r: &Recording) -> Option<f64> {
let path = r.file_path.as_deref()?;
if r.status != "completed" {
return None;
}
let safe = safe_path::resolve_within(&recordings_dir_path(), path)?;
media_probe::duration(&safe).await
}
fn enrich(scheduler: &RecordingScheduler, r: &Recording, now: DateTime<Utc>) -> Map<String, Value> {
let mut map = r.to_map();
if r.status == "recording" {
let start = r.start_utc();
let total = (r.end_utc() - start).num_seconds();
if total > 0 {
let elapsed = (now - start).num_seconds();
let pct = (elapsed as f64 * 100.0 / total as f64)
.clamp(0.0, 100.0)
.round() as i64;
map.insert("progress_pct".into(), json!(pct));
}
map.insert("is_active".into(), json!(scheduler.is_capturing(&r.id)));
if let Some(retries) = scheduler.retry_count_of(&r.id) {
map.insert("retry_count".into(), json!(retries));
}
}
if let Some(path) = &r.file_path
&& let Ok(meta) = std::fs::metadata(path)
&& meta.is_file()
{
map.insert("file_size_bytes".into(), json!(meta.len()));
}
map
}
static ZONE_SUFFIX: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"[+-]\d{2}:?\d{2}$").unwrap());
/// Date ISO-8601 AVEC fuseau, normalisée en UTC. Une date naïve est
/// ambiguë : l'interpréter dans le fuseau du serveur décalait
/// l'enregistrement de plusieurs heures selon le TZ du conteneur.
fn parse_zoned_date(raw: Option<&Value>) -> Option<DateTime<Utc>> {
let s = match raw? {
Value::String(s) => s.clone(),
Value::Null => return None,
other => other.to_string(),
};
if s.is_empty() || !(s.ends_with('Z') || ZONE_SUFFIX.is_match(&s)) {
return None;
}
DartDateTime::parse(&s).map(|d| d.to_utc())
}
fn to_text(v: Option<&Value>) -> Option<String> {
match v? {
Value::Null => None,
Value::String(s) => Some(s.clone()),
other => Some(other.to_string()),
}
}
fn create(state: &SharedState, req: &Req) -> Resp {
let Some(user_id) = req
.user
.as_ref()
.map(|u| u.id.clone())
.or(req.user_id.clone())
else {
return json(401, &json!({"error": "Authentification requise"}));
};
let Ok(Value::Object(data)) = serde_json::from_slice::<Value>(&req.body) else {
return json(400, &json!({"error": "Corps JSON invalide"}));
};
let channel_id = to_text(data.get("channel_id")).unwrap_or_default();
let stream_url = to_text(data.get("stream_url")).unwrap_or_default();
if channel_id.is_empty() {
return json(400, &json!({"error": "channel_id est requis"}));
}
if stream_url.is_empty() {
return json(400, &json!({"error": "stream_url est requis"}));
}
let (Some(start), Some(end)) = (
parse_zoned_date(data.get("start_time")),
parse_zoned_date(data.get("end_time")),
) else {
return json(
400,
&json!({"error": "start_time et end_time doivent être des dates ISO-8601 avec fuseau (ex: 2026-08-27T21:00:00Z)"}),
);
};
if end <= start {
return json(
400,
&json!({"error": "end_time doit être après start_time"}),
);
}
let max_hours: i64 = std::env::var("MAX_RECORDING_HOURS")
.ok()
.and_then(|v| v.parse().ok())
.unwrap_or(12);
if end - start > chrono::Duration::hours(max_hours) {
return json(
400,
&json!({"error": format!("Durée maximale dépassée ({max_hours} h)")}),
);
}
let title = to_text(data.get("title")).unwrap_or_else(|| "Sans Titre".into());
match state
.db
.create_recording(&user_id, &channel_id, &stream_url, &title, start, end)
{
Ok(recording) => json(200, &Value::Object(recording.to_map())),
Err(e) => {
println!("[RecordingsApi] Erreur à la création: {e}");
json(500, &json!({"error": "Erreur lors de la programmation"}))
}
}
}
/// Annule ou supprime un enregistrement : FFmpeg arrêté s'il tourne,
/// fichiers (.mkv, .log, parties) supprimés avec la ligne.
fn delete(state: &SharedState, req: &Req, id: &str) -> Resp {
let Some(recording) = state.db.get_recording_by_id(id) else {
return json(404, &json!({"error": "Enregistrement non trouvé"}));
};
if !can_access(req.user.as_ref(), &recording) {
return json(403, &json!({"error": "Accès refusé"}));
}
// Tuer FFmpeg AVANT de supprimer de la base.
state.scheduler.stop_recording(id);
if let Some(path) = &recording.file_path {
// Confiné au dossier des enregistrements.
if let Some(safe) = safe_path::resolve_within(&recordings_dir_path(), path) {
RecordingScheduler::delete_recording_files(&safe);
}
}
state.db.delete_recording(id);
json(
200,
&json!({"message": "Enregistrement supprimé avec succès"}),
)
}
fn stop(state: &SharedState, req: &Req, id: &str) -> Resp {
let Some(recording) = state.db.get_recording_by_id(id) else {
return json(404, &json!({"error": "Enregistrement non trouvé"}));
};
if !can_access(req.user.as_ref(), &recording) {
return json(403, &json!({"error": "Accès refusé"}));
}
if state.scheduler.stop_recording(id) {
return json(200, &json!({"message": "Enregistrement arrêté"}));
}
match recording.status.as_str() {
// Rien capturé : annulé, pas « terminé » (la lecture échouerait).
"scheduled" => {
state
.db
.update_recording_status(id, "cancelled", None, None, false);
json(200, &json!({"message": "Enregistrement annulé"}))
}
// « recording » sans processus (orphelin) : clôturer.
"recording" => {
state
.db
.update_recording_status(id, "completed", None, None, false);
json(
200,
&json!({"message": "Enregistrement marqué comme terminé"}),
)
}
// Déjà clôturé : ne pas écraser le statut final.
_ => json(200, &json!({"message": "Enregistrement déjà clôturé"})),
}
}
// ==================== /api/season-passes ====================
pub fn season_passes(state: &SharedState, req: &Req, rest: &str) -> Resp {
match (req.method.as_str(), segments(rest).as_slice()) {
("GET" | "HEAD", []) => {
let Some(user) = &req.user else {
return json(401, &json!({"error": "Authentification requise"}));
};
let passes = if user.is_admin {
state.db.get_all_season_passes()
} else {
state.db.get_season_passes_for_user(&user.id)
};
json(
200,
&Value::Array(passes.into_iter().map(Value::Object).collect()),
)
}
("POST", []) => create_pass(state, req),
("DELETE", [id]) => {
let Some(pass) = state.db.get_season_pass_by_id(id) else {
return json(404, &json!({"error": "Season Pass non trouvé"}));
};
// Seul le propriétaire ou un admin supprime.
let owner = pass.get("user_id").and_then(Value::as_str);
let allowed = req
.user
.as_ref()
.is_some_and(|u| u.is_admin || owner == Some(u.id.as_str()));
if !allowed {
return json(403, &json!({"error": "Accès refusé"}));
}
state.db.delete_season_pass(id);
json(200, &json!({"message": "Season Pass supprimé"}))
}
_ => http::not_found(),
}
}
fn create_pass(state: &SharedState, req: &Req) -> Resp {
let data = match serde_json::from_slice::<Value>(&req.body) {
Ok(Value::Object(map)) => map,
Ok(_) => return json(500, &json!({"error": "Erreur: invalid body"})),
Err(e) => {
return json(
500,
&json!({"error": format!("Erreur: FormatException: {e}")}),
);
}
};
let field = |k: &str| {
data.get(k)
.and_then(Value::as_str)
.unwrap_or("")
.to_string()
};
let (show_title, channel_id, stream_url) = (
field("show_title"),
field("channel_id"),
field("stream_url"),
);
if show_title.is_empty() {
return json(400, &json!({"error": "show_title est requis"}));
}
if channel_id.is_empty() {
return json(400, &json!({"error": "channel_id est requis"}));
}
if stream_url.is_empty() {
return json(400, &json!({"error": "stream_url est requis"}));
}
let Some(user) = &req.user else {
return json(401, &json!({"error": "Authentification requise"}));
};
// 'exact' par défaut : « Journal » ne doit pas capturer tous les
// programmes qui contiennent le mot.
let match_mode = if data.get("match_mode").and_then(Value::as_str) == Some("contains") {
"contains"
} else {
"exact"
};
let duplicate = state
.db
.get_season_passes_for_user(&user.id)
.iter()
.any(|p| {
p.get("show_title")
.and_then(Value::as_str)
.is_some_and(|t| t.to_lowercase() == show_title.to_lowercase())
&& p.get("channel_id").and_then(Value::as_str) == Some(channel_id.as_str())
});
if duplicate {
return json(
409,
&json!({"error": "Un Season Pass identique existe déjà"}),
);
}
match state
.db
.create_season_pass(&user.id, &show_title, &channel_id, &stream_url, match_mode)
{
Ok(pass) => {
println!("[SeasonPass] Créé: \"{show_title}\" sur chaîne {channel_id}");
json(201, &Value::Object(pass))
}
Err(e) => json(500, &json!({"error": format!("Erreur: {e}")})),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn exige_un_fuseau() {
assert!(parse_zoned_date(Some(&json!("2026-08-27T21:00:00Z"))).is_some());
assert_eq!(
parse_zoned_date(Some(&json!("2026-08-27T23:00:00+02:00"))).unwrap(),
parse_zoned_date(Some(&json!("2026-08-27T21:00:00Z"))).unwrap()
);
assert!(parse_zoned_date(Some(&json!("2026-08-27T21:00:00"))).is_none());
assert!(parse_zoned_date(Some(&json!(""))).is_none());
assert!(parse_zoned_date(None).is_none());
}
}