mirror of
https://github.com/R0m1k3/xtremflow.git
synced 2026-10-16 19:58:54 +02:00
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>
380 lines
14 KiB
Rust
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());
|
|
}
|
|
}
|