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>
This commit is contained in:
MichaelandClaude Opus 5.5 committed 2026-10-13 00:29:57 +02:00
1 parent f82c70ae05
commit 3ea6fa0ae3
109 files changed
+14008 -11047

No files matched your search

+2063
View File
File diff suppressed because it is too large. Load diff
+35
View File
@@ -0,0 +1,35 @@
[package]
name = "xtremflow-server"
version = "0.1.0"
edition = "2024"
description = "Serveur XtremFlow : API, proxy Xtream, streaming FFmpeg, enregistrements TV"
publish = false
[dependencies]
async-stream = "0.3.6"
axum = "0.8.9"
base64 = "0.22"
bcrypt = "0.19.3"
bytes = "1.12.1"
chrono = "0.4.45"
flate2 = "1.1.10"
futures = "0.3.34"
libc = "0.2.190"
md-5 = "0.10"
mime_guess = "2.0.5"
percent-encoding = "2.3.2"
quick-xml = "0.37"
regex = "1.13.1"
reqwest = { version = "0.12", default-features = false, features = ["rustls-tls", "stream"] }
rusqlite = { version = "0.40.2", features = ["bundled"] }
serde = { version = "1.0.229", features = ["derive"] }
serde_json = { version = "1.0.154", features = ["preserve_order"] }
sha2 = "0.10"
tokio = { version = "1.53.2", features = ["full"] }
tokio-util = { version = "0.7.20", features = ["io"] }
url = "2.5.8"
uuid = { version = "1.28.0", features = ["v4"] }
[profile.release]
lto = "thin"
strip = true
+384
View File
@@ -0,0 +1,384 @@
//! Comptes : authentification, utilisateurs (admin), playlists, réglages.
use crate::http::{self, Req, Resp, extract_token, json, json_as_text, segments, text};
use crate::state::SharedState;
use crate::util::password;
use serde_json::{Value, json};
fn error_json(status: u16, error: impl Into<String>) -> Resp {
json(status, &json!({"success": false, "error": error.into()}))
}
fn str_field(payload: &Value, key: &str) -> Option<String> {
payload.get(key).and_then(Value::as_str).map(str::to_string)
}
/// Corps JSON objet, ou réponse 500 (comportement de l'ancien serveur sur
/// un corps illisible).
fn parse_object(req: &Req) -> Result<Value, Resp> {
match serde_json::from_slice::<Value>(&req.body) {
Ok(v) if v.is_object() => Ok(v),
Ok(_) => Err(error_json(
500,
"type is not a subtype of type 'Map<String, dynamic>'",
)),
Err(e) => Err(error_json(500, format!("FormatException: {e}"))),
}
}
/// Longueur à la manière de Dart (unités UTF-16).
fn dart_len(s: &str) -> usize {
s.encode_utf16().count()
}
// ==================== /api/auth ====================
pub async fn auth(state: &SharedState, req: &Req, rest: &str) -> Resp {
match (req.method.as_str(), segments(rest).as_slice()) {
("POST", ["login"]) => login(state, req).await,
("POST", ["logout"]) => logout(state, req),
("GET" | "HEAD", ["me"]) => me(state, req),
_ => http::not_found(),
}
}
async fn login(state: &SharedState, req: &Req) -> Resp {
println!("[Auth] Login attempt received");
let payload = match parse_object(req) {
Ok(p) => p,
Err(resp) => {
println!("[Auth] ERROR during login: invalid body");
return resp;
}
};
let (Some(username), Some(pass)) = (
str_field(&payload, "username"),
str_field(&payload, "password"),
) else {
println!("[Auth] Missing username or password");
return error_json(400, "Username and password are required");
};
println!("[Auth] Verifying credentials for user: {username}");
// bcrypt est coûteux : hors du runtime asynchrone.
let db = state.db.clone();
let user_name = username.clone();
let user = tokio::task::spawn_blocking(move || db.verify_credentials(&user_name, &pass))
.await
.ok()
.flatten();
let Some(user) = user else {
println!("[Auth] Invalid credentials for user: {username}");
return error_json(401, "Invalid credentials");
};
println!(
"[Auth] User verified, creating session for userId: {}",
user.id
);
let session = match state.db.create_session(&user.id) {
Ok(s) => s,
Err(e) => return error_json(500, e.to_string()),
};
// Cookie de session HttpOnly : les requêtes média de même origine
// (hls.js dans l'iframe du lecteur ne peut pas envoyer d'en-tête
// Authorization) sont ainsi authentifiées.
let max_age = (session.expires_at.to_utc() - chrono::Utc::now()).num_seconds();
let mut resp = json(
200,
&json!({"success": true, "user": user.to_json(), "token": session.token}),
);
http::set_header(
&mut resp,
"set-cookie",
&format!(
"session={}; HttpOnly; SameSite=Lax; Path=/; Max-Age={max_age}",
session.token
),
);
resp
}
fn logout(state: &SharedState, req: &Req) -> Resp {
let Some(token) = extract_token(req) else {
return error_json(401, "Unauthorized");
};
state.db.delete_session(&token);
let mut resp = json(200, &json!({"success": true}));
http::set_header(
&mut resp,
"set-cookie",
"session=; HttpOnly; SameSite=Lax; Path=/; Max-Age=0",
);
resp
}
fn me(state: &SharedState, req: &Req) -> Resp {
let Some(token) = extract_token(req) else {
return error_json(401, "Unauthorized");
};
let Some(session) = state.db.find_session_by_token(&token) else {
return error_json(401, "Invalid or expired session");
};
match state.db.find_user_by_id(&session.user_id) {
Some(user) => json(200, &json!({"user": user.to_json()})),
None => error_json(404, "User not found"),
}
}
// ==================== /api/users ====================
fn is_admin(state: &SharedState, req: &Req) -> bool {
req.user_id
.as_deref()
.and_then(|id| state.db.find_user_by_id(id))
.is_some_and(|u| u.is_admin)
}
fn admin_required() -> Resp {
json_as_text(403, &json!({"success": false, "error": "Admin required"}))
}
pub async fn users(state: &SharedState, req: &Req, rest: &str) -> Resp {
match (req.method.as_str(), segments(rest).as_slice()) {
("GET" | "HEAD", []) => list_users(state, req),
("POST", []) => create_user(state, req).await,
("PUT", [id, "password"]) => update_password(state, req, id).await,
("PUT", [id]) => update_user(state, req, id),
("DELETE", [id]) => delete_user(state, req, id),
_ => http::not_found(),
}
}
fn list_users(state: &SharedState, req: &Req) -> Resp {
if !is_admin(state, req) {
return admin_required();
}
let users: Vec<Value> = state
.db
.get_all_users()
.iter()
.map(|u| u.to_json())
.collect();
json(200, &json!({"success": true, "users": users}))
}
async fn create_user(state: &SharedState, req: &Req) -> Resp {
if !is_admin(state, req) {
return admin_required();
}
let payload = match parse_object(req) {
Ok(p) => p,
Err(resp) => return resp,
};
let (Some(username), Some(pass)) = (
str_field(&payload, "username"),
str_field(&payload, "password"),
) else {
return error_json(400, "Missing required fields");
};
let admin = payload
.get("isAdmin")
.and_then(Value::as_bool)
.unwrap_or(false);
if dart_len(&pass) < 8 {
return error_json(400, "Le mot de passe doit faire au moins 8 caractères");
}
if state.db.find_user_by_username(&username).is_some() {
return error_json(400, "Username already exists");
}
let hash = match tokio::task::spawn_blocking(move || password::hash(&pass)).await {
Ok(Ok(h)) => h,
Ok(Err(e)) => return error_json(500, e),
Err(e) => return error_json(500, e.to_string()),
};
match state.db.create_user(&username, &hash, admin) {
Ok(user) => json(200, &json!({"success": true, "user": user.to_json()})),
Err(e) => error_json(500, e.to_string()),
}
}
async fn update_password(state: &SharedState, req: &Req, id: &str) -> Resp {
if !is_admin(state, req) {
return admin_required();
}
let payload = match parse_object(req) {
Ok(p) => p,
Err(resp) => return resp,
};
let Some(pass) = str_field(&payload, "password") else {
return error_json(400, "Missing password");
};
if dart_len(&pass) < 8 {
return error_json(400, "Le mot de passe doit faire au moins 8 caractères");
}
let hash = match tokio::task::spawn_blocking(move || password::hash(&pass)).await {
Ok(Ok(h)) => h,
Ok(Err(e)) => return error_json(500, e),
Err(e) => return error_json(500, e.to_string()),
};
state.db.update_password_hash(id, &hash);
json(200, &json!({"success": true}))
}
fn update_user(state: &SharedState, req: &Req, id: &str) -> Resp {
if !is_admin(state, req) {
return admin_required();
}
let payload = match parse_object(req) {
Ok(p) => p,
Err(resp) => return resp,
};
if let Some(admin) = payload.get("isAdmin").and_then(Value::as_bool)
&& let Err(e) = state.db.update_user_admin_status(id, admin)
{
return error_json(500, e.to_string());
}
json(200, &json!({"success": true}))
}
fn delete_user(state: &SharedState, req: &Req, id: &str) -> Resp {
if !is_admin(state, req) {
return admin_required();
}
// Interdit de se supprimer soi-même.
if req.user_id.as_deref() == Some(id) {
return error_json(400, "Cannot delete yourself");
}
match state.db.delete_user(id) {
Ok(()) => json(200, &json!({"success": true})),
Err(e) => error_json(500, e.to_string()),
}
}
// ==================== /api/playlists ====================
pub fn playlists(state: &SharedState, req: &Req, rest: &str) -> Resp {
let Some(user_id) = req.user_id.clone() else {
return error_json(401, "Unauthorized");
};
match (req.method.as_str(), segments(rest).as_slice()) {
("GET" | "HEAD", []) => {
let list: Vec<Value> = state
.db
.get_playlists(&user_id)
.iter()
.map(|p| p.to_json())
.collect();
json(200, &json!({"playlists": list}))
}
("POST", []) => create_playlist(state, req, &user_id),
("PUT", [id]) => update_playlist(state, req, &user_id, id),
("DELETE", [id]) => delete_playlist(state, &user_id, id),
_ => http::not_found(),
}
}
fn create_playlist(state: &SharedState, req: &Req, user_id: &str) -> Resp {
let payload = match parse_object(req) {
Ok(p) => p,
Err(resp) => return resp,
};
let field = |k| str_field(&payload, k);
let (Some(name), Some(server_url), Some(username), Some(pass)) = (
field("name"),
field("serverUrl"),
field("username"),
field("password"),
) else {
return error_json(400, "Missing required fields");
};
let dns = field("dns").unwrap_or_else(|| server_url.clone());
match state
.db
.create_playlist(user_id, &name, &server_url, &username, &pass, Some(&dns))
{
Ok(p) => json(200, &json!({"success": true, "playlist": p.to_json()})),
Err(e) => error_json(500, e.to_string()),
}
}
fn update_playlist(state: &SharedState, req: &Req, user_id: &str, id: &str) -> Resp {
let Some(existing) = state.db.get_playlist_by_id(id) else {
return error_json(404, "Playlist not found");
};
if existing.user_id != user_id {
return error_json(403, "Forbidden");
}
let payload = match parse_object(req) {
Ok(p) => p,
Err(resp) => return resp,
};
let field = |k| str_field(&payload, k);
let name = field("name").unwrap_or(existing.name.clone());
let server_url = field("serverUrl").unwrap_or(existing.server_url.clone());
let username = field("username").unwrap_or(existing.username.clone());
// Mot de passe vide = « inchangé » : les clients ne reçoivent jamais le
// vrai mot de passe, les formulaires d'édition le renvoient vide.
let pass = field("password")
.filter(|p| !p.is_empty())
.unwrap_or(existing.password.clone());
let dns = field("dns").or(existing.dns.clone());
match state
.db
.update_playlist(id, &name, &server_url, &username, &pass, dns.as_deref())
{
Ok(Some(p)) => json(200, &json!({"success": true, "playlist": p.to_json()})),
Ok(None) => error_json(404, "Playlist not found"),
Err(e) => error_json(500, e.to_string()),
}
}
fn delete_playlist(state: &SharedState, user_id: &str, id: &str) -> Resp {
let Some(existing) = state.db.get_playlist_by_id(id) else {
return error_json(404, "Playlist not found");
};
if existing.user_id != user_id {
return error_json(403, "Forbidden");
}
match state.db.delete_playlist(id) {
Ok(()) => json(200, &json!({"success": true})),
Err(e) => error_json(500, e.to_string()),
}
}
// ==================== /api/settings ====================
pub fn settings(state: &SharedState, req: &Req, rest: &str) -> Resp {
let Some(user_id) = req.user_id.clone() else {
return json_as_text(403, &json!({"error": "Authentication required"}));
};
if !segments(rest).is_empty() {
return http::not_found();
}
match req.method.as_str() {
"GET" | "HEAD" => {
let settings = state
.db
.get_user_settings(&user_id)
.unwrap_or_else(|| "{}".to_string());
http::with_headers(
http::response(200, settings),
&[("content-type", "application/json")],
)
}
"POST" => {
let body = req.body_str();
if serde_json::from_str::<Value>(&body).is_err() {
return json_as_text(400, &json!({"error": "Invalid JSON"}));
}
if let Err(e) = state.db.update_user_settings(&user_id, &body) {
return text(
500,
json!({"error": format!("Failed to save settings: {e}")}).to_string(),
);
}
http::with_headers(
http::response(200, body),
&[("content-type", "application/json")],
)
}
_ => http::not_found(),
}
}
+194
View File
@@ -0,0 +1,194 @@
//! `/api/admin` : purge des temporaires et diagnostic du délai de zap.
use crate::http::{self, Req, Resp, json_as_text, segments};
use crate::models::PlaylistConfig;
use crate::state::SharedState;
use serde_json::{Value, json};
use std::time::{Duration, Instant};
pub async fn admin(state: &SharedState, req: &Req, rest: &str) -> Resp {
match (req.method.as_str(), segments(rest).as_slice()) {
("POST", ["purge"]) => {
if !req.user.as_ref().is_some_and(|u| u.is_admin) {
return json_as_text(403, &json!({"error": "Admin access required"}));
}
http::json(200, &state.cleanup.run_cleanup().await)
}
("GET" | "HEAD", ["upstream-probe"]) => {
if !req.user.as_ref().is_some_and(|u| u.is_admin) {
return json_as_text(403, &json!({"error": "Admin access required"}));
}
let stream = req.query_param("stream").unwrap_or_default();
if stream.is_empty() || !stream.bytes().all(|b| b.is_ascii_digit()) {
return http::text(400, "stream=<id numérique> requis");
}
let Some(playlist) = state.get_playlist(req) else {
return http::text(403, "No playlist");
};
http::json(
200,
&probe_upstream(&state.http_manual, &playlist, &stream).await,
)
}
_ => http::not_found(),
}
}
/// Diagnostic admin : chronomètre l'ouverture d'une chaîne chez le
/// fournisseur, en `.ts` et en `.m3u8`.
///
/// POURQUOI : ~4,3 s des ~4,6 s d'un zap se passent avant le premier octet
/// du panneau (mesuré en prod). Reste à savoir lequel des deux formats
/// démarre le plus vite, et où part le temps. Les identifiants n'apparaissent
/// jamais dans la réponse : seuls l'hôte et les durées sont rendus.
///
/// [client] ne doit PAS suivre les redirections (chaque saut est chronométré).
pub async fn probe_upstream(
client: &reqwest::Client,
playlist: &PlaylistConfig,
stream_id: &str,
) -> Value {
let base = format!(
"{}/live/{}/{}/{stream_id}",
playlist.dns, playlist.username, playlist.password
);
let ts = match reqwest::Url::parse(&format!("{base}.ts")) {
Ok(url) => probe_stream(client, url).await,
Err(_) => json!({"error": "FormatException", "afterMs": 0}),
};
// Laisser le panneau libérer la connexion (comptes à une connexion).
tokio::time::sleep(Duration::from_secs(2)).await;
let hls = match reqwest::Url::parse(&format!("{base}.m3u8")) {
Ok(url) => probe_hls(client, url).await,
Err(_) => json!({"error": "FormatException", "afterMs": 0}),
};
json!({"stream": stream_id, "ts": ts, "m3u8": hls})
}
/// Suit les redirections à la main pour chronométrer chaque saut.
async fn open(
client: &reqwest::Client,
url: reqwest::Url,
started: Instant,
) -> Result<(Option<reqwest::Response>, Vec<Value>, reqwest::Url), String> {
let mut hops = Vec::new();
let mut current = url;
for _ in 0..5 {
let send = client
.get(current.clone())
.header("User-Agent", "VLC/3.0.18 LibVLC/3.0.18")
.send();
let response = tokio::time::timeout(Duration::from_secs(20), send)
.await
.map_err(|_| "TimeoutException".to_string())?
.map_err(|_| "ClientException".to_string())?;
let status = response.status().as_u16();
hops.push(json!({
"host": current.host_str().unwrap_or(""),
"status": status,
"headersMs": started.elapsed().as_millis() as u64,
}));
let location = response
.headers()
.get("location")
.and_then(|v| v.to_str().ok())
.map(str::to_string);
if let (true, Some(location)) = ([301, 302, 303, 307, 308].contains(&status), location) {
drop(response);
current = current
.join(&location)
.map_err(|_| "FormatException".to_string())?;
continue;
}
return Ok((Some(response), hops, current));
}
Ok((None, hops, current))
}
fn status_error(response: Option<&reqwest::Response>) -> String {
match response {
Some(r) => format!("status {}", r.status().as_u16()),
None => "status null".to_string(),
}
}
async fn probe_stream(client: &reqwest::Client, url: reqwest::Url) -> Value {
let started = Instant::now();
let (response, hops, _) = match open(client, url, started).await {
Ok(r) => r,
Err(e) => return json!({"error": e, "afterMs": started.elapsed().as_millis() as u64}),
};
let mut response = match response {
Some(r) if r.status().as_u16() == 200 => r,
other => return json!({"hops": hops, "error": status_error(other.as_ref())}),
};
let mut bytes = 0usize;
let mut first_byte_ms: Option<u64> = None;
// 1 Mo suffit à voir le premier envoi (préchargement du panneau).
let read = async {
while let Ok(Some(chunk)) = response.chunk().await {
first_byte_ms.get_or_insert(started.elapsed().as_millis() as u64);
bytes += chunk.len();
if bytes > 1_000_000 {
break;
}
}
};
let _ = tokio::time::timeout(Duration::from_secs(15), read).await;
json!({
"hops": hops,
"firstByteMs": first_byte_ms,
"firstMegabyteMs": (bytes > 1_000_000).then(|| started.elapsed().as_millis() as u64),
})
}
async fn probe_hls(client: &reqwest::Client, url: reqwest::Url) -> Value {
let started = Instant::now();
let (response, hops, final_url) = match open(client, url, started).await {
Ok(r) => r,
Err(e) => return json!({"error": e, "afterMs": started.elapsed().as_millis() as u64}),
};
let response = match response {
Some(r) if r.status().as_u16() == 200 => r,
other => return json!({"hops": hops, "error": status_error(other.as_ref())}),
};
let body = match response.text().await {
Ok(b) => b,
Err(_) => {
return json!({"error": "ClientException", "afterMs": started.elapsed().as_millis() as u64});
}
};
let playlist_ms = started.elapsed().as_millis() as u64;
let lines: Vec<&str> = body.lines().collect();
let segments: Vec<&str> = lines
.iter()
.copied()
.filter(|l| !l.is_empty() && !l.starts_with('#'))
.collect();
let target = lines
.iter()
.find(|l| l.starts_with("#EXT-X-TARGETDURATION"))
.map(|l| l.rsplit(':').next().unwrap_or("").to_string())
.unwrap_or_default();
if segments.is_empty() {
return json!({"hops": hops, "playlistMs": playlist_ms, "segments": 0});
}
// Un lecteur démarre près du direct : chronométrer l'avant-dernier.
let pick = if segments.len() >= 2 {
segments[segments.len() - 2]
} else {
segments[segments.len() - 1]
};
let segment = match final_url.join(pick) {
Ok(url) => Box::pin(probe_stream(client, url)).await,
Err(_) => json!({"error": "FormatException"}),
};
json!({
"hops": hops,
"playlistMs": playlist_ms,
"segments": segments.len(),
"targetDuration": target,
"segment": segment,
"segmentStartedAtMs": playlist_ms,
})
}
+774
View File
@@ -0,0 +1,774 @@
//! API EPG — `GET /api/epg/<channel_id>`, cache 30 minutes.
//!
//! Le panneau de l'abonné reste la source de référence, mais par son dump
//! `xmltv.php` d'abord : une requête couvre toutes les chaînes, là où
//! `player_api` en réclame une par chaîne à plusieurs secondes pièce. Vient
//! ensuite la source XMLTV externe (déjà indexée en mémoire), puis
//! l'interrogation chaîne par chaîne — cas des revendeurs dont le guide est
//! figé depuis plusieurs jours.
use crate::dart_time::{DartDateTime, utc_iso};
use crate::http::{self, Resp};
use crate::models::PlaylistConfig;
use crate::services::xmltv::{XmltvConfig, XmltvEpgService};
use crate::util::b64;
use crate::util::log_redactor::redact_url;
use chrono::{TimeZone, Utc};
use futures::FutureExt;
use futures::future::{BoxFuture, Shared};
use serde_json::{Map, Value, json};
use std::collections::HashMap;
use std::sync::{Arc, Mutex};
use std::time::{Duration, Instant};
pub type PanelXmltvBuilder = Arc<dyn Fn(&PlaylistConfig) -> XmltvEpgService + Send + Sync>;
/// Correspondance `stream_id` → identifiant EPG et nom, par compte. Le dump
/// XMLTV indexe par identifiant de chaîne, pas par `stream_id` du panneau.
#[derive(Default)]
struct ChannelMap {
epg_ids: HashMap<String, String>,
names: HashMap<String, String>,
expires_at: Option<Instant>,
}
struct Inner {
xmltv: Option<Arc<XmltvEpgService>>,
http: reqwest::Client,
panel_builder: PanelXmltvBuilder,
/// « dns|username|channelId » → réponse JSON. La clé inclut le compte :
/// deux panneaux peuvent partager un même `stream_id`.
cache: Mutex<HashMap<String, (String, Instant)>>,
channel_maps: Mutex<HashMap<String, Arc<ChannelMap>>>,
channel_maps_in_flight: Mutex<HashMap<String, Shared<BoxFuture<'static, Arc<ChannelMap>>>>>,
panel_xmltv: Mutex<HashMap<String, Arc<XmltvEpgService>>>,
}
#[derive(Clone)]
pub struct EpgApi {
inner: Arc<Inner>,
}
/// Dump XMLTV servi par le panneau lui-même.
///
/// POURQUOI : `get_simple_data_table` répond en plusieurs secondes chez
/// beaucoup de revendeurs, et il faut un appel par chaîne — une grille de
/// trente chaînes demandait plus d'une minute. `xmltv.php` renvoie le guide
/// de toutes les chaînes en une requête, réutilisée pendant des heures.
pub fn default_panel_xmltv(client: reqwest::Client) -> PanelXmltvBuilder {
Arc::new(move |playlist: &PlaylistConfig| {
let enc = |s: &str| url::form_urlencoded::byte_serialize(s.as_bytes()).collect::<String>();
let mut config = XmltvConfig::new(vec![format!(
"{}/xmltv.php?username={}&password={}",
playlist.dns,
enc(&playlist.username),
enc(&playlist.password)
)]);
config.refresh_interval = Duration::from_secs(3 * 3600);
// Guide facultatif : pas plusieurs minutes d'attente sur un panneau lent.
config.download_timeout = Duration::from_secs(90);
XmltvEpgService::new(config, client.clone())
})
}
fn value_to_string(v: &Value) -> Option<String> {
match v {
Value::Null => None,
Value::String(s) => Some(s.clone()),
other => Some(other.to_string()),
}
}
/// `stream_id` → (`epg_channel_id`, nom) depuis `get_live_streams`.
fn parse_channel_table(body: &[u8]) -> (HashMap<String, String>, HashMap<String, String>) {
let mut epg_ids = HashMap::new();
let mut names = HashMap::new();
// UTF-8 explicite : sans charset annoncé, un décodage latin-1 abîmait
// les noms (« Chérie », « ◉ ») et donc leur correspondance au guide.
let text = String::from_utf8_lossy(body);
if let Ok(Value::Array(items)) = serde_json::from_str::<Value>(&text) {
for item in items {
let Value::Object(item) = item else { continue };
let Some(stream_id) = item.get("stream_id").and_then(value_to_string) else {
continue;
};
if stream_id.is_empty() {
continue;
}
if let Some(epg_id) = item.get("epg_channel_id").and_then(value_to_string)
&& !epg_id.is_empty()
{
epg_ids.insert(stream_id.clone(), epg_id);
}
if let Some(name) = item.get("name").and_then(value_to_string)
&& !name.is_empty()
{
names.insert(stream_id, name);
}
}
}
(epg_ids, names)
}
/// Epoch (secondes, texte ou entier) → ISO-8601 UTC.
fn iso_utc(timestamp: Option<&Value>) -> Option<String> {
let seconds: i64 = match timestamp? {
Value::Number(n) => n.as_i64()?,
Value::String(s) => s.parse().ok()?,
_ => return None,
};
if seconds <= 0 {
return None;
}
Utc.timestamp_opt(seconds, 0).single().map(utc_iso)
}
/// Repli : « YYYY-MM-DD HH:MM:SS » → « YYYY-MM-DDTHH:MM:SS ».
fn normalize_date(raw: &str) -> String {
if raw.contains(' ') && !raw.contains('T') {
raw.replacen(' ', "T", 1)
} else {
raw.to_string()
}
}
/// Normalise une réponse Xtream (`epg_listings`) vers le format servi.
pub fn transform_epg_data(raw: &Value, channel_id: &str) -> Value {
let listings: Vec<Value> = match raw {
Value::Object(map) if map.contains_key("epg_listings") => map
.get("epg_listings")
.and_then(Value::as_array)
.cloned()
.unwrap_or_default(),
Value::Array(list) => list.clone(),
_ => Vec::new(),
};
let str_of = |item: &Map<String, Value>, key: &str| -> String {
item.get(key)
.and_then(Value::as_str)
.unwrap_or("")
.to_string()
};
let mut programmes = Vec::new();
for item in listings {
let Value::Object(item) = item else {
return json!({"channel_id": channel_id, "programmes": [], "error": "EPG indisponible"});
};
// Les champs texte `start`/`end` sont dans le fuseau du panneau, sans
// indicateur de zone : le client les relisait en heure locale et
// décalait tout le guide. Les `*_timestamp` sont de l'epoch UTC.
let start = iso_utc(item.get("start_timestamp"))
.unwrap_or_else(|| normalize_date(&str_of(&item, "start")));
let end = iso_utc(item.get("stop_timestamp"))
.or_else(|| iso_utc(item.get("end_timestamp")))
.unwrap_or_else(|| {
let raw = item
.get("stop")
.and_then(Value::as_str)
.or_else(|| item.get("end").and_then(Value::as_str))
.unwrap_or("");
normalize_date(raw)
});
let decode = |raw: String| -> String {
if raw.is_empty() {
return raw;
}
match b64::decode_text(&raw) {
Some(decoded) if !decoded.is_empty() => decoded,
_ => raw,
}
};
programmes.push(json!({
"title": decode(str_of(&item, "title")),
"description": decode(str_of(&item, "description")),
"start": start,
"end": end,
"channel_id": channel_id,
}));
}
json!({"channel_id": channel_id, "programmes": programmes})
}
/// Le guide contient-il un programme en cours ou à venir ? Un panneau au
/// guide figé répond avec des centaines de programmes tous terminés :
/// compter les entrées ne suffit pas.
fn has_current_programme(programmes: &[Value]) -> bool {
let now = Utc::now();
programmes.iter().any(|p| {
p.get("end")
.and_then(Value::as_str)
.and_then(DartDateTime::parse)
.is_some_and(|end| end.to_utc() > now)
})
}
impl EpgApi {
pub fn new(
xmltv: Option<Arc<XmltvEpgService>>,
http: reqwest::Client,
panel_builder: PanelXmltvBuilder,
) -> Self {
Self {
inner: Arc::new(Inner {
xmltv,
http,
panel_builder,
cache: Mutex::default(),
channel_maps: Mutex::default(),
channel_maps_in_flight: Mutex::default(),
panel_xmltv: Mutex::default(),
}),
}
}
pub async fn handle(&self, playlist: Option<PlaylistConfig>, channel_id: &str) -> Resp {
let Some(playlist) = playlist else {
return http::json(403, &json!({"error": "Playlist non trouvée"}));
};
let cache_key = format!("{}|{}|{channel_id}", playlist.dns, playlist.username);
if let Some((data, expires)) = self.inner.cache.lock().unwrap().get(&cache_key)
&& Instant::now() < *expires
{
return http::with_headers(
http::response(200, data.clone()),
&[("content-type", "application/json"), ("x-cache", "HIT")],
);
}
// 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 found = false;
for name in ["panel-xmltv", "xmltv", "xtream"] {
let candidate = match name {
"panel-xmltv" => self.panel_programmes(&playlist, channel_id).await,
"xmltv" => {
self.programmes_from_xmltv(self.inner.xmltv.clone(), &playlist, channel_id)
.await
}
_ => self.xtream_programmes(&playlist, channel_id).await,
};
if candidate.is_empty() {
continue;
}
let current = has_current_programme(&candidate);
if !found || current {
programmes = candidate;
source = name;
found = true;
}
if current {
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),
],
)
}
async fn panel_programmes(&self, playlist: &PlaylistConfig, channel_id: &str) -> Vec<Value> {
let key = format!("{}|{}", playlist.dns, playlist.username);
let service = self
.inner
.panel_xmltv
.lock()
.unwrap()
.entry(key)
.or_insert_with(|| Arc::new((self.inner.panel_builder)(playlist)))
.clone();
self.programmes_from_xmltv(Some(service), playlist, channel_id)
.await
}
/// Programmes d'une chaîne dans un index XMLTV (panneau ou externe).
async fn programmes_from_xmltv(
&self,
xmltv: Option<Arc<XmltvEpgService>>,
playlist: &PlaylistConfig,
channel_id: &str,
) -> Vec<Value> {
let Some(xmltv) = xmltv else {
return Vec::new();
};
let channels = self.channel_map_for(playlist).await;
let epg_id = channels.epg_ids.get(channel_id).map(String::as_str);
let name = channels.names.get(channel_id).map(String::as_str);
if epg_id.is_none() && name.is_none() {
return Vec::new();
}
let now = Utc::now();
xmltv
.programmes_for(epg_id, name)
.await
.iter()
.filter(|p| p.stop > now)
.take(64)
.map(|p| p.to_json(channel_id))
.collect()
}
/// Interrogation du panneau chaîne par chaîne : chemin lent, plusieurs
/// secondes par action, réservé aux chaînes absentes des dumps.
async fn xtream_programmes(&self, playlist: &PlaylistConfig, channel_id: &str) -> Vec<Value> {
// `get_simple_data_table` d'abord : seule action réellement
// universelle. `get_epg` manque sur beaucoup de panneaux, qui
// renvoient alors le payload d'authentification.
for action in ["get_simple_data_table", "get_short_epg"] {
let url = format!(
"{}/player_api.php?username={}&password={}&action={action}&stream_id={channel_id}",
playlist.dns, playlist.username, playlist.password
);
let fetch = async {
let response = self.inner.http.get(&url).send().await?;
let status = response.status().as_u16();
Ok::<_, reqwest::Error>((status, response.bytes().await?))
};
let (status, body) = match tokio::time::timeout(Duration::from_secs(20), fetch).await {
Ok(Ok(r)) => r,
Ok(Err(e)) => {
// Une source parmi d'autres, pas une raison de répondre 500.
println!(
"[EpgApi] player_api {action} indisponible pour {channel_id} : {}",
redact_url(&format!("{e:?}"))
);
continue;
}
Err(_) => {
println!(
"[EpgApi] player_api {action} indisponible pour {channel_id} : TimeoutException after 0:00:20.000000"
);
continue;
}
};
if status != 200 {
continue;
}
let Ok(raw) = serde_json::from_str::<Value>(&String::from_utf8_lossy(&body)) else {
continue;
};
let programmes: Vec<Value> = transform_epg_data(&raw, channel_id)["programmes"]
.as_array()
.cloned()
.unwrap_or_default()
.into_iter()
.filter(Value::is_object)
.collect();
if !programmes.is_empty() {
return programmes;
}
}
Vec::new()
}
/// Table des chaînes du compte, rafraîchie toutes les 6 heures. Les
/// requêtes simultanées partagent un seul téléchargement : la grille
/// demande le guide de 30 chaînes d'un coup (7 Mo de table en prod).
async fn channel_map_for(&self, playlist: &PlaylistConfig) -> Arc<ChannelMap> {
let key = format!("{}|{}", playlist.dns, playlist.username);
if let Some(map) = self.inner.channel_maps.lock().unwrap().get(&key)
&& map.expires_at.is_some_and(|e| Instant::now() < e)
{
return map.clone();
}
let shared = {
let mut in_flight = self.inner.channel_maps_in_flight.lock().unwrap();
match in_flight.get(&key) {
Some(f) => f.clone(),
None => {
let inner = self.inner.clone();
let playlist = playlist.clone();
let task_key = key.clone();
// Tâche détachée : le téléchargement aboutit même si le
// client qui l'a déclenché est parti.
let handle = tokio::spawn(async move {
let map = Arc::new(load_channel_map(&inner.http, &playlist).await);
inner
.channel_maps
.lock()
.unwrap()
.insert(task_key.clone(), map.clone());
inner
.channel_maps_in_flight
.lock()
.unwrap()
.remove(&task_key);
map
});
let f = async move { handle.await.unwrap_or_default() }
.boxed()
.shared();
in_flight.insert(key, f.clone());
f
}
}
};
shared.await
}
}
async fn load_channel_map(client: &reqwest::Client, playlist: &PlaylistConfig) -> ChannelMap {
let url = format!(
"{}/player_api.php?username={}&password={}&action=get_live_streams",
playlist.dns, playlist.username, playlist.password
);
let fetch = async {
let response = client.get(&url).send().await?;
let status = response.status().as_u16();
Ok::<_, reqwest::Error>((status, response.bytes().await?))
};
let (mut epg_ids, mut names) = (HashMap::new(), HashMap::new());
match tokio::time::timeout(Duration::from_secs(90), fetch).await {
Ok(Ok((200, body))) => {
// Décodage hors du runtime : 7 Mo de JSON gelaient sinon le relais
// des flux en cours.
match tokio::task::spawn_blocking(move || parse_channel_table(&body)).await {
Ok(parsed) => (epg_ids, names) = parsed,
Err(e) => println!("[EpgApi] table des chaînes illisible : {e}"),
}
}
Ok(Ok(_)) => {}
Ok(Err(e)) => println!(
"[EpgApi] table des chaînes indisponible : {}",
redact_url(&format!("{e:?}"))
),
Err(_) => println!(
"[EpgApi] table des chaînes indisponible : TimeoutException after 0:01:30.000000"
),
}
// Une table vide (panneau en échec) n'est gardée que 2 minutes : la
// garder 6 h privait TOUTES les chaînes du repli XMLTV pendant 6 h.
let ttl = if names.is_empty() {
Duration::from_secs(120)
} else {
Duration::from_secs(6 * 3600)
};
ChannelMap {
epg_ids,
names,
expires_at: Some(Instant::now() + ttl),
}
}
#[cfg(test)]
mod tests {
use super::*;
use axum::extract::{Query, Request};
use std::sync::atomic::{AtomicUsize, Ordering};
#[test]
fn convertit_les_epoch_en_utc_et_decode_le_base64() {
let raw = json!({"epg_listings": [{
"title": "Sm91cm5hbCBkZSAyMGg=",
"start": "2026-08-11 20:00:00",
"end": "2026-08-11 20:35:00",
"description": "TCdpbmZvIGR1IHNvaXI=",
"start_timestamp": "1786471200",
"stop_timestamp": "1786473300",
}]});
let result = transform_epg_data(&raw, "845452");
let p = &result["programmes"][0];
assert_eq!(p["start"], "2026-08-11T18:00:00.000Z");
assert_eq!(p["end"], "2026-08-11T18:35:00.000Z");
assert_eq!(p["title"], "Journal de 20h");
assert_eq!(p["description"], "L'info du soir");
assert_eq!(p["channel_id"], "845452");
}
#[test]
fn retombe_sur_les_champs_texte() {
let raw = json!({"epg_listings": [{
"title": "Sm91cm5hbCBkZSAyMGg=",
"start": "2026-08-11 20:00:00",
"stop": "2026-08-11 20:35:00",
}]});
let p = &transform_epg_data(&raw, "845452")["programmes"][0];
assert_eq!(p["start"], "2026-08-11T20:00:00");
assert_eq!(p["end"], "2026-08-11T20:35:00");
}
#[test]
fn liste_vide_sans_epg_listings() {
let raw = json!({"user_info": {"auth": 1}, "server_info": {"url": "x"}});
assert_eq!(transform_epg_data(&raw, "1")["programmes"], json!([]));
}
// ---------- handle() contre un faux panneau ----------
type Handler = Arc<dyn Fn(&str, &HashMap<String, String>) -> (u16, String) + Send + Sync>;
async fn mock(handler: Handler) -> String {
let app = axum::Router::new().fallback(
move |Query(q): Query<HashMap<String, String>>, req: Request| {
let handler = handler.clone();
async move {
// Laisser respirer les requêtes concurrentes (mutualisation).
tokio::time::sleep(Duration::from_millis(30)).await;
let (status, body) = handler(req.uri().path(), &q);
(axum::http::StatusCode::from_u16(status).unwrap(), body)
}
},
);
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
tokio::spawn(async move { axum::serve(listener, app).await.unwrap() });
format!("http://{addr}")
}
fn stamp(d: chrono::DateTime<Utc>) -> String {
d.format("%Y%m%d%H%M%S +0000").to_string()
}
fn dump(
id: &str,
name: &str,
start: chrono::DateTime<Utc>,
stop: chrono::DateTime<Utc>,
) -> String {
format!(
r#"<?xml version="1.0" encoding="utf-8" ?><tv><channel id="{id}"><display-name>{name}</display-name></channel><programme start="{}" stop="{}" channel="{id}"><title lang="fr">Journal de 20h</title><desc lang="fr">L'info du soir</desc></programme></tv>"#,
stamp(start),
stamp(stop)
)
}
fn playlist(base: &str) -> PlaylistConfig {
PlaylistConfig {
dns: base.to_string(),
username: "u".into(),
password: "p".into(),
}
}
fn api(base: &str, external: bool) -> EpgApi {
let client = reqwest::Client::new();
let xmltv = external.then(|| {
Arc::new(XmltvEpgService::new(
XmltvConfig::new(vec![format!("{base}/ext/epg.xml")]),
client.clone(),
))
});
let c = client.clone();
let builder: PanelXmltvBuilder = Arc::new(move |p: &PlaylistConfig| {
XmltvEpgService::new(
XmltvConfig::new(vec![format!("{}/xmltv.php", p.dns)]),
c.clone(),
)
});
EpgApi::new(xmltv, client, builder)
}
async fn body_json(resp: Resp) -> Value {
let bytes = axum::body::to_bytes(resp.into_body(), usize::MAX)
.await
.unwrap();
serde_json::from_slice(&bytes).unwrap()
}
#[tokio::test]
async fn sert_le_dump_du_panneau_sans_appel_par_chaine() {
let now = Utc::now();
let actions = Arc::new(Mutex::new(Vec::<String>::new()));
let a = actions.clone();
let base = mock(Arc::new(move |path, q| {
if path.ends_with("/xmltv.php") {
return (200, dump("France2.fr", "FRANCE 2", now - chrono::Duration::minutes(5), now + chrono::Duration::minutes(25)));
}
let action = q.get("action").cloned().unwrap_or_default();
a.lock().unwrap().push(action.clone());
if action == "get_live_streams" {
return (200, json!([{"stream_id": 845452, "name": "FRANCE 2", "epg_channel_id": "France2.fr"}]).to_string());
}
(200, "{}".into())
}))
.await;
let resp = api(&base, false)
.handle(Some(playlist(&base)), "845452")
.await;
assert_eq!(resp.status().as_u16(), 200);
assert_eq!(resp.headers()["x-epg-source"], "panel-xmltv");
let body = body_json(resp).await;
assert_eq!(body["programmes"][0]["title"], "Journal de 20h");
assert_eq!(*actions.lock().unwrap(), vec!["get_live_streams"]);
}
#[tokio::test]
async fn retombe_sur_player_api_quand_le_dump_ne_couvre_pas_la_chaine() {
let future = chrono::Local::now() + chrono::Duration::minutes(10);
let panel_stamp =
|d: chrono::DateTime<chrono::Local>| d.format("%Y-%m-%d %H:%M:%S").to_string();
let start = panel_stamp(future);
let end = panel_stamp(future + chrono::Duration::hours(2));
let base = mock(Arc::new(move |path, q| {
if path.ends_with("/xmltv.php") {
return (200, "<tv></tv>".into());
}
match q.get("action").map(String::as_str) {
Some("get_live_streams") => (200, "[]".into()),
Some("get_simple_data_table") => (
200,
json!({"epg_listings": [{"title": "TWF0Y2g=", "description": "", "start": start, "end": end}]}).to_string(),
),
_ => (200, "{}".into()),
}
}))
.await;
let resp = api(&base, false).handle(Some(playlist(&base)), "999").await;
assert_eq!(resp.headers()["x-epg-source"], "xtream");
assert_eq!(body_json(resp).await["programmes"][0]["title"], "Match");
}
#[tokio::test]
async fn un_panneau_qui_plante_ne_prive_pas_du_dump_externe() {
let now = Utc::now();
let base = mock(Arc::new(move |path, q| {
if path == "/ext/epg.xml" {
return (
200,
dump("TF1.fr", "TF1", now, now + chrono::Duration::hours(1)),
);
}
if path.ends_with("/xmltv.php") {
return (200, "<tv></tv>".into());
}
if q.get("action").map(String::as_str) == Some("get_live_streams") {
return (
200,
json!([{"stream_id": 1, "name": "FR - TF1 FHD ◉", "epg_channel_id": ""}])
.to_string(),
);
}
(500, "boom".into())
}))
.await;
let resp = api(&base, true).handle(Some(playlist(&base)), "1").await;
assert_eq!(resp.status().as_u16(), 200);
assert_eq!(resp.headers()["x-epg-source"], "xmltv");
assert_eq!(
body_json(resp).await["programmes"][0]["title"],
"Journal de 20h"
);
}
#[tokio::test]
async fn le_dump_externe_passe_avant_l_appel_lent() {
let now = Utc::now();
let actions = Arc::new(Mutex::new(Vec::<String>::new()));
let a = actions.clone();
let base = mock(Arc::new(move |path, q| {
if path == "/ext/epg.xml" {
return (
200,
dump("TF1.fr", "TF1", now, now + chrono::Duration::hours(1)),
);
}
if path.ends_with("/xmltv.php") {
return (200, "<tv></tv>".into());
}
let action = q.get("action").cloned().unwrap_or_default();
a.lock().unwrap().push(action.clone());
if action == "get_live_streams" {
return (
200,
json!([{"stream_id": 1, "name": "TF1", "epg_channel_id": "TF1.fr"}])
.to_string(),
);
}
(200, "{}".into())
}))
.await;
let resp = api(&base, true).handle(Some(playlist(&base)), "1").await;
assert_eq!(resp.headers()["x-epg-source"], "xmltv");
assert!(
!actions
.lock()
.unwrap()
.contains(&"get_simple_data_table".to_string())
);
}
#[tokio::test]
async fn la_grille_partage_un_seul_telechargement_de_la_table() {
let now = Utc::now();
let calls = Arc::new(AtomicUsize::new(0));
let c = calls.clone();
let base = mock(Arc::new(move |path, q| {
if path == "/ext/epg.xml" {
return (
200,
dump("TF1.fr", "TF1", now, now + chrono::Duration::hours(1)),
);
}
if path.ends_with("/xmltv.php") {
return (200, "<tv></tv>".into());
}
if q.get("action").map(String::as_str) == Some("get_live_streams") {
c.fetch_add(1, Ordering::SeqCst);
let list: Vec<Value> = (1..=5)
.map(|i| json!({"stream_id": i, "name": "TF1", "epg_channel_id": "TF1.fr"}))
.collect();
return (200, Value::Array(list).to_string());
}
(200, "{}".into())
}))
.await;
let api = api(&base, true);
let ids: Vec<String> = (1..=5).map(|i| i.to_string()).collect();
let responses =
futures::future::join_all(ids.iter().map(|id| api.handle(Some(playlist(&base)), id)))
.await;
assert!(responses.iter().all(|r| r.status().as_u16() == 200));
assert_eq!(calls.load(Ordering::SeqCst), 1);
}
#[tokio::test]
async fn cache_hit_au_second_appel() {
let now = Utc::now();
let base = mock(Arc::new(move |path, q| {
if path.ends_with("/xmltv.php") {
return (
200,
dump(
"France2.fr",
"FRANCE 2",
now,
now + chrono::Duration::minutes(25),
),
);
}
if q.get("action").map(String::as_str) == Some("get_live_streams") {
return (
200,
json!([{"stream_id": 7, "name": "FRANCE 2", "epg_channel_id": "France2.fr"}])
.to_string(),
);
}
(200, "{}".into())
}))
.await;
let api = api(&base, false);
let first = api.handle(Some(playlist(&base)), "7").await;
assert_eq!(first.headers()["x-cache"], "MISS");
let second = api.handle(Some(playlist(&base)), "7").await;
assert_eq!(second.headers()["x-cache"], "HIT");
assert!(api.handle(None, "7").await.status().as_u16() == 403);
}
}
+75
View File
@@ -0,0 +1,75 @@
//! `GET /api/logo?src=<stream_icon>&name=<nom de chaîne>`
//!
//! Logo d'une chaîne, avec repli. L'URL du panneau est tentée d'abord, via
//! le proxy Xtream (mêmes protections anti-SSRF, même mémoire des hôtes
//! morts). En cas d'échec, le logo est cherché par nom dans le dépôt public
//! `tv-logos`.
//!
//! POURQUOI : quand l'hébergeur de picons du revendeur tombe, toute la grille
//! perd ses logos d'un coup, alors que les autres applications IPTV les
//! affichent encore grâce à leurs propres sources de repli.
use crate::api::proxy::missing_image_response;
use crate::http::{self, Req, Resp};
use crate::state::AppState;
use axum::http::{HeaderMap, Method};
/// Un logo trouvé change rarement : un jour de cache navigateur.
const FOUND: &str = "public, max-age=86400";
pub async fn handle(state: &AppState, req: &Req) -> Resp {
let params = req.query_params();
let src = params.get("src").cloned().unwrap_or_default();
let name = params.get("name").cloned().unwrap_or_default();
if src.starts_with("http://") || src.starts_with("https://") {
let (path, query) = match src.split_once('?') {
Some((p, q)) => (p, q),
None => (src.as_str(), ""),
};
let path = path.split('#').next().unwrap_or("");
let query = query.split('#').next().unwrap_or("");
// Requête interne vers le proxy, avec la seule session du client.
let mut headers = HeaderMap::new();
for header in ["cookie", "authorization"] {
if let Some(value) = req.headers.get(header) {
headers.insert(header, value.clone());
}
}
let proxied = Req {
method: Method::GET,
path: format!("api/xtream/{path}"),
query: query.to_string(),
headers,
body: Default::default(),
remote: req.remote,
user: None,
user_id: None,
};
let upstream = state.proxy.handle(state, &proxied).await;
let content_type = upstream
.headers()
.get("content-type")
.and_then(|v| v.to_str().ok())
.unwrap_or("")
.to_string();
if upstream.status().as_u16() == 200 && content_type.starts_with("image/") {
return http::with_headers(
http::response(200, upstream.into_body()),
&[("content-type", &content_type), ("cache-control", FOUND)],
);
}
// Réponse abandonnée : la connexion amont est refermée.
drop(upstream);
}
if !name.is_empty()
&& let Some(bytes) = state.logo_catalog.logo_for(&name).await
{
return http::with_headers(
http::response(200, bytes),
&[("content-type", "image/png"), ("cache-control", FOUND)],
);
}
missing_image_response()
}
+11
View File
@@ -0,0 +1,11 @@
pub mod account;
pub mod admin;
pub mod epg;
pub mod logo;
pub mod player_log;
pub mod proxy;
pub mod recording_playlist;
pub mod recordings;
pub mod static_files;
pub mod streaming;
pub mod xtream_api;
+135
View File
@@ -0,0 +1,135 @@
//! Journal des incidents de lecture remontés par le lecteur web.
//!
//! POURQUOI : les freezes se produisent chez l'utilisateur, dans son
//! navigateur ; le serveur n'en voit rien. Le lecteur envoie ici chaque
//! coupure, erreur, recréation et saut, et le serveur les écrit dans
//! `docker logs` (`[Player] …`).
//!
//! Entrée non fiable : seuls des champs connus, courts et typés passent.
use crate::http::{self, Req, Resp};
use regex::Regex;
use serde_json::Value;
use std::sync::LazyLock;
const EVENTS: [&str; 11] = [
"start",
"first_frame",
"stall",
"seek",
"mpegts_error",
"recreate",
"fallback_hls",
"hls_error",
"profile",
"error_shown",
"heartbeat",
];
const NUMERIC_FIELDS: [&str; 11] = [
"t", "ms", "buffer", "pos", "from", "to", "stalls", "stallMs", "seeks", "errors", "rate",
];
const TEXT_FIELDS: [&str; 5] = ["session", "ch", "mode", "profile", "detail"];
static URL: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"https?://\S+").unwrap());
static UNSAFE: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"[^A-Za-z0-9_.\-:/<>]").unwrap());
/// Ligne de journal pour l'événement [raw], ou `None` s'il est invalide.
pub fn format_player_event(raw: &Value) -> Option<String> {
let map = raw.as_object()?;
let event = map.get("event")?.as_str()?;
if !EVENTS.contains(&event) {
return None;
}
let mut parts = vec![event.to_string()];
for key in TEXT_FIELDS {
if let Some(value) = map.get(key).and_then(Value::as_str) {
if value.is_empty() {
continue;
}
// Ni espace, ni retour ligne, ni URL : jamais d'injection de faux
// journaux, jamais d'identifiants recopiés depuis une URL.
let safe = URL.replace_all(value, "<url>");
let safe = UNSAFE.replace_all(&safe, "_");
let safe: String = safe.chars().take(60).collect();
parts.push(format!("{key}={safe}"));
}
}
for key in NUMERIC_FIELDS {
let Some(Value::Number(n)) = map.get(key) else {
continue;
};
if let Some(i) = n.as_i64() {
parts.push(format!("{key}={i}"));
} else if let Some(f) = n.as_f64().filter(|f| f.is_finite()) {
parts.push(format!("{key}={f:.1}"));
}
}
Some(format!("[Player] {}", parts.join(" ")))
}
pub fn handle(req: &Req) -> Resp {
let body = req.body_str();
if body.encode_utf16().count() > 4096 {
return http::empty(413);
}
let Ok(decoded) = serde_json::from_str::<Value>(&body) else {
return http::text(400, "Bad Request");
};
let events: Vec<&Value> = match &decoded {
Value::Array(list) => list.iter().take(20).collect(),
other => vec![other],
};
for event in events {
if let Some(line) = format_player_event(event) {
println!("{line}");
}
}
http::empty(204)
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn ecrit_une_coupure_avec_sa_duree_et_le_buffer() {
assert_eq!(
format_player_event(&json!({
"event": "stall", "session": "ab12", "ch": "554034", "ms": 5230, "buffer": 0.04
}))
.unwrap(),
"[Player] stall session=ab12 ch=554034 ms=5230 buffer=0.0"
);
}
#[test]
fn refuse_un_evenement_inconnu() {
assert!(format_player_event(&json!({"event": "rm -rf"})).is_none());
assert!(format_player_event(&json!("stall")).is_none());
assert!(format_player_event(&Value::Null).is_none());
}
#[test]
fn pas_d_injection_ni_d_url() {
let line = format_player_event(&json!({
"event": "mpegts_error",
"detail": "NetworkError http://panel/live/user/pass/1.ts\n[Player] faux"
}))
.unwrap();
assert!(!line.contains('\n'));
assert!(!line.contains("user/pass"));
assert!(line.contains("<url>"));
}
#[test]
fn ignore_les_champs_inattendus() {
let line =
format_player_event(&json!({"event": "seek", "from": 10, "cookie": "secret"})).unwrap();
assert!(line.contains("from=10"));
assert!(!line.contains("to="));
assert!(!line.contains("secret"));
}
}
+466
View File
@@ -0,0 +1,466 @@
//! Proxy Xtream `/api/xtream/<url>` (images, API du panneau) avec
//! protections anti-SSRF, et mémoire des hôtes d'images hors service.
use crate::http::{self, Req, Resp, extract_token};
use crate::models::PlaylistConfig;
use crate::state::AppState;
use crate::util::log_redactor::redact_url;
use axum::body::Body;
use futures::TryStreamExt;
use serde_json::json;
use std::collections::HashMap;
use std::net::IpAddr;
use std::sync::Mutex;
use std::time::{Duration, Instant};
/// Réponse pour une image indisponible.
///
/// Statut 410 et non 404 : la cascade du serveur retombe sur l'étage suivant
/// pour tout 404 et renverrait finalement celui des fichiers statiques, sans
/// notre `cache-control`. Un 410 s'arrête ici.
///
/// Un statut d'erreur plutôt qu'une image vide : le client affiche alors son
/// propre repli (icône, initiale) au lieu d'une tuile blanche. Le `max-age`
/// est essentiel : sans lui le navigateur redemande le logo à chaque
/// affichage de la grille.
pub fn missing_image_response() -> Resp {
http::with_headers(
http::empty(410),
&[
("cache-control", "public, max-age=600"),
("access-control-allow-origin", "*"),
],
)
}
/// Mémoire courte des hôtes d'images injoignables.
///
/// POURQUOI : les URL de picons pointent chez l'hébergeur du revendeur, pas
/// sur le panneau. Quand cette machine tombe, elle répond 5xx en quelques
/// millisecondes, le navigateur réessaie à chaque rendu de la grille et le
/// proxy relaie des centaines de requêtes mortes par seconde. Au bout de
/// [threshold] échecs consécutifs, l'hôte est tenu pour mort pendant [ttl]
/// et les requêtes suivantes sont servies localement, sans appel sortant.
pub struct AssetFailureCache {
threshold: u32,
ttl: Duration,
failures: Mutex<HashMap<String, u32>>,
down_until: Mutex<HashMap<String, Instant>>,
}
impl AssetFailureCache {
pub fn new(threshold: u32, ttl: Duration) -> Self {
Self {
threshold,
ttl,
failures: Mutex::default(),
down_until: Mutex::default(),
}
}
pub fn is_down(&self, host: &str) -> bool {
self.is_down_at(host, Instant::now())
}
fn is_down_at(&self, host: &str, now: Instant) -> bool {
let key = host.to_lowercase();
let mut down = self.down_until.lock().unwrap();
let Some(until) = down.get(&key).copied() else {
return false;
};
if now < until {
return true;
}
// Coupure expirée : ardoise vierge pour laisser une vraie chance.
down.remove(&key);
self.failures.lock().unwrap().remove(&key);
false
}
pub fn record_failure(&self, host: &str) {
self.record_failure_at(host, Instant::now());
}
fn record_failure_at(&self, host: &str, now: Instant) {
let key = host.to_lowercase();
let count = {
let mut failures = self.failures.lock().unwrap();
let count = failures.entry(key.clone()).or_insert(0);
*count += 1;
*count
};
if count >= self.threshold {
self.down_until.lock().unwrap().insert(key, now + self.ttl);
}
}
pub fn record_success(&self, host: &str) {
let key = host.to_lowercase();
self.failures.lock().unwrap().remove(&key);
self.down_until.lock().unwrap().remove(&key);
}
}
/// L'URL désigne-t-elle une image ou un logo ? Ces URL échappent à
/// l'allowlist de domaine (les revendeurs hébergent leurs picons ailleurs
/// que le panneau) et méritent un repli visuel plutôt qu'une erreur.
fn is_static_asset_url(url: &reqwest::Url) -> bool {
let path = url.path();
[".png", ".jpg", ".jpeg", ".gif", ".webp", ".ico"]
.iter()
.any(|ext| path.ends_with(ext))
|| path.contains("/picons/")
|| path.contains("/logos/")
}
/// Hôte à ne jamais atteindre par le proxy (loopback, LAN privé,
/// link-local/métadonnées cloud) — protection SSRF des URL d'images qui
/// échappent à l'allowlist de domaine.
pub fn is_forbidden_proxy_host(host: &str) -> bool {
let lower = host.to_lowercase();
let lower = lower.trim_start_matches('[').trim_end_matches(']');
if lower == "localhost" || lower == "::1" {
return true;
}
let Ok(ip) = lower.parse::<IpAddr>() else {
return false; // nom d'hôte : validé par l'allowlist de domaine
};
match ip {
IpAddr::V4(v4) => {
let o = v4.octets();
v4.is_loopback()
|| o[0] == 10
|| (o[0] == 172 && (16..=31).contains(&o[1]))
|| (o[0] == 192 && o[1] == 168)
|| (o[0] == 169 && o[1] == 254)
|| o[0] == 0
}
IpAddr::V6(v6) => v6.is_loopback() || (v6.segments()[0] & 0xffc0) == 0xfe80,
}
}
fn host_of(url: &reqwest::Url) -> String {
url.host_str()
.unwrap_or("")
.trim_start_matches('[')
.trim_end_matches(']')
.to_string()
}
const ALLOWED_RESPONSE_HEADERS: [&str; 6] = [
"content-type",
"content-range",
"accept-ranges",
"cache-control",
"server",
"date",
];
const ALLOWED_REQUEST_HEADERS: [&str; 4] = ["user-agent", "accept", "range", "referer"];
pub struct ProxyHandler {
asset_failures: AssetFailureCache,
/// Playlist servant à l'allowlist, mise en cache 5 minutes pour éviter
/// une requête SQL par segment.
playlist_cache: Mutex<Option<(PlaylistConfig, Instant)>>,
}
enum ProxyError {
Response(Resp),
Failure(String),
}
impl ProxyHandler {
pub fn new() -> Self {
Self {
asset_failures: AssetFailureCache::new(3, Duration::from_secs(300)),
playlist_cache: Mutex::new(None),
}
}
fn cached_playlist(&self, state: &AppState, req: &Req) -> Option<PlaylistConfig> {
if let Some((playlist, expiry)) = self.playlist_cache.lock().unwrap().as_ref()
&& Instant::now() < *expiry
{
return Some(playlist.clone());
}
let playlist = state.get_playlist(req)?;
*self.playlist_cache.lock().unwrap() =
Some((playlist.clone(), Instant::now() + Duration::from_secs(300)));
Some(playlist)
}
pub async fn handle(&self, state: &AppState, req: &Req) -> Resp {
// Le test de chemin passe AVANT tout : les autres requêtes doivent
// retomber sur les étages suivants de la cascade.
let Some(api_path) = req.path.strip_prefix("api/xtream/") else {
return http::empty(404);
};
// Authentification par session (en-tête ou cookie HttpOnly posé au
// login : `img src` et hls.js n'envoient que le cookie). Un proxy
// ouvert offrait un rebond SSRF non authentifié.
let authorized = extract_token(req)
.is_some_and(|token| state.db.find_session_by_token(&token).is_some());
if !authorized {
return http::text(401, "Unauthorized");
}
let mut api_path = api_path.to_string();
if api_path.starts_with("http%3A") || api_path.starts_with("https%3A") {
api_path = percent_encoding::percent_decode_str(&api_path)
.decode_utf8_lossy()
.into_owned();
}
if !api_path.starts_with("http://") && !api_path.starts_with("https://") {
return http::text(
400,
"Invalid API URL. Expected format: /api/xtream/http://...",
);
}
let mut full_url = api_path;
if !req.query.is_empty() {
full_url.push(if full_url.contains('?') { '&' } else { '?' });
full_url.push_str(&req.query);
}
let target = reqwest::Url::parse(&full_url).ok();
match self.forward(state, req, target.clone()).await {
Ok(resp) => resp,
Err(ProxyError::Response(resp)) => resp,
Err(ProxyError::Failure(error)) => {
// Une erreur reqwest recopie l'URL amont, identifiants inclus :
// jamais de détail vers le client, log masqué.
println!(
"[ProxyHandler] error on {}: {}",
redact_url(&req.path),
redact_url(&error)
);
if let Some(target) = target.filter(is_static_asset_url) {
self.asset_failures.record_failure(&host_of(&target));
return missing_image_response();
}
http::json(500, &json!({"error": "Proxy error"}))
}
}
}
async fn forward(
&self,
state: &AppState,
req: &Req,
target: Option<reqwest::Url>,
) -> Result<Resp, ProxyError> {
let target =
target.ok_or_else(|| ProxyError::Failure("FormatException: invalid URL".into()))?;
if target.scheme() != "http" && target.scheme() != "https" {
return Err(ProxyError::Response(http::text(
403,
"Unsupported URL scheme",
)));
}
let target_host = host_of(&target);
if is_forbidden_proxy_host(&target_host) {
println!("[Proxy] Blocked SSRF attempt to private host: {target_host}");
return Err(ProxyError::Response(http::text(
403,
"Access to this host is forbidden",
)));
}
// Images : tout hôte autorisé (CDN de picons des revendeurs).
let is_static_asset = is_static_asset_url(&target);
// Hôte d'images déjà constaté mort : réponse immédiate, sans appel
// sortant ni log — c'est précisément le flot qu'on veut éteindre.
if is_static_asset && self.asset_failures.is_down(&target_host) {
return Ok(missing_image_response());
}
let mut allowed_host = None;
if !is_static_asset {
// Appels d'API : allowlist de domaine.
let Some(playlist) = self.cached_playlist(state, req) else {
return Err(ProxyError::Response(http::text(
403,
"No active playlist configuration found to validate request",
)));
};
let allowed = reqwest::Url::parse(&playlist.dns)
.map(|u| host_of(&u).to_lowercase())
.unwrap_or_default();
if target_host.to_lowercase() != allowed {
println!(
"[Proxy] Blocked SSRF attempt to {} (Allowed: {allowed})",
target_host.to_lowercase()
);
return Err(ProxyError::Response(http::text(
403,
"Access to this domain is forbidden by policy",
)));
}
allowed_host = Some(allowed);
}
let mut headers = reqwest::header::HeaderMap::new();
let mut set = |name: &str, value: &str| {
if let (Ok(n), Ok(v)) = (
reqwest::header::HeaderName::from_bytes(name.as_bytes()),
reqwest::header::HeaderValue::from_str(value),
) {
headers.insert(n, v);
}
};
set("user-agent", "VLC/3.0.18 LibVLC/3.0.18");
set("accept", "*/*");
set("accept-encoding", "identity");
for name in ALLOWED_REQUEST_HEADERS {
if let Some(value) = req.header(name) {
set(name, value);
}
}
println!("[Proxy] Forwarding to: {}", redact_url(target.as_str()));
let method = reqwest::Method::from_bytes(req.method.as_str().as_bytes())
.unwrap_or(reqwest::Method::GET);
let post_body = (req.method == axum::http::Method::POST).then(|| req.body.clone());
// Redirections suivies À LA MAIN : chaque destination est revalidée
// (hôte privé, allowlist). Une 302 du serveur amont suffisait sinon à
// atteindre un hôte interne malgré l'anti-SSRF.
let mut current = target.clone();
let mut redirects = 0;
let response = loop {
let mut builder = state
.http_manual
.request(method.clone(), current.clone())
.headers(headers.clone());
if let Some(body) = &post_body {
builder = builder.body(body.clone());
}
// 90 s : laisse le front (60 s) abandonner proprement le premier.
let response = tokio::time::timeout(Duration::from_secs(90), builder.send())
.await
.map_err(|_| ProxyError::Failure("TimeoutException after 0:01:30.000000".into()))?
.map_err(|e| ProxyError::Failure(format!("{e:?}")))?;
let status = response.status().as_u16();
let location = response
.headers()
.get("location")
.and_then(|v| v.to_str().ok())
.map(str::to_string);
let Some(location) = location.filter(|_| (300..400).contains(&status)) else {
break response;
};
redirects += 1;
if redirects > 3 {
return Err(ProxyError::Response(http::text(403, "Too many redirects")));
}
current = current
.join(&location)
.map_err(|e| ProxyError::Failure(e.to_string()))?;
if current.scheme() != "http" && current.scheme() != "https" {
return Err(ProxyError::Response(http::text(
403,
"Unsupported redirect scheme",
)));
}
let host = host_of(&current);
if is_forbidden_proxy_host(&host) {
println!("[Proxy] Blocked SSRF redirect to private host: {host}");
return Err(ProxyError::Response(http::text(
403,
"Access to this host is forbidden",
)));
}
if let Some(allowed) = &allowed_host
&& host.to_lowercase() != *allowed
{
println!("[Proxy] Blocked redirect to {host} (Allowed: {allowed})");
return Err(ProxyError::Response(http::text(
403,
"Access to this domain is forbidden by policy",
)));
}
};
// Une image en erreur n'a rien à transmettre au navigateur : relayer
// le 503 déclenchait un nouveau cycle de requêtes à chaque rendu.
// L'échec est compté sur l'hôte DEMANDÉ (clé du garde-fou ci-dessus).
let status = response.status().as_u16();
if is_static_asset {
if status >= 400 {
self.asset_failures.record_failure(&target_host);
return Ok(missing_image_response());
}
self.asset_failures.record_success(&target_host);
}
let upstream_headers = response.headers().clone();
let mut resp = http::response(
status,
Body::from_stream(response.bytes_stream().map_err(std::io::Error::other)),
);
http::set_header(&mut resp, "access-control-allow-origin", "*");
for name in ALLOWED_RESPONSE_HEADERS {
let values: Vec<&str> = upstream_headers
.get_all(name)
.iter()
.filter_map(|v| v.to_str().ok())
.collect();
if !values.is_empty() {
http::set_header(&mut resp, name, &values.join(", "));
}
}
Ok(resp)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn bloque_le_loopback() {
assert!(is_forbidden_proxy_host("127.0.0.1"));
assert!(is_forbidden_proxy_host("localhost"));
assert!(is_forbidden_proxy_host("::1"));
assert!(is_forbidden_proxy_host("[::1]"));
}
#[test]
fn bloque_les_plages_privees() {
assert!(is_forbidden_proxy_host("10.0.0.5"));
assert!(is_forbidden_proxy_host("172.16.0.1"));
assert!(is_forbidden_proxy_host("172.31.255.255"));
assert!(is_forbidden_proxy_host("192.168.1.10"));
assert!(is_forbidden_proxy_host("169.254.169.254"));
assert!(is_forbidden_proxy_host("0.0.0.0"));
}
#[test]
fn autorise_les_ip_publiques_et_les_noms() {
assert!(!is_forbidden_proxy_host("8.8.8.8"));
assert!(!is_forbidden_proxy_host("172.32.0.1"));
assert!(!is_forbidden_proxy_host("cdn.example.com"));
}
#[test]
fn cache_des_hotes_morts() {
let cache = AssetFailureCache::new(3, Duration::from_secs(300));
let t0 = Instant::now();
assert!(!cache.is_down_at("cdn.example", t0));
cache.record_failure_at("cdn.example", t0);
assert!(!cache.is_down_at("cdn.example", t0));
cache.record_failure_at("cdn.example", t0);
cache.record_success("cdn.example");
cache.record_failure_at("cdn.example", t0);
assert!(!cache.is_down_at("cdn.example", t0));
cache.record_failure_at("CDN.example", t0);
cache.record_failure_at("cdn.example", t0);
assert!(cache.is_down_at("cdn.example", t0));
// Coupure expirée : ardoise vierge.
assert!(!cache.is_down_at("cdn.example", t0 + Duration::from_secs(301)));
cache.record_failure_at("cdn.example", t0);
assert!(!cache.is_down_at("cdn.example", t0));
}
}
+107
View File
@@ -0,0 +1,107 @@
//! Adressage des sessions de lecture d'un enregistrement.
//!
//! Un enregistrement est transcodé à la volée, séquentiellement. Reprendre à
//! 45 min sans le dire à FFmpeg imposerait d'attendre que l'encodeur y
//! arrive : le lecteur demande donc une playlist qui *commence* à cette
//! position (`?start=2700`), servie par une session FFmpeg dédiée.
use regex::Regex;
use std::sync::LazyLock;
/// Position de départ demandée (`?start=<secondes>`), bornée au positif.
pub fn parse_recording_start(raw: Option<&str>) -> u64 {
let Some(value) = raw.and_then(|r| r.trim().parse::<f64>().ok()) else {
return 0;
};
if !value.is_finite() || value <= 0.0 {
return 0;
}
value.floor() as u64
}
/// Segment de chemin qui rattache un `.ts` à SA session : la query string ne
/// survit pas aux URL relatives de la playlist, et les playlists démarrées à
/// 0 s et à 45 min demanderaient sinon toutes deux `segment_000.ts`.
pub fn recording_offset_key(start: u64) -> String {
format!("t{start}")
}
/// Inverse de [recording_offset_key] ; `None` si la clé est mal formée.
pub fn parse_recording_offset_key(key: &str) -> Option<u64> {
let digits = key.strip_prefix('t')?;
if digits.is_empty() || !digits.bytes().all(|b| b.is_ascii_digit() || b == b'+') {
return None;
}
digits.parse::<u64>().ok()
}
static SEGMENT_LINE: LazyLock<Regex> =
LazyLock::new(|| Regex::new(r"(?m)^(segment_\d+\.ts)\s*$").unwrap());
/// Préfixe les lignes de segment de [playlist] par [offset_key] ; balises
/// `#EXT-X-…` et lignes vides passent telles quelles.
pub fn rewrite_recording_playlist(playlist: &str, offset_key: &str) -> String {
SEGMENT_LINE
.replace_all(playlist, |caps: &regex::Captures| {
format!("{offset_key}/{}", &caps[1])
})
.into_owned()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn lit_la_position() {
assert_eq!(parse_recording_start(Some("2700")), 2700);
assert_eq!(parse_recording_start(Some("2700.9")), 2700);
for bad in [
None,
Some(""),
Some("abc"),
Some("-30"),
Some("NaN"),
Some("Infinity"),
] {
assert_eq!(parse_recording_start(bad), 0);
}
}
#[test]
fn cle_d_offset_aller_retour() {
for start in [0, 1, 2700, 86400] {
assert_eq!(
parse_recording_offset_key(&recording_offset_key(start)),
Some(start)
);
}
for bad in ["t", "segment_000.ts", "..", "t-30", "tabc"] {
assert_eq!(parse_recording_offset_key(bad), None, "{bad}");
}
}
const PLAYLIST: &str = "#EXTM3U\n#EXT-X-VERSION:3\n#EXT-X-TARGETDURATION:4\n#EXT-X-PLAYLIST-TYPE:EVENT\n#EXTINF:4.000000,\nsegment_000.ts\n#EXTINF:4.000000,\nsegment_001.ts\n";
#[test]
fn rattache_chaque_segment_a_sa_session() {
let out = rewrite_recording_playlist(PLAYLIST, "t2700");
assert!(out.contains("t2700/segment_000.ts"));
assert!(out.contains("t2700/segment_001.ts"));
assert!(!out.contains("\nsegment_"));
}
#[test]
fn laisse_les_balises() {
let out = rewrite_recording_playlist(PLAYLIST, "t0");
assert!(out.starts_with("#EXTM3U\n#EXT-X-VERSION:3\n"));
assert!(out.contains("#EXT-X-PLAYLIST-TYPE:EVENT"));
assert_eq!(out.matches("#EXTINF:4.000000,").count(), 2);
}
#[test]
fn idempotent() {
let once = rewrite_recording_playlist(PLAYLIST, "t60");
assert_eq!(rewrite_recording_playlist(&once, "t60"), once);
}
}
+379
View File
@@ -0,0 +1,379 @@
//! `/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());
}
}
+161
View File
@@ -0,0 +1,161 @@
//! Fichiers statiques de la build web, avec politique de cache.
use crate::http::{self, Req, Resp};
use crate::state::AppState;
use axum::body::Body;
use std::path::{Path, PathBuf};
use std::time::SystemTime;
use tokio_util::io::ReaderStream;
const NO_STORE: [(&str, &str); 3] = [
(
"cache-control",
"no-store, no-cache, must-revalidate, max-age=0",
),
("pragma", "no-cache"),
("expires", "0"),
];
/// Sert la build web en appliquant la politique de cache :
/// - points d'entrée versionnés (`?v=<empreinte>`) servis réécrits, jamais
/// mis en cache ;
/// - `.html`, `.js`, `.json` sans cache pour que les mises à jour soient vues
/// tout de suite, sauf les libs vendorisées (hls.js/mpegts.js, ~750 Ko,
/// versions figées) ;
/// - le reste en cache un jour.
pub async fn handle(state: &AppState, req: &Req) -> Resp {
let path = req.path.as_str();
let key = if path.is_empty() { "index.html" } else { path };
if req.is_get()
&& let Some(content) = state.versioned.get(key)
{
let content_type = if path.ends_with(".js") {
"text/javascript; charset=utf-8"
} else {
"text/html; charset=utf-8"
};
let mut resp = http::with_headers(
http::response(200, content.clone()),
&[("content-type", content_type)],
);
for (name, value) in NO_STORE {
http::set_header(&mut resp, name, value);
}
return resp;
}
let resp = serve_file(&state.web_path, req).await;
let vendored = path.starts_with("vendor/");
if !vendored
&& (path.is_empty()
|| path.ends_with(".html")
|| path.ends_with(".js")
|| path.ends_with(".json"))
{
return http::with_headers(resp, &NO_STORE);
}
http::with_headers(resp, &[("cache-control", "public, max-age=86400")])
}
/// Chemin disque de la requête, ou `None` s'il sort de la racine.
fn resolve(root: &str, path: &str) -> Option<PathBuf> {
let mut resolved = PathBuf::from(root);
for segment in path.split('/').filter(|s| !s.is_empty()) {
let decoded = percent_encoding::percent_decode_str(segment)
.decode_utf8()
.ok()?;
if decoded == "." || decoded == ".." || decoded.contains(['/', '\\', ':', '\0']) {
return None;
}
resolved.push(decoded.as_ref());
}
Some(resolved)
}
fn http_date(time: SystemTime) -> String {
let dt: chrono::DateTime<chrono::Utc> = time.into();
dt.format("%a, %d %b %Y %H:%M:%S GMT").to_string()
}
async fn serve_file(root: &str, req: &Req) -> Resp {
if !req.is_get() {
return http::text(405, "Method Not Allowed");
}
let Some(mut file_path) = resolve(root, &req.path) else {
return http::not_found();
};
if file_path.is_dir() {
// Répertoire sans `/` final : redirection, comme shelf_static.
if !req.path.is_empty() && !req.path.ends_with('/') {
let index = file_path.join("index.html");
if index.is_file() {
let mut resp = http::empty(301);
http::set_header(&mut resp, "location", &format!("/{}/", req.path));
return resp;
}
return http::not_found();
}
file_path = file_path.join("index.html");
}
let Ok(meta) = tokio::fs::metadata(&file_path).await else {
return http::not_found();
};
if !meta.is_file() {
return http::not_found();
}
let modified = meta.modified().ok();
if let (Some(modified), Some(since)) = (modified, req.header("if-modified-since"))
&& let Ok(since) = chrono::DateTime::parse_from_rfc2822(since)
{
let modified: chrono::DateTime<chrono::Utc> = modified.into();
if modified.timestamp() <= since.timestamp() {
return http::empty(304);
}
}
let content_type = content_type_of(&file_path);
let body = if req.method == axum::http::Method::HEAD {
Body::empty()
} else {
match tokio::fs::File::open(&file_path).await {
Ok(file) => Body::from_stream(ReaderStream::new(file)),
Err(_) => return http::not_found(),
}
};
let mut resp = http::response(200, body);
http::set_header(&mut resp, "content-length", &meta.len().to_string());
if let Some(ct) = content_type {
http::set_header(&mut resp, "content-type", &ct);
}
if let Some(modified) = modified {
http::set_header(&mut resp, "last-modified", &http_date(modified));
}
resp
}
fn content_type_of(path: &Path) -> Option<String> {
let ext = path.extension()?.to_str()?.to_lowercase();
Some(match ext.as_str() {
"js" | "mjs" => "text/javascript".to_string(),
"html" | "htm" => "text/html".to_string(),
"wasm" => "application/wasm".to_string(),
_ => mime_guess::from_ext(&ext).first_raw()?.to_string(),
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn refuse_la_traversee() {
assert!(resolve("/app/web", "../etc/passwd").is_none());
assert!(resolve("/app/web", "a/%2e%2e/b").is_none());
assert!(resolve("/app/web", "a/..%2Fb").is_none());
assert_eq!(
resolve("/app/web", "vendor/hls.min.js").unwrap(),
PathBuf::from("/app/web").join("vendor").join("hls.min.js")
);
}
}
File diff suppressed because it is too large. Load diff
+91
View File
@@ -0,0 +1,91 @@
//! Passerelle authentifiée vers `player_api.php` du panneau Xtream.
//!
//! Le front ne voit jamais les identifiants Xtream : il appelle
//! `GET /api/xtream-api?action=...` avec son jeton de session et le serveur
//! injecte username/password de la playlist de l'utilisateur.
use crate::http::{self, Req, Resp};
use crate::state::AppState;
use crate::util::log_redactor::redact_url;
use axum::body::Body;
use futures::TryStreamExt;
use serde_json::json;
use std::time::Duration;
/// Paramètres que le client peut transmettre à Xtream.
const ALLOWED_PARAMS: [&str; 7] = [
"action",
"category_id",
"stream_id",
"series_id",
"vod_id",
"limit",
"type",
];
pub async fn handle(state: &AppState, req: &Req) -> Resp {
let Some(playlist) = state.get_playlist(req) else {
return http::json(403, &json!({"error": "No playlist configured"}));
};
let Ok(mut target) = reqwest::Url::parse(&format!("{}/player_api.php", playlist.dns)) else {
return http::json(500, &json!({"error": "Upstream Xtream API error"}));
};
{
let mut pairs = target.query_pairs_mut();
pairs.clear();
pairs.append_pair("username", &playlist.username);
pairs.append_pair("password", &playlist.password);
// Ordre et dédoublonnage de la query cliente, comme `queryParameters`.
let mut seen = Vec::new();
for (key, _) in url::form_urlencoded::parse(req.query.as_bytes()) {
if ALLOWED_PARAMS.contains(&key.as_ref()) && !seen.contains(&key) {
seen.push(key);
}
}
let params = req.query_params();
for key in seen {
if let Some(value) = params.get(key.as_ref()) {
pairs.append_pair(&key, value);
}
}
}
let send = state
.http
.get(target.clone())
.header("User-Agent", "VLC/3.0.18 LibVLC/3.0.18")
.header("Accept", "*/*")
.send();
let response = match tokio::time::timeout(Duration::from_secs(90), send).await {
Ok(Ok(r)) => r,
Ok(Err(e)) => return upstream_error(&target, &format!("{e:?}")),
Err(_) => return upstream_error(&target, "TimeoutException after 0:01:30.000000"),
};
let content_type = response
.headers()
.get("content-type")
.and_then(|v| v.to_str().ok())
.unwrap_or("application/json")
.to_string();
let status = response.status().as_u16();
http::with_headers(
http::response(
status,
Body::from_stream(response.bytes_stream().map_err(std::io::Error::other)),
),
&[
("content-type", &content_type),
("cache-control", "no-store"),
],
)
}
fn upstream_error(target: &reqwest::Url, error: &str) -> Resp {
println!(
"[XtreamApi] Error forwarding to {}: {}",
redact_url(target.as_str()),
redact_url(error)
);
http::json(500, &json!({"error": "Upstream Xtream API error"}))
}
+252
View File
@@ -0,0 +1,252 @@
//! Dates au format de l'ancien serveur Dart.
//!
//! POURQUOI : la base SQLite de production a été écrite par le serveur Dart.
//! Ses colonnes de dates contiennent la sortie de `DateTime.toIso8601String()`
//! (heure locale sans suffixe pour les sessions et les horodatages de
//! création, UTC avec `Z` pour les fenêtres d'enregistrement), et certaines
//! requêtes les comparent comme des chaînes (`expires_at < ?`,
//! `start_time BETWEEN ? AND ?`). Reproduire exactement ce format garde la
//! base existante lisible et les comparaisons justes, sans migration.
use chrono::{
DateTime, Datelike, Local, NaiveDate, NaiveDateTime, NaiveTime, TimeZone, Timelike, Utc,
};
use regex::Regex;
use std::sync::LazyLock;
/// Équivalent d'un `DateTime` Dart : un instant « mural » et un drapeau UTC.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct DartDateTime {
pub naive: NaiveDateTime,
pub utc: bool,
}
/// Grammaire de `DateTime.parse` (SDK Dart).
static PARSE_RE: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(concat!(
r"^([+-]?\d{4,6})-?(\d\d)-?(\d\d)",
r"(?:[ T](\d\d)(?::?(\d\d)(?::?(\d\d)(?:[.,](\d+))?)?)?",
r"( ?[zZ]| ?([-+])(\d\d)(?::?(\d\d))?)?)?$",
))
.unwrap()
});
fn truncate_micros(naive: NaiveDateTime) -> NaiveDateTime {
let micros = naive.and_utc().timestamp_subsec_micros();
naive.with_nanosecond(micros * 1000).unwrap_or(naive)
}
impl DartDateTime {
/// `DateTime.now()` : heure locale, précision microseconde.
pub fn now_local() -> Self {
Self {
naive: truncate_micros(Local::now().naive_local()),
utc: false,
}
}
pub fn from_utc(dt: DateTime<Utc>) -> Self {
Self {
naive: truncate_micros(dt.naive_utc()),
utc: true,
}
}
/// Instant local (`DateTime.toLocal()`).
pub fn local_from_utc(dt: DateTime<Utc>) -> Self {
Self {
naive: truncate_micros(dt.with_timezone(&Local).naive_local()),
utc: false,
}
}
/// `DateTime.tryParse`.
pub fn parse(raw: &str) -> Option<Self> {
let caps = PARSE_RE.captures(raw)?;
let num = |i: usize| -> Option<i64> { caps.get(i).map(|m| m.as_str().parse().ok())? };
let year = num(1)? as i32;
let month = num(2)? as u32;
let day = num(3)? as u32;
let hour = num(4).unwrap_or(0) as u32;
let minute = num(5).unwrap_or(0) as u32;
let second = num(6).unwrap_or(0) as u32;
let micros = caps
.get(7)
.map(|m| {
let digits = m.as_str().as_bytes();
let mut value: u32 = 0;
for i in 0..6 {
value *= 10;
if i < digits.len() {
value += (digits[i] - b'0') as u32;
}
}
value
})
.unwrap_or(0);
let date = NaiveDate::from_ymd_opt(year, month, day)?;
// Dart normalise les débordements (24:00, :60) ; on les ajoute en durée.
let base = NaiveTime::from_hms_micro_opt(0, 0, 0, micros)?;
let mut naive = date.and_time(base)
+ chrono::Duration::hours(hour as i64)
+ chrono::Duration::minutes(minute as i64)
+ chrono::Duration::seconds(second as i64);
let utc = caps.get(8).is_some();
if let Some(sign) = caps.get(9) {
let hours = num(10).unwrap_or(0);
let minutes = num(11).unwrap_or(0);
let offset = chrono::Duration::minutes(hours * 60 + minutes);
naive = if sign.as_str() == "-" {
naive + offset
} else {
naive - offset
};
}
Some(Self { naive, utc })
}
/// `DateTime.toIso8601String()`.
pub fn to_iso(self) -> String {
let n = &self.naive;
let year = n.year();
let year_str = if (0..=9999).contains(&year) {
format!("{year:04}")
} else if year < 0 {
format!("-{:06}", -year)
} else {
format!("+{year:06}")
};
let micros_total = n.nanosecond() / 1000;
let ms = micros_total / 1000;
let us = micros_total % 1000;
let mut out = format!(
"{year_str}-{:02}-{:02}T{:02}:{:02}:{:02}.{ms:03}",
n.month(),
n.day(),
n.hour(),
n.minute(),
n.second()
);
if us != 0 {
out.push_str(&format!("{us:03}"));
}
if self.utc {
out.push('Z');
}
out
}
/// Instant absolu (`DateTime.toUtc()`).
pub fn to_utc(self) -> DateTime<Utc> {
if self.utc {
return self.naive.and_utc();
}
match Local.from_local_datetime(&self.naive) {
chrono::LocalResult::Single(dt) => dt.with_timezone(&Utc),
chrono::LocalResult::Ambiguous(dt, _) => dt.with_timezone(&Utc),
// Heure qui n'existe pas (passage à l'heure d'été) : décaler d'une
// heure, comme le fait la libc.
chrono::LocalResult::None => Local
.from_local_datetime(&(self.naive + chrono::Duration::hours(1)))
.earliest()
.map(|dt| dt.with_timezone(&Utc))
.unwrap_or_else(|| self.naive.and_utc()),
}
}
/// `DateTime.toUtc()` gardé au format Dart.
pub fn as_utc(self) -> Self {
Self::from_utc(self.to_utc())
}
pub fn add(self, d: chrono::Duration) -> Self {
if self.utc {
Self::from_utc(self.to_utc() + d)
} else {
Self::local_from_utc(self.to_utc() + d)
}
}
}
/// `DateTime.now().toIso8601String()` (heure locale).
pub fn now_local_iso() -> String {
DartDateTime::now_local().to_iso()
}
/// Instant UTC au format Dart (`...Z`).
pub fn utc_iso(dt: DateTime<Utc>) -> String {
DartDateTime::from_utc(dt).to_iso()
}
/// `'${DateTime.now()}'` : format de `DateTime.toString()` (journaux).
pub fn now_display() -> String {
display_naive(DartDateTime::now_local().naive)
}
/// `'${instant.toLocal()}'` (journaux).
pub fn display_local(dt: DateTime<Utc>) -> String {
display_naive(DartDateTime::local_from_utc(dt).naive)
}
fn display_naive(n: NaiveDateTime) -> String {
let micros_total = n.nanosecond() / 1000;
let ms = micros_total / 1000;
let us = micros_total % 1000;
let mut out = format!(
"{:04}-{:02}-{:02} {:02}:{:02}:{:02}.{ms:03}",
n.year(),
n.month(),
n.day(),
n.hour(),
n.minute(),
n.second()
);
if us != 0 {
out.push_str(&format!("{us:03}"));
}
out
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn formate_comme_dart() {
let d = DartDateTime::parse("2026-08-11T18:00:00Z").unwrap();
assert!(d.utc);
assert_eq!(d.to_iso(), "2026-08-11T18:00:00.000Z");
let d = DartDateTime::parse("2026-08-11T18:00:00.123456").unwrap();
assert!(!d.utc);
assert_eq!(d.to_iso(), "2026-08-11T18:00:00.123456");
let d = DartDateTime::parse("2026-08-11T18:00:00.120").unwrap();
assert_eq!(d.to_iso(), "2026-08-11T18:00:00.120");
}
#[test]
fn lit_le_format_sqlite() {
let d = DartDateTime::parse("2026-10-12 12:03:22").unwrap();
assert!(!d.utc);
assert_eq!(d.to_iso(), "2026-10-12T12:03:22.000");
}
#[test]
fn applique_les_decalages() {
let d = DartDateTime::parse("2026-08-11T20:00:00+02:00").unwrap();
assert!(d.utc);
assert_eq!(d.to_iso(), "2026-08-11T18:00:00.000Z");
let d = DartDateTime::parse("2026-08-11T20:00:00-0130").unwrap();
assert_eq!(d.to_iso(), "2026-08-11T21:30:00.000Z");
}
#[test]
fn rejette_les_valeurs_invalides() {
assert!(DartDateTime::parse("").is_none());
assert!(DartDateTime::parse("abc").is_none());
assert!(DartDateTime::parse("2026-13-01").is_none());
}
}
+913
View File
@@ -0,0 +1,913 @@
//! Base SQLite (`/app/data/xtremflow.db`).
//!
//! Schéma, migrations et formats de dates identiques à l'ancien serveur
//! Dart : la base de production est reprise telle quelle au premier
//! démarrage du serveur Rust.
use crate::dart_time::{DartDateTime, now_local_iso, utc_iso};
use crate::models::{Playlist, Recording, Session, User};
use crate::util::password;
use chrono::{DateTime, Duration, Utc};
use regex::Regex;
use rusqlite::types::ValueRef;
use rusqlite::{Connection, OptionalExtension, Row, params};
use serde_json::{Map, Value};
use std::sync::{LazyLock, Mutex};
pub struct AppDatabase {
conn: Mutex<Connection>,
}
type DbResult<T> = Result<T, rusqlite::Error>;
fn parse_date(raw: Option<String>) -> DartDateTime {
raw.as_deref()
.and_then(DartDateTime::parse)
.unwrap_or_else(DartDateTime::now_local)
}
fn user_from_row(row: &Row) -> DbResult<User> {
Ok(User {
id: row.get("id")?,
username: row.get("username")?,
is_admin: row.get::<_, Option<i64>>("is_admin")?.unwrap_or(0) == 1,
created_at: parse_date(row.get("created_at")?),
})
}
fn playlist_from_row(row: &Row) -> DbResult<Playlist> {
Ok(Playlist {
id: row.get("id")?,
user_id: row.get("user_id")?,
name: row.get("name")?,
server_url: row.get("server_url")?,
username: row.get("username")?,
password: row.get("password")?,
dns: row.get("dns")?,
created_at: parse_date(row.get("created_at")?),
updated_at: parse_date(row.get("updated_at")?),
})
}
fn recording_from_row(row: &Row) -> DbResult<Recording> {
let text = |name: &str| -> DbResult<String> {
Ok(row.get::<_, Option<String>>(name)?.unwrap_or_default())
};
Ok(Recording {
id: text("id")?,
user_id: text("user_id")?,
channel_id: text("channel_id")?,
stream_url: text("stream_url")?,
title: text("title")?,
start_time: parse_date(row.get("start_time")?),
end_time: parse_date(row.get("end_time")?),
status: row
.get::<_, Option<String>>("status")?
.unwrap_or_else(|| "scheduled".into()),
file_path: row.get("file_path")?,
error_reason: row.get("error_reason")?,
created_at: parse_date(row.get("created_at")?),
updated_at: parse_date(row.get("updated_at")?),
})
}
/// 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();
let stmt = row.as_ref();
for i in 0..stmt.column_count() {
let name = stmt.column_name(i)?.to_string();
let value = match row.get_ref(i)? {
ValueRef::Null => Value::Null,
ValueRef::Integer(v) => Value::from(v),
ValueRef::Real(v) => Value::from(v),
ValueRef::Text(v) => Value::from(String::from_utf8_lossy(v).into_owned()),
ValueRef::Blob(v) => Value::from(String::from_utf8_lossy(v).into_owned()),
};
map.insert(name, value);
}
Ok(map)
}
/// Mot de passe initial sans caractères ambigus (0/O, 1/l/I) : il est
/// recopié depuis les logs du conteneur.
fn generate_password(length: usize) -> String {
const CHARS: &[u8] = b"abcdefghijkmnopqrstuvwxyzABCDEFGHJKLMNPQRSTUVWXYZ23456789";
let mut out = String::with_capacity(length);
while out.len() < length {
// Octets aléatoires du générateur système (via UUID v4), tirage par
// rejet pour une distribution uniforme.
for byte in uuid::Uuid::new_v4().into_bytes() {
let limit = 256 - (256 % CHARS.len());
if (byte as usize) < limit && out.len() < length {
out.push(CHARS[byte as usize % CHARS.len()] as char);
}
}
}
out
}
static GPU_SETTING: LazyLock<Regex> =
LazyLock::new(|| Regex::new(r#"(?i)"enable_nvidia_gpu"\s*:\s*true"#).unwrap());
impl AppDatabase {
/// Ouvre (ou crée) la base et applique schéma et migrations.
pub fn open(path: &str) -> DbResult<Self> {
if let Some(dir) = std::path::Path::new(path).parent() {
let _ = std::fs::create_dir_all(dir);
}
let conn = Connection::open(path)?;
// Sans foreign_keys, les ON DELETE CASCADE déclarés dans le schéma sont
// ignorés par SQLite : supprimer un utilisateur laissait ses sessions
// (donc des jetons valides), playlists et enregistrements orphelins.
conn.execute_batch(
"PRAGMA foreign_keys = ON; PRAGMA journal_mode = WAL; PRAGMA busy_timeout = 5000;",
)?;
let db = Self {
conn: Mutex::new(conn),
};
db.create_tables()?;
db.run_migrations()?;
println!("Database initialized: {path}");
Ok(db)
}
fn lock(&self) -> std::sync::MutexGuard<'_, Connection> {
self.conn.lock().unwrap_or_else(|e| e.into_inner())
}
fn create_tables(&self) -> DbResult<()> {
self.lock().execute_batch(
r#"
CREATE TABLE IF NOT EXISTS users (
id TEXT PRIMARY KEY,
username TEXT UNIQUE NOT NULL,
password_hash TEXT NOT NULL,
is_admin INTEGER DEFAULT 0,
created_at TEXT DEFAULT CURRENT_TIMESTAMP,
updated_at TEXT DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS playlists (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL,
name TEXT NOT NULL,
server_url TEXT NOT NULL,
username TEXT NOT NULL,
password TEXT NOT NULL,
dns TEXT,
created_at TEXT DEFAULT CURRENT_TIMESTAMP,
updated_at TEXT DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS sessions (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL,
token TEXT UNIQUE NOT NULL,
expires_at TEXT NOT NULL,
created_at TEXT DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS user_settings (
user_id TEXT PRIMARY KEY,
settings_json TEXT NOT NULL,
updated_at TEXT DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS tv_recordings (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL,
channel_id TEXT NOT NULL,
stream_url TEXT NOT NULL,
title TEXT NOT NULL,
start_time TEXT NOT NULL,
end_time TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'scheduled',
file_path TEXT,
error_reason TEXT,
created_at TEXT DEFAULT CURRENT_TIMESTAMP,
updated_at TEXT DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS season_passes (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL,
show_title TEXT NOT NULL,
channel_id TEXT NOT NULL,
stream_url TEXT NOT NULL,
enabled INTEGER DEFAULT 1,
match_mode TEXT NOT NULL DEFAULT 'exact',
created_at TEXT DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users(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);
CREATE INDEX IF NOT EXISTS idx_recordings_status ON tv_recordings(status);
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);
"#,
)
}
/// Migrations de schéma pour les bases créées par une version antérieure.
///
/// `CREATE TABLE IF NOT EXISTS` n'ajoute jamais de colonne à une table
/// existante : chaque colonne introduite après coup a sa migration,
/// numérotée et rejouée uniquement si nécessaire.
fn run_migrations(&self) -> DbResult<()> {
let conn = self.lock();
conn.execute(
"CREATE TABLE IF NOT EXISTS schema_version (version INTEGER PRIMARY KEY)",
[],
)?;
let mut current: i64 = conn.query_row(
"SELECT COALESCE(MAX(version), 0) AS v FROM schema_version",
[],
|r| r.get(0),
)?;
let migrations: [(i64, &str, &str, &str); 2] = [
// 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
// lignes existantes (comportement historique) ; les nouvelles
// créations passent par l'API qui choisit 'exact' par défaut.
(
2,
"season_passes",
"match_mode",
"TEXT NOT NULL DEFAULT 'contains'",
),
];
for (version, table, column, definition) in migrations {
if version <= current {
continue;
}
let exists = {
let mut stmt = conn.prepare(&format!("PRAGMA table_info({table})"))?;
let names = stmt.query_map([], |r| r.get::<_, String>("name"))?;
let mut found = false;
for name in names {
if name? == column {
found = true;
}
}
found
};
if !exists {
conn.execute(
&format!("ALTER TABLE {table} ADD COLUMN {column} {definition}"),
[],
)?;
}
conn.execute("INSERT INTO schema_version (version) VALUES (?)", [version])?;
current = version;
println!("[DB] Migration v{version} appliquée");
}
Ok(())
}
/// Crée le compte admin s'il n'existe aucun utilisateur.
///
/// Le mot de passe initial vient de ADMIN_INITIAL_PASSWORD, ou est généré
/// aléatoirement et affiché UNE FOIS dans les logs de démarrage.
pub fn seed_admin(&self) -> DbResult<()> {
let count: i64 = self
.lock()
.query_row("SELECT COUNT(*) as count FROM users", [], |r| r.get(0))?;
if count != 0 {
return Ok(());
}
let env_password = std::env::var("ADMIN_INITIAL_PASSWORD").unwrap_or_default();
let generated = env_password.is_empty();
let password = if generated {
generate_password(16)
} else {
env_password
};
let hash = password::hash(&password).expect("bcrypt");
self.lock().execute(
"INSERT INTO users (id, username, password_hash, is_admin) VALUES (?, ?, ?, 1)",
params![uuid::Uuid::new_v4().to_string(), "admin", hash],
)?;
if generated {
println!("╔══════════════════════════════════════════════════════════╗");
println!(" Compte admin créé — mot de passe initial (affiché une");
println!(" seule fois, changez-le après la première connexion) :");
println!(" utilisateur: admin");
println!(" mot de passe: {password}");
println!("╚══════════════════════════════════════════════════════════╝");
} else {
println!(
"Default admin user created (username: admin, password: ADMIN_INITIAL_PASSWORD)"
);
}
Ok(())
}
// ==================== Users ====================
pub fn find_user_by_username(&self, username: &str) -> Option<User> {
self.lock()
.query_row(
"SELECT * FROM users WHERE username = ?",
[username],
user_from_row,
)
.optional()
.ok()
.flatten()
}
pub fn find_user_by_id(&self, user_id: &str) -> Option<User> {
self.lock()
.query_row("SELECT * FROM users WHERE id = ?", [user_id], user_from_row)
.optional()
.ok()
.flatten()
}
/// Vérifie les identifiants. Le hachage bcrypt (coûteux) est fait hors
/// du verrou de la base : à appeler depuis `spawn_blocking`.
pub fn verify_credentials(&self, username: &str, password: &str) -> Option<User> {
let (user, stored) = self
.lock()
.query_row(
"SELECT * FROM users WHERE username = ?",
[username],
|row| Ok((user_from_row(row)?, row.get::<_, String>("password_hash")?)),
)
.optional()
.ok()
.flatten()?;
if !password::verify(password, &stored) {
return None;
}
// Migration paresseuse : les anciens hachages SHA-256 sont refaits en
// bcrypt à la connexion (seul moment où le mot de passe est connu).
if password::is_legacy(&stored)
&& let Ok(hash) = password::hash(password)
{
self.update_password_hash(&user.id, &hash);
println!("[Auth] Migrated legacy password hash for user: {username}");
}
Some(user)
}
pub fn update_password_hash(&self, user_id: &str, hash: &str) {
let _ = self.lock().execute(
"UPDATE users SET password_hash = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?",
params![hash, user_id],
);
}
/// Crée un utilisateur ; [password_hash] est déjà calculé par l'appelant.
pub fn create_user(
&self,
username: &str,
password_hash: &str,
is_admin: bool,
) -> DbResult<User> {
let id = uuid::Uuid::new_v4().to_string();
self.lock().execute(
"INSERT INTO users (id, username, password_hash, is_admin) VALUES (?, ?, ?, ?)",
params![id, username, password_hash, is_admin as i64],
)?;
Ok(User {
id,
username: username.to_string(),
is_admin,
created_at: DartDateTime::now_local(),
})
}
pub fn get_all_users(&self) -> Vec<User> {
let conn = self.lock();
let Ok(mut stmt) = conn.prepare("SELECT * FROM users ORDER BY username ASC") else {
return Vec::new();
};
stmt.query_map([], user_from_row)
.map(|rows| rows.filter_map(Result::ok).collect())
.unwrap_or_default()
}
pub fn update_user_admin_status(&self, user_id: &str, is_admin: bool) -> DbResult<()> {
self.lock().execute(
"UPDATE users SET is_admin = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?",
params![is_admin as i64, user_id],
)?;
Ok(())
}
pub fn delete_user(&self, user_id: &str) -> DbResult<()> {
self.lock()
.execute("DELETE FROM users WHERE id = ?", [user_id])?;
Ok(())
}
// ==================== Sessions ====================
pub fn create_session(&self, user_id: &str) -> DbResult<Session> {
let token = uuid::Uuid::new_v4().to_string();
let expires_at = DartDateTime::now_local().add(Duration::days(7));
self.lock().execute(
"INSERT INTO sessions (id, user_id, token, expires_at) VALUES (?, ?, ?, ?)",
params![
uuid::Uuid::new_v4().to_string(),
user_id,
token,
expires_at.to_iso()
],
)?;
Ok(Session {
user_id: user_id.to_string(),
token,
expires_at,
})
}
/// Session valide pour [token] ; une session expirée est supprimée.
pub fn find_session_by_token(&self, token: &str) -> Option<Session> {
let session = self
.lock()
.query_row("SELECT * FROM sessions WHERE token = ?", [token], |row| {
Ok(Session {
user_id: row.get("user_id")?,
token: row.get("token")?,
expires_at: parse_date(row.get("expires_at")?),
})
})
.optional()
.ok()
.flatten()?;
if session.is_expired() {
self.delete_session(token);
return None;
}
Some(session)
}
pub fn delete_session(&self, token: &str) {
let _ = self
.lock()
.execute("DELETE FROM sessions WHERE token = ?", [token]);
}
pub fn clean_expired_sessions(&self) {
let _ = self.lock().execute(
"DELETE FROM sessions WHERE expires_at < ?",
[now_local_iso()],
);
}
// ==================== Playlists ====================
pub fn get_playlists(&self, user_id: &str) -> Vec<Playlist> {
let conn = self.lock();
let Ok(mut stmt) =
conn.prepare("SELECT * FROM playlists WHERE user_id = ? ORDER BY created_at DESC")
else {
return Vec::new();
};
stmt.query_map([user_id], playlist_from_row)
.map(|rows| rows.filter_map(Result::ok).collect())
.unwrap_or_default()
}
pub fn get_playlist_by_id(&self, id: &str) -> Option<Playlist> {
self.lock()
.query_row(
"SELECT * FROM playlists WHERE id = ?",
[id],
playlist_from_row,
)
.optional()
.ok()
.flatten()
}
#[allow(clippy::too_many_arguments)]
pub fn create_playlist(
&self,
user_id: &str,
name: &str,
server_url: &str,
username: &str,
password: &str,
dns: Option<&str>,
) -> DbResult<Playlist> {
let id = uuid::Uuid::new_v4().to_string();
let now = DartDateTime::now_local();
let now_iso = now.to_iso();
self.lock().execute(
"INSERT INTO playlists (id, user_id, name, server_url, username, password, dns, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)",
params![id, user_id, name, server_url, username, password, dns, now_iso, now_iso],
)?;
Ok(Playlist {
id,
user_id: user_id.into(),
name: name.into(),
server_url: server_url.into(),
username: username.into(),
password: password.into(),
dns: dns.map(str::to_string),
created_at: now,
updated_at: now,
})
}
pub fn update_playlist(
&self,
id: &str,
name: &str,
server_url: &str,
username: &str,
password: &str,
dns: Option<&str>,
) -> DbResult<Option<Playlist>> {
self.lock().execute(
"UPDATE playlists SET name = ?, server_url = ?, username = ?, password = ?, dns = ?, updated_at = ? WHERE id = ?",
params![name, server_url, username, password, dns, now_local_iso(), id],
)?;
Ok(self.get_playlist_by_id(id))
}
pub fn delete_playlist(&self, id: &str) -> DbResult<()> {
self.lock()
.execute("DELETE FROM playlists WHERE id = ?", [id])?;
Ok(())
}
// ==================== User Settings ====================
pub fn get_user_settings(&self, user_id: &str) -> Option<String> {
self.lock()
.query_row(
"SELECT settings_json FROM user_settings WHERE user_id = ?",
[user_id],
|r| r.get(0),
)
.optional()
.ok()
.flatten()
}
pub fn update_user_settings(&self, user_id: &str, json: &str) -> DbResult<()> {
self.lock().execute(
"INSERT INTO user_settings (user_id, settings_json, updated_at) VALUES (?, ?, ?)
ON CONFLICT(user_id) DO UPDATE SET settings_json = excluded.settings_json, updated_at = excluded.updated_at",
params![user_id, json, now_local_iso()],
)?;
Ok(())
}
/// Réglage GPU NVIDIA, global au serveur : celui des derniers réglages
/// enregistrés, tous utilisateurs confondus.
pub fn is_nvidia_gpu_enabled(&self) -> bool {
let settings: Option<String> = self
.lock()
.query_row(
"SELECT settings_json FROM user_settings ORDER BY updated_at DESC LIMIT 1",
[],
|r| r.get(0),
)
.optional()
.ok()
.flatten();
settings.is_some_and(|json| GPU_SETTING.is_match(&json))
}
// ==================== TV Recordings ====================
pub fn create_recording(
&self,
user_id: &str,
channel_id: &str,
stream_url: &str,
title: &str,
start: DateTime<Utc>,
end: DateTime<Utc>,
) -> DbResult<Recording> {
let id = uuid::Uuid::new_v4().to_string();
let now = DartDateTime::now_local();
// Toujours stocker en UTC avec suffixe 'Z' : les comparaisons du
// scheduler et la déduplication des season passes reposent sur ce
// format unique.
let start = DartDateTime::from_utc(start);
let end = DartDateTime::from_utc(end);
self.lock().execute(
"INSERT INTO tv_recordings (id, user_id, channel_id, stream_url, title, start_time, end_time, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)",
params![
id,
user_id,
channel_id,
stream_url,
title,
start.to_iso(),
end.to_iso(),
now.to_iso(),
now.to_iso()
],
)?;
Ok(Recording {
id,
user_id: user_id.into(),
channel_id: channel_id.into(),
stream_url: stream_url.into(),
title: title.into(),
start_time: start,
end_time: end,
status: "scheduled".into(),
file_path: None,
error_reason: None,
created_at: now,
updated_at: now,
})
}
fn query_recordings(&self, sql: &str, args: &[&str]) -> Vec<Recording> {
let conn = self.lock();
let Ok(mut stmt) = conn.prepare(sql) else {
return Vec::new();
};
stmt.query_map(rusqlite::params_from_iter(args.iter()), recording_from_row)
.map(|rows| rows.filter_map(Result::ok).collect())
.unwrap_or_default()
}
pub fn get_all_recordings(&self) -> Vec<Recording> {
self.query_recordings("SELECT * FROM tv_recordings ORDER BY start_time ASC", &[])
}
/// Enregistrements qui intéressent le scheduler : à lancer ou en cours.
pub fn get_pending_recordings(&self) -> Vec<Recording> {
self.query_recordings(
"SELECT * FROM tv_recordings WHERE status IN ('scheduled', 'recording') ORDER BY start_time ASC",
&[],
)
}
/// Enregistrements terminés, du plus ancien au plus récent (rotation disque).
pub fn get_finished_recordings_oldest_first(&self) -> Vec<Recording> {
self.query_recordings(
"SELECT * FROM tv_recordings WHERE status IN ('completed', 'failed', 'cancelled') ORDER BY start_time ASC",
&[],
)
}
pub fn get_user_recordings(&self, user_id: &str) -> Vec<Recording> {
self.query_recordings(
"SELECT * FROM tv_recordings WHERE user_id = ? ORDER BY start_time ASC",
&[user_id],
)
}
pub fn get_recording_by_id(&self, id: &str) -> Option<Recording> {
self.query_recordings("SELECT * FROM tv_recordings WHERE id = ?", &[id])
.into_iter()
.next()
}
/// Met à jour le statut et éventuellement le chemin d'un enregistrement.
///
/// [clear_error] efface le motif d'erreur au lieu de conserver l'ancien :
/// utile quand un enregistrement précédemment en échec est relancé.
pub fn update_recording_status(
&self,
id: &str,
status: &str,
file_path: Option<&str>,
error_reason: Option<&str>,
clear_error: bool,
) {
let error_expr = if clear_error {
"?"
} else {
"COALESCE(?, error_reason)"
};
let sql = format!(
"UPDATE tv_recordings SET status = ?, file_path = COALESCE(?, file_path), error_reason = {error_expr}, updated_at = ? WHERE id = ?"
);
if let Err(e) = self.lock().execute(
&sql,
params![status, file_path, error_reason, now_local_iso(), id],
) {
println!("[DB] Mise à jour du statut de {id} impossible: {e}");
}
}
/// Réaligne la fenêtre d'un enregistrement planifié (programme déplacé
/// dans l'EPG depuis sa création par un season pass).
pub fn update_recording_window(&self, id: &str, start: DateTime<Utc>, end: DateTime<Utc>) {
let _ = self.lock().execute(
"UPDATE tv_recordings SET start_time = ?, end_time = ?, updated_at = ? WHERE id = ?",
params![utc_iso(start), utc_iso(end), now_local_iso(), id],
);
}
/// Supprime l'enregistrement de la base (pas le fichier).
pub fn delete_recording(&self, id: &str) {
let _ = self
.lock()
.execute("DELETE FROM tv_recordings WHERE id = ?", [id]);
}
// ==================== Season Passes ====================
pub fn create_season_pass(
&self,
user_id: &str,
show_title: &str,
channel_id: &str,
stream_url: &str,
match_mode: &str,
) -> DbResult<Map<String, Value>> {
let id = uuid::Uuid::new_v4().to_string();
let now = now_local_iso();
self.lock().execute(
"INSERT INTO season_passes (id, user_id, show_title, channel_id, stream_url, match_mode, created_at) VALUES (?, ?, ?, ?, ?, ?, ?)",
params![id, user_id, show_title, channel_id, stream_url, match_mode, now],
)?;
let mut map = Map::new();
map.insert("id".into(), Value::from(id));
map.insert("user_id".into(), Value::from(user_id));
map.insert("show_title".into(), Value::from(show_title));
map.insert("channel_id".into(), Value::from(channel_id));
map.insert("stream_url".into(), Value::from(stream_url));
map.insert("match_mode".into(), Value::from(match_mode));
map.insert("enabled".into(), Value::from(1));
map.insert("created_at".into(), Value::from(now));
Ok(map)
}
fn query_json(&self, sql: &str, args: &[&str]) -> Vec<Map<String, Value>> {
let conn = self.lock();
let Ok(mut stmt) = conn.prepare(sql) else {
return Vec::new();
};
stmt.query_map(rusqlite::params_from_iter(args.iter()), row_to_json)
.map(|rows| rows.filter_map(Result::ok).collect())
.unwrap_or_default()
}
/// Season Passes actifs (pour le scheduler).
pub fn get_all_season_passes(&self) -> Vec<Map<String, Value>> {
self.query_json(
"SELECT * FROM season_passes WHERE enabled = 1 ORDER BY created_at DESC",
&[],
)
}
pub fn get_season_passes_for_user(&self, user_id: &str) -> Vec<Map<String, Value>> {
self.query_json(
"SELECT * FROM season_passes WHERE user_id = ? ORDER BY created_at DESC",
&[user_id],
)
}
pub fn get_season_pass_by_id(&self, id: &str) -> Option<Map<String, Value>> {
self.query_json("SELECT * FROM season_passes WHERE id = ?", &[id])
.into_iter()
.next()
}
pub fn delete_season_pass(&self, id: &str) {
let _ = self
.lock()
.execute("DELETE FROM season_passes WHERE id = ?", [id]);
}
/// Enregistrement existant pour cet épisode (déduplication des season
/// passes) : même titre, début à ±[tolerance] près.
///
/// Les dates sont stockées en ISO-8601 UTC : l'ordre lexicographique
/// correspond à l'ordre chronologique, un BETWEEN sur chaînes suffit.
pub fn find_recording_for_episode(
&self,
title: &str,
start: DateTime<Utc>,
tolerance: Duration,
) -> Option<Recording> {
let low = utc_iso(start - tolerance);
let high = utc_iso(start + tolerance);
self.query_recordings(
"SELECT * FROM tv_recordings WHERE LOWER(title) = LOWER(?) AND start_time BETWEEN ? AND ? AND status NOT IN ('failed', 'cancelled') LIMIT 1",
&[title, &low, &high],
)
.into_iter()
.next()
}
}
#[cfg(test)]
mod tests {
use super::*;
fn temp_db() -> (AppDatabase, std::path::PathBuf) {
let path = std::env::temp_dir().join(format!("xf-test-{}.db", uuid::Uuid::new_v4()));
let db = AppDatabase::open(path.to_str().unwrap()).unwrap();
(db, path)
}
#[test]
fn sessions_et_cascade() {
let (db, _path) = temp_db();
let hash = password::hash("password1").unwrap();
let user = db.create_user("bob", &hash, false).unwrap();
let session = db.create_session(&user.id).unwrap();
assert!(db.find_session_by_token(&session.token).is_some());
assert!(db.verify_credentials("bob", "password1").is_some());
assert!(db.verify_credentials("bob", "nope").is_none());
// ON DELETE CASCADE actif : la session disparaît avec l'utilisateur.
db.delete_user(&user.id).unwrap();
assert!(db.find_session_by_token(&session.token).is_none());
}
#[test]
fn migration_paresseuse_du_sha256() {
let (db, _path) = temp_db();
let legacy = "8c6976e5b5410415bde908bd4dee15dfb167a9c873fc4bb8a81f6f2ab448a918";
let user = db.create_user("old", legacy, false).unwrap();
assert!(db.verify_credentials("old", "admin").is_some());
let stored: String = db
.lock()
.query_row(
"SELECT password_hash FROM users WHERE id = ?",
[&user.id],
|r| r.get(0),
)
.unwrap();
assert!(!password::is_legacy(&stored));
assert!(db.verify_credentials("old", "admin").is_some());
}
#[test]
fn deduplication_des_episodes_a_deux_minutes_pres() {
let (db, _path) = temp_db();
let user = db.create_user("u", "x", false).unwrap();
let start = DartDateTime::parse("2026-08-11T18:00:00Z")
.unwrap()
.to_utc();
let end = start + Duration::hours(1);
db.create_recording(&user.id, "1", "/api/live/1.ts", "Journal", start, end)
.unwrap();
assert!(
db.find_recording_for_episode(
"JOURNAL",
start + Duration::seconds(90),
Duration::minutes(2)
)
.is_some()
);
assert!(
db.find_recording_for_episode(
"Journal",
start + Duration::minutes(5),
Duration::minutes(2)
)
.is_none()
);
let rec = db.get_all_recordings().pop().unwrap();
assert_eq!(rec.start_time.to_iso(), "2026-08-11T18:00:00.000Z");
assert_eq!(rec.status, "scheduled");
}
#[test]
fn statut_et_motif_d_erreur() {
let (db, _path) = temp_db();
let user = db.create_user("u", "x", false).unwrap();
let now = Utc::now();
let rec = db
.create_recording(&user.id, "1", "/x", "T", now, now + Duration::hours(1))
.unwrap();
db.update_recording_status(&rec.id, "failed", None, Some("boom"), false);
db.update_recording_status(&rec.id, "failed", Some("/f.mkv"), None, false);
let r = db.get_recording_by_id(&rec.id).unwrap();
assert_eq!(r.error_reason.as_deref(), Some("boom"));
assert_eq!(r.file_path.as_deref(), Some("/f.mkv"));
db.update_recording_status(&rec.id, "recording", None, None, true);
let r = db.get_recording_by_id(&rec.id).unwrap();
assert_eq!(r.error_reason, None);
assert_eq!(r.file_path.as_deref(), Some("/f.mkv"));
}
#[test]
fn reglage_gpu() {
let (db, _path) = temp_db();
let user = db.create_user("u", "x", false).unwrap();
assert!(!db.is_nvidia_gpu_enabled());
db.update_user_settings(&user.id, r#"{"enable_nvidia_gpu": true}"#)
.unwrap();
assert!(db.is_nvidia_gpu_enabled());
}
#[test]
fn mot_de_passe_genere() {
let p = generate_password(16);
assert_eq!(p.len(), 16);
assert!(!p.contains(['0', 'O', '1', 'l', 'I']));
}
}
+280
View File
@@ -0,0 +1,280 @@
//! Requête/réponse HTTP internes et petits utilitaires partagés.
//!
//! Le routage reproduit la `Cascade` shelf de l'ancien serveur : chaque
//! étage (API, proxy Xtream, streaming, fichiers statiques) répond ou passe
//! la main par un 404/405. Le corps des requêtes est lu une fois pour toutes
//! en amont, ce qui permet de présenter la même requête à plusieurs étages.
use crate::models::User;
use axum::body::Body;
use axum::http::{HeaderMap, HeaderName, HeaderValue, Method, StatusCode};
use bytes::Bytes;
use serde_json::Value;
use std::collections::HashMap;
use std::net::{IpAddr, SocketAddr};
pub type Resp = axum::response::Response;
#[derive(Clone)]
pub struct Req {
pub method: Method,
/// Chemin sans `/` initial, tel que reçu (encodé), comme `request.url.path`.
pub path: String,
/// Query string brute, sans `?`.
pub query: String,
pub headers: HeaderMap,
pub body: Bytes,
pub remote: Option<SocketAddr>,
/// Renseignés par le middleware d'authentification.
pub user: Option<User>,
pub user_id: Option<String>,
}
impl Req {
pub fn header(&self, name: &str) -> Option<&str> {
self.headers.get(name).and_then(|v| v.to_str().ok())
}
/// Paramètres de query décodés (`+` = espace) ; la dernière occurrence
/// d'une clé l'emporte, comme `Uri.queryParameters`.
pub fn query_params(&self) -> HashMap<String, String> {
url::form_urlencoded::parse(self.query.as_bytes())
.into_owned()
.collect()
}
pub fn query_param(&self, key: &str) -> Option<String> {
self.query_params().remove(key)
}
pub fn is_get(&self) -> bool {
self.method == Method::GET || self.method == Method::HEAD
}
pub fn body_str(&self) -> String {
String::from_utf8_lossy(&self.body).into_owned()
}
}
// ==================== Réponses ====================
pub fn response(status: u16, body: impl Into<Body>) -> Resp {
let mut resp = Resp::new(body.into());
*resp.status_mut() = StatusCode::from_u16(status).unwrap_or(StatusCode::INTERNAL_SERVER_ERROR);
resp
}
/// Corps texte (`text/plain; charset=utf-8`, le défaut de shelf).
pub fn text(status: u16, body: impl Into<String>) -> Resp {
let mut resp = response(status, body.into());
set_header(&mut resp, "content-type", "text/plain; charset=utf-8");
resp
}
pub fn empty(status: u16) -> Resp {
response(status, Body::empty())
}
/// JSON avec l'en-tête `Content-Type: application/json`.
pub fn json(status: u16, value: &Value) -> Resp {
raw_json(status, value.to_string())
}
pub fn raw_json(status: u16, body: String) -> Resp {
let mut resp = response(status, body);
set_header(&mut resp, "content-type", "application/json");
resp
}
/// JSON sans en-tête de type (les `Response.forbidden(jsonEncode(...))` de
/// l'ancien serveur, servis en `text/plain`).
pub fn json_as_text(status: u16, value: &Value) -> Resp {
text(status, value.to_string())
}
pub fn redirect(location: &str) -> Resp {
let mut resp = text(302, format!("Moved to {location}"));
set_header(&mut resp, "location", location);
resp
}
pub fn set_header(resp: &mut Resp, name: &str, value: &str) {
if let (Ok(name), Ok(value)) = (
HeaderName::from_bytes(name.as_bytes()),
HeaderValue::from_str(value),
) {
resp.headers_mut().insert(name, value);
}
}
pub fn with_headers(mut resp: Resp, headers: &[(&str, &str)]) -> Resp {
for (name, value) in headers {
set_header(&mut resp, name, value);
}
resp
}
/// La `Cascade` passe à l'étage suivant sur un 404 ou un 405.
pub fn is_miss(resp: &Resp) -> bool {
matches!(resp.status().as_u16(), 404 | 405)
}
pub fn not_found() -> Resp {
text(404, "Not Found")
}
// ==================== Authentification ====================
/// Jeton de session : en-tête `Authorization: Bearer …` ou cookie `session`.
pub fn extract_token(req: &Req) -> Option<String> {
if let Some(auth) = req.header("authorization")
&& let Some(token) = auth.strip_prefix("Bearer ")
{
return Some(token.to_string());
}
let cookie = req.header("cookie")?;
cookie
.split(';')
.map(str::trim)
.find_map(|part| part.strip_prefix("session=").map(str::to_string))
}
// ==================== Adresse du client ====================
/// Proxys de confiance dont l'en-tête X-Forwarded-For est honoré.
/// Par défaut : loopback et plages privées RFC1918 (le reverse proxy du
/// docker-compose parle depuis le réseau Docker). TRUSTED_PROXIES (liste
/// d'IP séparées par des virgules) restreint la liste.
pub fn is_trusted_proxy(address: &IpAddr, trusted: &[String]) -> bool {
if !trusted.is_empty() {
return trusted.iter().any(|t| t == &address.to_string());
}
match address {
IpAddr::V4(v4) => {
let o = v4.octets();
v4.is_loopback()
|| o[0] == 10
|| (o[0] == 172 && (16..=31).contains(&o[1]))
|| (o[0] == 192 && o[1] == 168)
}
IpAddr::V6(v6) => {
v6.is_loopback() || v6.to_ipv4_mapped().is_some_and(|v4| v4.is_loopback())
}
}
}
/// Adresse socket au format texte (IPv4 « nue » pour les adresses mappées).
pub fn socket_ip(req: &Req) -> Option<IpAddr> {
req.remote.map(|addr| match addr.ip() {
IpAddr::V6(v6) => v6
.to_ipv4_mapped()
.map(IpAddr::V4)
.unwrap_or(IpAddr::V6(v6)),
ip => ip,
})
}
/// IP réelle du client.
///
/// X-Forwarded-For n'est honoré que si la connexion socket provient d'un
/// proxy de confiance : sinon un client direct peut forger l'en-tête et
/// contourner le rate limit global comme la limite de tentatives de login.
pub fn client_ip(req: &Req, trusted: &[String]) -> String {
let socket = socket_ip(req);
if let (Some(forwarded), Some(ip)) = (req.header("x-forwarded-for"), socket)
&& !forwarded.is_empty()
&& is_trusted_proxy(&ip, trusted)
{
return forwarded.split(',').next().unwrap_or("").trim().to_string();
}
socket
.map(|ip| ip.to_string())
.unwrap_or_else(|| "unknown".to_string())
}
/// Segments d'un sous-chemin monté (`""` → aucun segment).
pub fn segments(rest: &str) -> Vec<&str> {
let rest = rest.trim_start_matches('/');
if rest.is_empty() {
Vec::new()
} else {
rest.split('/').collect()
}
}
/// Sous-chemin d'un montage shelf (`mount('/api/x')`) : `Some("")` pour
/// `api/x` ou `api/x/`, `Some("a/b")` pour `api/x/a/b`, `None` sinon.
pub fn mounted<'a>(path: &'a str, prefix: &str) -> Option<&'a str> {
if path == prefix {
return Some("");
}
path.strip_prefix(prefix)?.strip_prefix('/')
}
#[cfg(test)]
mod tests {
use super::*;
fn req_with(headers: &[(&str, &str)], remote: &str) -> Req {
let mut map = HeaderMap::new();
for (k, v) in headers {
map.insert(
HeaderName::from_bytes(k.as_bytes()).unwrap(),
HeaderValue::from_str(v).unwrap(),
);
}
Req {
method: Method::GET,
path: String::new(),
query: String::new(),
headers: map,
body: Bytes::new(),
remote: Some(remote.parse().unwrap()),
user: None,
user_id: None,
}
}
#[test]
fn jeton_par_en_tete_ou_cookie() {
assert_eq!(
extract_token(&req_with(&[("authorization", "Bearer abc")], "1.2.3.4:1")),
Some("abc".into())
);
assert_eq!(
extract_token(&req_with(
&[("cookie", "a=1; session=xyz; b=2")],
"1.2.3.4:1"
)),
Some("xyz".into())
);
assert_eq!(extract_token(&req_with(&[], "1.2.3.4:1")), None);
}
#[test]
fn x_forwarded_for_seulement_derriere_un_proxy_de_confiance() {
let direct = req_with(&[("x-forwarded-for", "9.9.9.9")], "8.8.8.8:1");
assert_eq!(client_ip(&direct, &[]), "8.8.8.8");
let proxied = req_with(&[("x-forwarded-for", "9.9.9.9, 10.0.0.1")], "172.18.0.2:1");
assert_eq!(client_ip(&proxied, &[]), "9.9.9.9");
assert_eq!(client_ip(&proxied, &["10.1.1.1".into()]), "172.18.0.2");
}
#[test]
fn montage_shelf() {
assert_eq!(mounted("api/playlists", "api/playlists"), Some(""));
assert_eq!(mounted("api/playlists/", "api/playlists"), Some(""));
assert_eq!(mounted("api/playlists/x", "api/playlists"), Some("x"));
assert_eq!(mounted("api/playlistsx", "api/playlists"), None);
}
#[test]
fn parametres_de_query() {
let mut req = req_with(&[], "1.2.3.4:1");
req.query = "a=1&b=x+y&a=2&c=%C3%A9".into();
let params = req.query_params();
assert_eq!(params["a"], "2");
assert_eq!(params["b"], "x y");
assert_eq!(params["c"], "é");
}
}
+253
View File
@@ -0,0 +1,253 @@
//! Serveur XtremFlow : API REST, proxy Xtream, streaming FFmpeg,
//! enregistrements TV et fichiers de la build web Flutter.
#![allow(clippy::result_large_err)]
mod api;
mod dart_time;
mod db;
mod http;
mod models;
mod server;
mod services;
mod state;
mod util;
use crate::api::epg::{EpgApi, default_panel_xmltv};
use crate::api::proxy::ProxyHandler;
use crate::api::streaming::Streaming;
use crate::db::AppDatabase;
use crate::server::RateLimiter;
use crate::services::cleanup::CleanupService;
use crate::services::ffmpeg_sessions::FfmpegSessionManager;
use crate::services::logo_catalog::LogoCatalog;
use crate::services::recording_scheduler::RecordingScheduler;
use crate::services::xmltv::{XmltvConfig, XmltvEpgService};
use crate::state::AppState;
use std::net::SocketAddr;
use std::sync::Arc;
use std::time::Duration;
const DEFAULT_XMLTV: &str = "https://epgshare01.online/epgshare01/epg_ripper_FR1.xml.gz,https://epgshare01.online/epgshare01/epg_ripper_CH1.xml.gz,https://epgshare01.online/epgshare01/epg_ripper_BE2.xml.gz";
struct Args {
port: u16,
path: String,
}
/// `--port 8089` / `-p 8089` / `--port=8089`, `--path /app/web`.
fn parse_args() -> Args {
let mut args = Args {
port: 8089,
path: "/app/web".to_string(),
};
let raw: Vec<String> = std::env::args().skip(1).collect();
let mut i = 0;
while i < raw.len() {
let (key, inline) = match raw[i].split_once('=') {
Some((k, v)) => (k.to_string(), Some(v.to_string())),
None => (raw[i].clone(), None),
};
let mut value = || {
inline.clone().or_else(|| {
i += 1;
raw.get(i).cloned()
})
};
match key.as_str() {
"--port" | "-p" => {
if let Some(v) = value() {
args.port = v.parse().expect("--port doit être un entier");
}
}
"--path" => {
if let Some(v) = value() {
args.path = v;
}
}
_ => {}
}
i += 1;
}
args
}
fn http_client(redirects: reqwest::redirect::Policy) -> reqwest::Client {
reqwest::Client::builder()
.redirect(redirects)
.user_agent("VLC/3.0.18 LibVLC/3.0.18")
.connect_timeout(Duration::from_secs(30))
.build()
.expect("client HTTP")
}
#[tokio::main]
async fn main() {
let args = parse_args();
let web_path = args.path.clone();
let db_path =
std::env::var("XTREMFLOW_DB_PATH").unwrap_or_else(|_| "/app/data/xtremflow.db".into());
let db = Arc::new(AppDatabase::open(&db_path).expect("ouverture de la base"));
db.seed_admin().expect("création du compte admin");
let http = http_client(reqwest::redirect::Policy::limited(5));
let http_manual = http_client(reqwest::redirect::Policy::none());
// Planificateur d'enregistrements (les season passes résolvent la
// playlist de leur propriétaire à chaque scan).
let scheduler = Arc::new(RecordingScheduler::new(db.clone(), http.clone()));
scheduler.start();
// Sessions FFmpeg de streaming.
let sessions = Arc::new(FfmpegSessionManager::new(
std::env::temp_dir().join("xtremflow_streams"),
));
sessions.init();
let streaming = Arc::new(Streaming::new(sessions.clone(), http.clone()));
// Source XMLTV de repli. EPG_XMLTV_URLS vide = aucun appel sortant : le
// guide se limite alors au panneau de l'abonné.
let xmltv_urls: Vec<String> = std::env::var("EPG_XMLTV_URLS")
.unwrap_or_else(|_| DEFAULT_XMLTV.to_string())
.split(',')
.map(|u| u.trim().to_string())
.filter(|u| !u.is_empty())
.collect();
if xmltv_urls.is_empty() {
println!("[EPG] Repli XMLTV désactivé (EPG_XMLTV_URLS vide)");
} else {
println!("[EPG] Repli XMLTV : {} source(s)", xmltv_urls.len());
}
let xmltv = (!xmltv_urls.is_empty()).then(|| {
Arc::new(XmltvEpgService::new(
XmltvConfig::new(xmltv_urls),
http.clone(),
))
});
let epg = EpgApi::new(xmltv, http.clone(), default_panel_xmltv(http.clone()));
// Purge des temporaires. Jamais le dossier temporaire système en
// récursif : il contient les sessions HLS.
let cleanup = Arc::new(CleanupService::new());
cleanup.add_target("/app/data/logs");
cleanup.add_target("/app/data/tmp");
cleanup.start();
// Libs de lecture servies depuis l'image : si elles manquent, le lecteur
// échoue côté navigateur sans que rien ne l'ait signalé. On le dit ici.
for lib in ["hls.min.js", "mpegts.min.js"] {
if !std::path::Path::new(&web_path)
.join("vendor")
.join(lib)
.exists()
{
println!(
"[Server] ATTENTION: web/vendor/{lib} absent de {web_path} — la lecture échouera dans le navigateur."
);
}
}
let versioned = util::asset_versioning::build_versioned_entry_points(&web_path);
let mut keys: Vec<&String> = versioned.keys().collect();
keys.sort();
println!(
"[Server] Assets versionnés : {}",
keys.iter()
.map(|k| k.as_str())
.collect::<Vec<_>>()
.join(", ")
);
let state = Arc::new(AppState {
db: db.clone(),
web_path: web_path.clone(),
versioned,
http: http.clone(),
http_manual,
scheduler: scheduler.clone(),
streaming,
proxy: ProxyHandler::new(),
epg,
logo_catalog: Arc::new(LogoCatalog::new(http.clone())),
cleanup,
rate_limit: RateLimiter::new(600),
login_rate_limit: RateLimiter::new(10),
allowed_origin: std::env::var("ALLOWED_ORIGIN")
.ok()
.filter(|o| !o.is_empty()),
trusted_proxies: std::env::var("TRUSTED_PROXIES")
.unwrap_or_default()
.split(',')
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
.collect(),
});
// Sessions expirées purgées toutes les heures.
let db_clean = db.clone();
tokio::spawn(async move {
let mut interval = tokio::time::interval(Duration::from_secs(3600));
interval.tick().await;
loop {
interval.tick().await;
db_clean.clean_expired_sessions();
println!("Cleaned expired sessions");
}
});
// Arrêt gracieux unique (docker stop / Ctrl+C) : clôturer d'abord les
// enregistrements (kill FFmpeg, fusion des parties, statut en base),
// puis les sessions de streaming, puis sortir.
tokio::spawn(shutdown_on_signal(scheduler, sessions));
let app = axum::Router::new()
.fallback(server::entry)
.with_state(state);
let addr = SocketAddr::from(([0, 0, 0, 0], args.port));
let listener = tokio::net::TcpListener::bind(addr)
.await
.unwrap_or_else(|e| panic!("écoute sur {addr} impossible : {e}"));
println!("Server started on port {}", args.port);
println!("Serving static files from: {web_path}");
println!("REST API available at: /api/auth/* and /api/playlists/*");
println!("Xtream proxy available at: /api/xtream/* (SSRF Protected)");
axum::serve(
listener,
app.into_make_service_with_connect_info::<SocketAddr>(),
)
.await
.expect("serveur HTTP");
}
async fn shutdown_on_signal(
scheduler: Arc<RecordingScheduler>,
sessions: Arc<FfmpegSessionManager>,
) {
let signal = wait_for_signal().await;
println!("[Server] {signal} reçu, arrêt en cours…");
scheduler.shutdown().await;
sessions.kill_all();
// Laisser SIGTERM partir vers les FFmpeg avant de sortir.
tokio::time::sleep(Duration::from_millis(200)).await;
std::process::exit(0);
}
#[cfg(unix)]
async fn wait_for_signal() -> &'static str {
use tokio::signal::unix::{SignalKind, signal};
let mut term = signal(SignalKind::terminate()).expect("SIGTERM");
let mut int = signal(SignalKind::interrupt()).expect("SIGINT");
tokio::select! {
_ = term.recv() => "SIGTERM",
_ = int.recv() => "SIGINT",
}
}
#[cfg(not(unix))]
async fn wait_for_signal() -> &'static str {
let _ = tokio::signal::ctrl_c().await;
"SIGINT"
}
+121
View File
@@ -0,0 +1,121 @@
//! Modèles de données, sérialisés exactement comme l'ancien serveur Dart
//! (noms de champs, formats de dates) : le front Flutter n'a pas changé.
use crate::dart_time::DartDateTime;
use serde_json::{Map, Value, json};
#[derive(Clone, Debug)]
pub struct User {
pub id: String,
pub username: String,
pub is_admin: bool,
pub created_at: DartDateTime,
}
impl User {
pub fn to_json(&self) -> Value {
json!({
"id": self.id,
"username": self.username,
"isAdmin": self.is_admin,
"createdAt": self.created_at.to_iso(),
})
}
}
#[derive(Clone, Debug)]
pub struct Playlist {
pub id: String,
pub user_id: String,
pub name: String,
pub server_url: String,
pub username: String,
pub password: String,
pub dns: Option<String>,
pub created_at: DartDateTime,
pub updated_at: DartDateTime,
}
impl Playlist {
pub fn to_json(&self) -> Value {
json!({
"id": self.id,
"userId": self.user_id,
"name": self.name,
"serverUrl": self.server_url,
"username": self.username,
// Le mot de passe Xtream n'est jamais exposé aux clients : le
// serveur injecte les identifiants lui-même (voir xtream_api).
"password": "",
"dns": self.dns,
"createdAt": self.created_at.to_iso(),
"updatedAt": self.updated_at.to_iso(),
})
}
}
#[derive(Clone, Debug)]
pub struct Session {
pub user_id: String,
pub token: String,
pub expires_at: DartDateTime,
}
impl Session {
pub fn is_expired(&self) -> bool {
chrono::Utc::now() > self.expires_at.to_utc()
}
}
/// Configuration Xtream utilisée par les proxys et le streaming.
#[derive(Clone, Debug)]
pub struct PlaylistConfig {
/// URL du serveur Xtream (sans `/` final).
pub dns: String,
pub username: String,
pub password: String,
}
#[derive(Clone, Debug)]
pub struct Recording {
pub id: String,
pub user_id: String,
pub channel_id: String,
pub stream_url: String,
pub title: String,
pub start_time: DartDateTime,
pub end_time: DartDateTime,
/// 'scheduled', 'recording', 'completed', 'failed', 'cancelled'
pub status: String,
pub file_path: Option<String>,
pub error_reason: Option<String>,
pub created_at: DartDateTime,
pub updated_at: DartDateTime,
}
impl Recording {
pub fn to_map(&self) -> Map<String, Value> {
let mut map = Map::new();
map.insert("id".into(), json!(self.id));
map.insert("user_id".into(), json!(self.user_id));
map.insert("channel_id".into(), json!(self.channel_id));
map.insert("stream_url".into(), json!(self.stream_url));
map.insert("title".into(), json!(self.title));
map.insert("start_time".into(), json!(self.start_time.to_iso()));
map.insert("end_time".into(), json!(self.end_time.to_iso()));
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("created_at".into(), json!(self.created_at.to_iso()));
map.insert("updated_at".into(), json!(self.updated_at.to_iso()));
map
}
pub fn start_utc(&self) -> chrono::DateTime<chrono::Utc> {
self.start_time.to_utc()
}
pub fn end_utc(&self) -> chrono::DateTime<chrono::Utc> {
self.end_time.to_utc()
}
}
+368
View File
@@ -0,0 +1,368 @@
//! 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::dart_time::now_local_iso;
use crate::http::{self, Req, Resp, client_ip, extract_token, is_miss, mounted, socket_ip};
use crate::state::SharedState;
use crate::util::log_redactor::redact_url;
use axum::extract::{ConnectInfo, Request, State};
use axum::http::Method;
use std::collections::{HashMap, VecDeque};
use std::net::SocketAddr;
use std::sync::{Arc, Mutex};
use std::time::{Duration, Instant};
/// Corps de requête maximal lu en mémoire.
const MAX_BODY: usize = 16 * 1024 * 1024;
/// Limiteur glissant par IP sur une minute.
pub struct RateLimiter {
limit: usize,
history: Arc<Mutex<HashMap<String, VecDeque<Instant>>>>,
}
impl RateLimiter {
pub fn new(limit: usize) -> Self {
let history: Arc<Mutex<HashMap<String, VecDeque<Instant>>>> = Arc::default();
// Ménage toutes les 5 minutes (pas de fuite mémoire par IP).
let h = history.clone();
tokio::spawn(async move {
let mut interval = tokio::time::interval(Duration::from_secs(300));
interval.tick().await;
loop {
interval.tick().await;
h.lock().unwrap().retain(|_, times| {
times.retain(|t| t.elapsed() < Duration::from_secs(120));
!times.is_empty()
});
}
});
Self { limit, history }
}
/// Enregistre une requête ; `false` si la limite est atteinte.
pub fn allow(&self, ip: &str) -> bool {
let mut history = self.history.lock().unwrap();
let times = history.entry(ip.to_string()).or_default();
while times
.front()
.is_some_and(|t| t.elapsed() >= Duration::from_secs(60))
{
times.pop_front();
}
if times.len() >= self.limit {
return false;
}
times.push_back(Instant::now());
true
}
}
/// Handler axum unique : lit le corps puis applique la chaîne complète.
pub async fn entry(
State(state): State<SharedState>,
ConnectInfo(remote): ConnectInfo<SocketAddr>,
request: Request,
) -> Resp {
let (parts, body) = request.into_parts();
let body = match axum::body::to_bytes(body, MAX_BODY).await {
Ok(b) => b,
Err(_) => return http::text(413, "Request Entity Too Large"),
};
let req = Req {
method: parts.method,
path: parts.uri.path().trim_start_matches('/').to_string(),
query: parts.uri.query().unwrap_or("").to_string(),
headers: parts.headers,
body,
remote: Some(remote),
user: None,
user_id: None,
};
handle(&state, req).await
}
pub async fn handle(state: &SharedState, req: Req) -> Resp {
// Journal masqué : le chemin de /api/xtream/<url> porte les
// identifiants Xtream en clair.
let started = Instant::now();
let log_target = if req.query.is_empty() {
format!("/{}", req.path)
} else {
format!("/{}?{}", req.path, req.query)
};
let method = req.method.clone();
let mut resp = security_chain(state, req).await;
add_security_headers(&mut resp);
println!(
"{} {} {method} {} ({}ms)",
now_local_iso(),
resp.status().as_u16(),
redact_url(&log_target),
started.elapsed().as_millis()
);
resp
}
fn add_security_headers(resp: &mut Resp) {
for (name, value) in [
("x-content-type-options", "nosniff"),
// SAMEORIGIN : les iframes player.html restent intégrables.
("x-frame-options", "SAMEORIGIN"),
("x-xss-protection", "1; mode=block"),
(
"strict-transport-security",
"max-age=63072000; includeSubDomains; preload",
),
("referrer-policy", "strict-origin-when-cross-origin"),
// Report-Only : Flutter Web (CanvasKit/wasm) et google_fonts peuvent
// casser sous une CSP appliquée.
(
"content-security-policy-report-only",
"default-src 'self'; script-src 'self' 'wasm-unsafe-eval' 'unsafe-eval'; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; font-src 'self' https://fonts.gstatic.com; img-src 'self' data: blob: https:; media-src 'self' blob:; connect-src 'self' https://fonts.gstatic.com",
),
] {
http::set_header(resp, name, value);
}
}
/// Chemins classiques des scanners de vulnérabilités.
const HONEYPOT_PATHS: [&str; 9] = [
"/admin/phpmyadmin",
"/phpmyadmin",
"/wp-admin",
"/wp-login.php",
"/.env",
"/config.php",
"/api/.env",
"/console",
"/actuator/health",
];
async fn security_chain(state: &SharedState, req: Req) -> Resp {
// Honeypot : chemin exact ou préfixe de segment (pas une sous-chaîne
// quelconque, qui bloquait des URL proxifiées légitimes).
let path = format!("/{}", req.path);
for trap in HONEYPOT_PATHS {
if path == trap || path.starts_with(&format!("{trap}/")) {
println!(
"SECURITY ALERT: Honeypot triggered by {} on path: {path}",
client_ip(&req, &state.trusted_proxies)
);
return http::text(403, "Access Denied");
}
}
// Limite globale : 600 req/min/IP (une grille de chaînes déclenche une
// requête de logo et une de guide par tuile, derrière une seule IP de
// reverse proxy).
let ip = client_ip(&req, &state.trusted_proxies);
if ip != "unknown" && ip != "127.0.0.1" && !state.rate_limit.allow(&ip) {
println!("SECURITY WARN: Rate limit exceeded for {ip}");
return http::text(429, "Too Many Requests");
}
cors(state, req).await
}
/// CORS : l'app est servie en même origine ; les en-têtes ne servent qu'au
/// développement (localhost) ou à ALLOWED_ORIGIN. L'origine n'est renvoyée
/// que si elle est autorisée — jamais de joker.
async fn cors(state: &SharedState, req: Req) -> Resp {
let origin = req.header("origin").map(str::to_string);
let allowed = origin.as_deref().is_some_and(|o| {
if state
.allowed_origin
.as_deref()
.is_some_and(|a| !a.is_empty() && a == o)
{
return true;
}
url::Url::parse(o)
.ok()
.and_then(|u| u.host_str().map(str::to_string))
.is_some_and(|h| h == "localhost" || h == "127.0.0.1")
});
let headers: Vec<(&str, String)> = match (&origin, allowed) {
(Some(o), true) => vec![
("access-control-allow-origin", o.clone()),
(
"access-control-allow-methods",
"GET, POST, PUT, DELETE, OPTIONS".into(),
),
(
"access-control-allow-headers",
"Origin, Content-Type, Accept, Authorization, Range".into(),
),
(
"access-control-expose-headers",
"Content-Length, Content-Range".into(),
),
("access-control-allow-credentials", "true".into()),
("vary", "Origin".into()),
],
_ => Vec::new(),
};
let mut resp = if req.method == Method::OPTIONS {
http::text(200, "")
} else {
cascade(state, req).await
};
for (name, value) in headers {
http::set_header(&mut resp, name, &value);
}
resp
}
/// Cascade shelf : un 404/405 passe la main à l'étage suivant.
async fn cascade(state: &SharedState, req: Req) -> Resp {
let resp = api_router(state, &req).await;
if !is_miss(&resp) {
return resp;
}
let resp = state.proxy.handle(state, &req).await;
if !is_miss(&resp) {
return resp;
}
let resp = protected_streaming(state, &req).await;
if !is_miss(&resp) {
return resp;
}
static_files::handle(state, &req).await
}
/// Middleware d'authentification : session valide exigée, utilisateur
/// attaché à la requête.
fn authenticate(state: &SharedState, req: &Req) -> Result<Req, Resp> {
let Some(token) = extract_token(req) else {
return Err(http::text(401, "Unauthorized"));
};
let Some(session) = state.db.find_session_by_token(&token) else {
return Err(http::text(401, "Invalid or expired session"));
};
let mut authed = req.clone();
authed.user = state.db.find_user_by_id(&session.user_id);
authed.user_id = Some(session.user_id);
Ok(authed)
}
macro_rules! authed {
($state:expr, $req:expr) => {
match authenticate($state, $req) {
Ok(r) => r,
Err(resp) => return resp,
}
};
}
async fn api_router(state: &SharedState, req: &Req) -> Resp {
let path = req.path.as_str();
if let Some(rest) = mounted(path, "api/auth") {
// Protection brute force : 10 tentatives de login/min/IP, et 300 ms
// de pénalité par échec.
let is_login = req.method == Method::POST && path.ends_with("login");
if is_login {
let ip = client_ip(req, &state.trusted_proxies);
if !state.login_rate_limit.allow(&ip) {
println!("SECURITY WARN: Login rate limit exceeded for {ip}");
return http::text(429, "Too many login attempts. Try again later.");
}
}
let resp = account::auth(state, req, rest).await;
if is_login && resp.status().as_u16() == 401 {
tokio::time::sleep(Duration::from_millis(300)).await;
}
return resp;
}
// Passerelle Xtream : identifiants injectés côté serveur.
if mounted(path, "api/xtream-api").is_some() {
let req = authed!(state, req);
return xtream_api::handle(state, &req).await;
}
if let Some(rest) = mounted(path, "api/playlists") {
let req = authed!(state, req);
return account::playlists(state, &req, rest);
}
if let Some(rest) = mounted(path, "api/users") {
let req = authed!(state, req);
return account::users(state, &req, rest).await;
}
if let Some(rest) = mounted(path, "api/settings") {
let req = authed!(state, req);
return account::settings(state, &req, rest);
}
// NB : /api/recordings/stream/* traverse ce routeur (404) et atteint le
// streaming plus bas dans la cascade.
if let Some(rest) = mounted(path, "api/recordings") {
let req = authed!(state, req);
return recordings::recordings(state, &req, rest).await;
}
// Logos (le cookie de session accompagne les requêtes d'image).
if path == "api/logo" && req.is_get() {
let req = authed!(state, req);
return logo::handle(state, &req).await;
}
// Incidents de lecture remontés par le lecteur web.
if path == "api/player-log" && req.method == Method::POST {
let _ = authed!(state, req);
return player_log::handle(req);
}
if let Some(rest) = mounted(path, "api/epg") {
let req = authed!(state, req);
return match (req.is_get(), http::segments(rest).as_slice()) {
(true, [channel_id]) => {
let playlist = state.get_playlist(&req);
state.epg.handle(playlist, channel_id).await
}
_ => http::not_found(),
};
}
if let Some(rest) = mounted(path, "api/season-passes") {
let req = authed!(state, req);
return recordings::season_passes(state, &req, rest);
}
if let Some(rest) = mounted(path, "api/admin") {
let req = authed!(state, req);
return admin::admin(state, &req, rest).await;
}
http::not_found()
}
/// Streaming : le cookie de session HttpOnly est accepté (hls.js ne peut pas
/// envoyer d'en-tête Authorization) ; les requêtes FFmpeg locales du
/// planificateur (`http://localhost:8089/api/live/<id>.ts`, sans cookie)
/// passent sans authentification.
async fn protected_streaming(state: &SharedState, req: &Req) -> Resp {
let path = req.path.as_str();
let is_streaming = path.starts_with("api/live")
|| path.starts_with("api/vod")
|| path.starts_with("api/recordings/stream");
if !is_streaming {
return http::not_found();
}
let loopback = socket_ip(req).is_some_and(|ip| ip.is_loopback());
let via_proxy = req.headers.contains_key("x-forwarded-for");
if !(loopback && !via_proxy) {
let valid =
extract_token(req).is_some_and(|t| state.db.find_session_by_token(&t).is_some());
if !valid {
return http::text(401, "Unauthorized");
}
}
let streaming = state.streaming.clone();
if let Some(rest) = mounted(path, "api/live") {
return streaming.live(state, req, rest).await;
}
if let Some(rest) = mounted(path, "api/vod") {
return streaming.vod(state, req, rest).await;
}
if let Some(rest) = mounted(path, "api/recordings/stream") {
return streaming.recording(state, req, rest).await;
}
http::not_found()
}
+135
View File
@@ -0,0 +1,135 @@
//! Purge périodique des fichiers temporaires et des logs de plus de 24 h.
//!
//! Ne JAMAIS cibler le dossier temporaire système en récursif : il contient
//! le dossier des sessions HLS — les fichiers de plus de 24 h y seraient
//! supprimés aveuglément.
use crate::dart_time::now_local_iso;
use serde_json::{Value, json};
use std::path::{Path, PathBuf};
use std::sync::Mutex;
use std::time::{Duration, SystemTime};
pub struct CleanupService {
targets: Mutex<Vec<PathBuf>>,
max_age: Duration,
}
impl CleanupService {
pub fn new() -> Self {
Self {
targets: Mutex::new(Vec::new()),
max_age: Duration::from_secs(24 * 3600),
}
}
/// Ajoute un dossier surveillé (créé s'il manque).
pub fn add_target(&self, dir: &str) {
let path = PathBuf::from(dir);
if !path.exists() {
match std::fs::create_dir_all(&path) {
Ok(()) => println!("CleanupService: Created missing directory: {dir}"),
Err(e) => {
println!(
"CleanupService: Should monitor {dir} but it does not exist and could not be created: {e}"
);
return;
}
}
}
self.targets.lock().unwrap().push(path);
println!("CleanupService: Monitoring {dir}");
}
/// Lance la purge immédiatement puis toutes les 6 heures.
pub fn start(self: &std::sync::Arc<Self>) {
println!("CleanupService: Starting scheduler (Interval: 6h)");
let this = self.clone();
tokio::spawn(async move {
let mut interval = tokio::time::interval(Duration::from_secs(6 * 3600));
loop {
interval.tick().await;
this.run_cleanup().await;
}
});
}
/// Purge manuelle (aussi exposée via /api/admin/purge).
pub async fn run_cleanup(&self) -> Value {
println!("CleanupService: Running cleanup task...");
let targets = self.targets.lock().unwrap().clone();
let max_age = self.max_age;
let (deleted, freed, errors) = tokio::task::spawn_blocking(move || {
let mut deleted = 0u64;
let mut freed = 0u64;
let mut errors = Vec::new();
let threshold = SystemTime::now() - max_age;
for dir in targets {
if !dir.exists() {
continue;
}
match clean_directory(&dir, threshold) {
Ok((count, bytes)) => {
deleted += count;
freed += bytes;
}
Err(e) => {
println!("CleanupService: Error processing {}: {e}", dir.display());
errors.push(format!("Error in {}: {e}", dir.display()));
}
}
}
(deleted, freed, errors)
})
.await
.unwrap_or((0, 0, vec!["cleanup task panicked".to_string()]));
println!(
"CleanupService: Cleanup complete. Deleted {deleted} files, freed {:.2} MB",
freed as f64 / 1024.0 / 1024.0
);
json!({
"status": "complete",
"deleted_files": deleted,
"freed_bytes": freed,
"timestamp": now_local_iso(),
"errors": errors,
})
}
}
/// Supprime récursivement les fichiers modifiés avant [threshold] (sans
/// suivre les liens symboliques).
fn clean_directory(dir: &Path, threshold: SystemTime) -> std::io::Result<(u64, u64)> {
let mut count = 0;
let mut bytes = 0;
for entry in std::fs::read_dir(dir)? {
let Ok(entry) = entry else { continue };
let Ok(file_type) = entry.file_type() else {
continue;
};
if file_type.is_symlink() {
continue;
}
if file_type.is_dir() {
if let Ok((c, b)) = clean_directory(&entry.path(), threshold) {
count += c;
bytes += b;
}
continue;
}
if !file_type.is_file() {
continue;
}
let Ok(meta) = entry.metadata() else { continue };
let Ok(modified) = meta.modified() else {
continue;
};
// Erreurs individuelles ignorées (fichier verrouillé…).
if modified < threshold && std::fs::remove_file(entry.path()).is_ok() {
count += 1;
bytes += meta.len();
}
}
Ok((count, bytes))
}
+367
View File
@@ -0,0 +1,367 @@
//! Registre des processus FFmpeg de transcodage (direct, film, lecture
//! d'enregistrement).
//!
//! Responsabilités :
//! - un processus par identifiant de session (`live_{id}_{qualité}`, …)
//! - le « reaper » tue les sessions inactives au-delà d'un délai (plus aucun
//! spectateur ne demande de segment)
//! - le dossier temporaire HLS est vidé au démarrage (orphelins d'un crash)
//! - l'arrêt du serveur tue tout (pas d'FFmpeg orphelin après `docker stop`)
use crate::util::log_redactor::redact_url;
use crate::util::process::ManagedChild;
use std::collections::{HashMap, VecDeque};
use std::path::{Path, PathBuf};
use std::process::Stdio;
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::{Arc, LazyLock, Mutex};
use std::time::{Duration, Instant, SystemTime};
use tokio::io::{AsyncBufReadExt, BufReader};
const LIVE_IDLE_TIMEOUT: Duration = Duration::from_secs(4 * 60);
const VOD_IDLE_TIMEOUT: Duration = Duration::from_secs(15 * 60);
/// Playlist d'un direct non réécrite depuis ce délai : FFmpeg est bloqué.
const LIVE_STALL_TIMEOUT: Duration = Duration::from_secs(45);
static EPOCH: LazyLock<Instant> = LazyLock::new(Instant::now);
fn now_ms() -> u64 {
EPOCH.elapsed().as_millis() as u64
}
/// Une session FFmpeg en cours.
pub struct FfmpegSession {
pub id: String,
pub child: ManagedChild,
pub dir: PathBuf,
pub is_live: bool,
pub started_at: Instant,
last_access_ms: AtomicU64,
/// Dernières lignes de stderr, pour le diagnostic d'un échec rapide.
recent_stderr: Mutex<VecDeque<String>>,
}
impl FfmpegSession {
pub fn touch(&self) {
self.last_access_ms.store(now_ms(), Ordering::Relaxed);
}
/// Temps écoulé depuis la dernière requête de playlist ou de segment.
pub fn idle_for(&self) -> Duration {
Duration::from_millis(now_ms().saturating_sub(self.last_access_ms.load(Ordering::Relaxed)))
}
pub fn exited(&self) -> bool {
self.child.has_exited()
}
pub fn exit_code(&self) -> Option<i32> {
self.child.exit_code()
}
pub fn playlist_path(&self) -> PathBuf {
self.dir.join("playlist.m3u8")
}
fn stderr_text(&self) -> String {
self.recent_stderr
.lock()
.unwrap()
.iter()
.cloned()
.collect::<String>()
.trim()
.to_string()
}
}
pub struct FfmpegSessionManager {
pub base_dir: PathBuf,
sessions: Mutex<HashMap<String, Arc<FfmpegSession>>>,
}
fn playlist_contains(path: &Path, needle: &str) -> bool {
std::fs::read_to_string(path).is_ok_and(|c| c.contains(needle))
}
impl FfmpegSessionManager {
pub fn new(base_dir: PathBuf) -> Self {
Self {
base_dir,
sessions: Mutex::new(HashMap::new()),
}
}
/// Vide les dossiers orphelins et démarre le reaper. Une fois au démarrage.
pub fn init(self: &Arc<Self>) {
if self.base_dir.exists()
&& let Err(e) = std::fs::remove_dir_all(&self.base_dir)
{
println!("[FFmpegManager] Could not wipe temp dir: {e}");
}
let _ = std::fs::create_dir_all(&self.base_dir);
let this = self.clone();
tokio::spawn(async move {
let mut interval = tokio::time::interval(Duration::from_secs(60));
interval.tick().await;
loop {
interval.tick().await;
this.reap();
}
});
}
pub fn get(&self, id: &str) -> Option<Arc<FfmpegSession>> {
self.sessions.lock().unwrap().get(id).cloned()
}
/// Marque une session comme utilisée (routes de playlist ET de segment).
pub fn touch(&self, id: &str) {
if let Some(session) = self.get(id) {
session.touch();
}
}
pub fn contains(&self, id: &str) -> bool {
self.sessions.lock().unwrap().contains_key(id)
}
fn needs_start_for(existing: Option<&Arc<FfmpegSession>>, is_live: bool) -> bool {
let Some(existing) = existing else {
return true;
};
if !existing.exited() {
return false;
}
// Un transcodage VOD/enregistrement terminé proprement reste lisible
// depuis ses segments : on le réutilise au lieu de retranscoder.
is_live
|| existing.exit_code() != Some(0)
|| !playlist_contains(&existing.playlist_path(), "#EXT-X-ENDLIST")
}
/// Vrai si [get_or_start] lancerait un nouveau processus — donc une
/// nouvelle connexion au fournisseur.
pub fn needs_start(&self, id: &str, is_live: bool) -> bool {
let sessions = self.sessions.lock().unwrap();
Self::needs_start_for(sessions.get(id), is_live)
}
/// Session saine existante, ou nouveau processus FFmpeg. Le dossier de
/// session est recréé pour une nouvelle session ; [args] y est exécuté.
pub fn get_or_start(
&self,
id: &str,
is_live: bool,
ffmpeg_path: &str,
args: &[String],
) -> std::io::Result<Arc<FfmpegSession>> {
let mut sessions = self.sessions.lock().unwrap();
let existing = sessions.get(id).cloned();
if let Some(existing) = &existing {
if !Self::needs_start_for(Some(existing), is_live) {
existing.touch();
return Ok(existing.clone());
}
// Processus mort : nettoyer avant de relancer.
sessions.remove(id);
existing.child.terminate();
let _ = std::fs::remove_dir_all(&existing.dir);
}
let dir = self.base_dir.join(id);
if dir.exists() {
let _ = std::fs::remove_dir_all(&dir);
}
std::fs::create_dir_all(&dir)?;
let mut child = tokio::process::Command::new(ffmpeg_path)
.args(args)
.current_dir(&dir)
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::piped())
.spawn()?;
let stderr = child.stderr.take();
let managed = ManagedChild::spawn(child);
let session = Arc::new(FfmpegSession {
id: id.to_string(),
child: managed,
dir,
is_live,
started_at: Instant::now(),
last_access_ms: AtomicU64::new(now_ms()),
recent_stderr: Mutex::new(VecDeque::new()),
});
sessions.insert(id.to_string(), session.clone());
drop(sessions);
if let Some(stderr) = stderr {
let s = session.clone();
tokio::spawn(async move {
let mut lines = BufReader::new(stderr).lines();
while let Ok(Some(line)) = lines.next_line().await {
{
let mut recent = s.recent_stderr.lock().unwrap();
recent.push_back(format!("{line}\n"));
if recent.len() > 20 {
recent.pop_front();
}
}
// FFmpeg rappelle l'URL d'entrée (« Input #0 … from
// 'http://…' ») : masquer les identifiants Xtream.
println!("[FFmpeg {}] {}", s.id, redact_url(&line));
}
});
}
let s = session.clone();
tokio::spawn(async move {
let code = s.child.wait().await;
println!("[FFmpegManager] Session {} exited with code {code}", s.id);
});
Ok(session)
}
/// Attend que la playlist de la session référence au moins
/// [min_segments] segments. Échoue vite si le processus meurt avant d'avoir
/// produit quoi que ce soit, en renvoyant son stderr récent.
///
/// [min_segments] > 1 donne au lecteur une avance de démarrage. Une
/// playlist déjà close (`#EXT-X-ENDLIST`) est rendue telle quelle.
pub async fn wait_for_playlist(
&self,
session: &FfmpegSession,
min_segments: usize,
) -> (bool, Option<String>) {
let playlist = session.playlist_path();
let deadline = Instant::now() + Duration::from_secs(30);
while Instant::now() < deadline {
if let Ok(content) = tokio::fs::read_to_string(&playlist).await {
let segments = content.matches(".ts").count();
if segments >= min_segments || (segments > 0 && content.contains("#EXT-X-ENDLIST"))
{
return (true, None);
}
}
if session.exited() {
// Un encodeur terminé proprement avec moins de segments que
// demandé a simplement fini : c'est un succès.
if session.exit_code() == Some(0) && playlist_contains(&playlist, ".ts") {
return (true, None);
}
return (
false,
Some(format!(
"FFmpeg exited ({}): {}",
session.exit_code().unwrap_or(-1),
session.stderr_text()
)),
);
}
// 100 ms : à 500 ms, on ajoutait en moyenne un quart de seconde
// de latence pure entre la disponibilité de la playlist et sa
// réponse.
tokio::time::sleep(Duration::from_millis(100)).await;
}
// Délai écoulé : mieux vaut servir ce qui existe que rien.
if playlist_contains(&playlist, ".ts") {
return (true, None);
}
(false, Some("Timeout waiting for transcoder".to_string()))
}
pub fn kill_session(&self, id: &str) {
let Some(session) = self.sessions.lock().unwrap().remove(id) else {
return;
};
session.child.terminate();
if session.dir.exists()
&& let Err(e) = std::fs::remove_dir_all(&session.dir)
{
println!("[FFmpegManager] Could not delete dir for {id}: {e}");
}
}
/// Tue les sessions sœurs d'un même contenu (`rec_<id>_t0`,
/// `rec_<id>_t600`…) que plus personne ne consomme depuis 30 s.
///
/// Chaque saut hors de la zone déjà transcodée démarre un FFmpeg à un
/// nouvel offset ; sans ce ménage, une poignée d'allers-retours dans la
/// barre de progression laisserait autant de processus vivants jusqu'au
/// TTL de 15 minutes.
pub fn kill_idle_siblings(&self, prefix: &str, keep: &str) {
let stale: Vec<String> = self
.sessions
.lock()
.unwrap()
.values()
.filter(|s| s.id != keep && s.id.starts_with(prefix))
.filter(|s| s.idle_for() >= Duration::from_secs(30))
.map(|s| s.id.clone())
.collect();
for id in stale {
println!("[FFmpegManager] Dropping stale sibling {id}");
self.kill_session(&id);
}
}
pub fn kill_all(&self) {
let ids: Vec<String> = self.sessions.lock().unwrap().keys().cloned().collect();
println!("[FFmpegManager] Killing {} session(s)", ids.len());
for id in ids {
self.kill_session(&id);
}
}
fn reap(&self) {
let sessions: Vec<Arc<FfmpegSession>> =
self.sessions.lock().unwrap().values().cloned().collect();
for session in sessions {
let timeout = if session.is_live {
LIVE_IDLE_TIMEOUT
} else {
VOD_IDLE_TIMEOUT
};
let idle = session.idle_for();
// Les sessions terminées gardent leurs segments jusqu'au délai
// d'inactivité : un film transcodé est souvent encore regardé.
if idle > timeout {
println!(
"[FFmpegManager] Reaping idle session {} (idle {}s)",
session.id,
idle.as_secs()
);
self.kill_session(&session.id);
continue;
}
// Chien de garde : un direct dont la playlist n'est plus réécrite
// est bloqué sur une source figée. Le tuer laisse la prochaine
// requête du lecteur relancer le transcodeur.
if session.is_live && !session.exited() && Self::is_stalled(&session) {
println!(
"[FFmpegManager] Restarting stalled live session {}",
session.id
);
self.kill_session(&session.id);
}
}
}
fn is_stalled(session: &FfmpegSession) -> bool {
match std::fs::metadata(session.playlist_path()).and_then(|m| m.modified()) {
Ok(modified) => SystemTime::now()
.duration_since(modified)
.is_ok_and(|age| age > LIVE_STALL_TIMEOUT),
// Jamais rien produit : délai compté depuis le démarrage.
Err(_) => session.started_at.elapsed() > LIVE_STALL_TIMEOUT,
}
}
}
+111
View File
@@ -0,0 +1,111 @@
//! Repli GPU → CPU des transcodages.
//!
//! Quand la VRAM est saturée (plusieurs sessions NVENC, un autre processus
//! sur la carte) ou que le GPU dépasse son quota de sessions d'encodage,
//! FFmpeg échoue à l'ouverture de l'encodeur et meurt en une seconde. Sans
//! repli, le lecteur recevait un 502, relançait… et retombait sur le même
//! GPU saturé : le flux restait illisible tant que la carte était pleine.
use regex::Regex;
use std::sync::{LazyLock, Mutex};
use std::time::{Duration, Instant};
/// Signatures FFmpeg d'un échec imputable au GPU NVIDIA (NVENC/NVDEC/CUDA).
///
/// N'est consulté que pour une session lancée sur le GPU : « opening
/// encoder » y désigne donc forcément h264_nvenc.
static GPU_FAILURE: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(
r"(?i)nvenc|cuda|cuvid|nvdec|OpenEncodeSession|No capable devices|out of memory|Device creation failed|hwaccel|opening encoder",
)
.unwrap()
});
/// Vrai si [error] (stderr récent de FFmpeg) trahit une panne GPU, et non
/// une source injoignable ou un délai dépassé — qu'un repli CPU ne
/// réglerait pas, et qui doublerait inutilement la charge du serveur.
pub fn is_gpu_failure(error: Option<&str>) -> bool {
error.is_some_and(|e| GPU_FAILURE.is_match(e))
}
/// Mémorise une panne GPU récente pour que les sessions suivantes partent
/// directement sur le CPU, au lieu de payer chacune un échec NVENC avant
/// leur repli.
pub struct GpuHealth {
cooldown: Duration,
unavailable_until: Mutex<Option<Instant>>,
}
impl GpuHealth {
pub fn new(cooldown: Duration) -> Self {
Self {
cooldown,
unavailable_until: Mutex::new(None),
}
}
pub fn available(&self) -> bool {
self.available_at(Instant::now())
}
pub fn available_at(&self, now: Instant) -> bool {
match *self.unavailable_until.lock().unwrap() {
None => true,
Some(until) => now >= until,
}
}
pub fn mark_failed(&self) {
self.mark_failed_at(Instant::now());
}
pub fn mark_failed_at(&self, now: Instant) {
*self.unavailable_until.lock().unwrap() = Some(now + self.cooldown);
}
}
impl Default for GpuHealth {
fn default() -> Self {
Self::new(Duration::from_secs(120))
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn detecte_les_pannes_gpu() {
assert!(is_gpu_failure(Some(
"FFmpeg exited (1): [h264_nvenc @ 0x55] OpenEncodeSessionEx failed: out of memory (10)"
)));
assert!(is_gpu_failure(Some(
"[h264_nvenc @ 0x1] No capable devices found"
)));
assert!(is_gpu_failure(Some(
"cu->cuInit(0) failed -> CUDA_ERROR_OUT_OF_MEMORY"
)));
assert!(is_gpu_failure(Some(
"Error while opening encoder for output stream #0:0 - maybe incorrect parameters such as bit_rate, rate, width or height"
)));
}
#[test]
fn ignore_les_pannes_amont_et_les_delais() {
assert!(!is_gpu_failure(Some(
"FFmpeg exited (1): http://x/live/u/p/1.ts: Server returned 404 Not Found"
)));
assert!(!is_gpu_failure(Some("Timeout waiting for transcoder")));
assert!(!is_gpu_failure(None));
}
#[test]
fn disponible_apres_le_delai() {
let health = GpuHealth::new(Duration::from_secs(120));
let t0 = Instant::now();
assert!(health.available_at(t0));
health.mark_failed_at(t0);
assert!(!health.available_at(t0 + Duration::from_secs(119)));
assert!(health.available_at(t0 + Duration::from_secs(120)));
}
}
+645
View File
@@ -0,0 +1,645 @@
//! Logos de chaînes de repli, tirés du dépôt public `tv-logo/tv-logos`.
//!
//! POURQUOI : les URL `stream_icon` du panneau pointent chez l'hébergeur du
//! revendeur. Quand cette machine tombe — cas observé : serveur de picons en
//! maintenance, 503 sur toutes les URL —, toute la grille reste sans logo.
//! Le dépôt `tv-logos` est maintenu, nommé de façon régulière
//! (`countries/france/rmc-sport-2-fr.png`) et s'apparie au nom de chaîne.
use std::collections::{HashMap, HashSet};
use std::sync::{Arc, LazyLock, Mutex, MutexGuard, PoisonError};
use std::time::{Duration, Instant};
use bytes::Bytes;
use futures::future::{BoxFuture, FutureExt, Shared};
use regex::Regex;
use reqwest::header::{ACCEPT, USER_AGENT};
use crate::util::log_redactor::redact_url;
/// GitHub refuse les requêtes sans `User-Agent`.
const USER_AGENT_VALUE: &str = "XtremFlow";
/// Plafond d'une requête (liste ou image).
const REQUEST_TIMEOUT: Duration = Duration::from_secs(20);
type SharedListing = Shared<BoxFuture<'static, Option<Arc<Listing>>>>;
/// Catalogue de logos, partagé via `Arc<LogoCatalog>`.
pub struct LogoCatalog {
inner: Arc<Inner>,
}
struct Inner {
client: reqwest::Client,
api_base: String,
/// Durée de vie d'une liste de logos par pays. L'API GitHub non
/// authentifiée plafonne à 60 requêtes par heure : une par jour suffit.
listing_ttl: Duration,
/// Recul après un échec de téléchargement de liste (limite de débit,
/// réseau), pour ne pas marteler l'API à chaque tuile affichée.
retry_backoff: Duration,
state: Mutex<State>,
}
#[derive(Default)]
struct State {
listings: HashMap<&'static str, Arc<Listing>>,
listings_in_flight: HashMap<&'static str, SharedListing>,
listing_failed_at: HashMap<&'static str, Instant>,
/// URL de téléchargement → octets PNG.
bytes: HashMap<String, Bytes>,
}
struct Listing {
/// Clé normalisée → URL de téléchargement brute.
urls: HashMap<String, String>,
fetched_at: Instant,
}
impl Inner {
fn lock(&self) -> MutexGuard<'_, State> {
self.state.lock().unwrap_or_else(PoisonError::into_inner)
}
}
/// Libère la place de la requête de liste en cours quand elle se termine,
/// même en cas de panique.
struct InFlightGuard(Arc<Inner>, &'static str);
impl Drop for InFlightGuard {
fn drop(&mut self) {
self.0.lock().listings_in_flight.remove(self.1);
}
}
impl LogoCatalog {
pub fn new(client: reqwest::Client) -> Self {
Self::with_api_base(client, "https://api.github.com".to_string())
}
/// `api_base` remplace `https://api.github.com` (tests).
pub fn with_api_base(client: reqwest::Client, api_base: String) -> Self {
Self {
inner: Arc::new(Inner {
client,
api_base: api_base.trim_end_matches('/').to_string(),
listing_ttl: Duration::from_secs(24 * 3600),
retry_backoff: Duration::from_secs(15 * 60),
state: Mutex::new(State::default()),
}),
}
}
/// Octets PNG du logo correspondant à `channel_name`, ou `None`.
pub async fn logo_for(&self, channel_name: &str) -> Option<Bytes> {
let country = country_folder_for(channel_name);
let listing = self.listing_for(country).await?;
let key = match_logo_key(channel_name, listing.urls.keys())?;
let url = listing.urls.get(&key)?.clone();
if let Some(cached) = self.inner.lock().bytes.get(&url) {
return Some(cached.clone());
}
let download = async {
let response = self
.inner
.client
.get(&url)
.header(USER_AGENT, USER_AGENT_VALUE)
.send()
.await
.ok()?;
if response.status().as_u16() != 200 {
return None;
}
response.bytes().await.ok()
};
let bytes = tokio::time::timeout(REQUEST_TIMEOUT, download)
.await
.ok()
.flatten()?;
self.inner.lock().bytes.insert(url, bytes.clone());
Some(bytes)
}
async fn listing_for(&self, country: &'static str) -> Option<Arc<Listing>> {
let pending = {
let mut state = self.inner.lock();
let cached = state.listings.get(country).cloned();
if let Some(listing) = &cached
&& listing.fetched_at.elapsed() < self.inner.listing_ttl
{
return cached;
}
if state
.listing_failed_at
.get(country)
.is_some_and(|at| at.elapsed() < self.inner.retry_backoff)
{
// Liste périmée plutôt que rien : les logos ne changent pas
// d'un jour à l'autre.
return cached;
}
// Les tuiles d'une grille arrivent en rafale : une seule requête
// de liste. Elle tourne dans sa propre tâche pour aller au bout
// même si le premier demandeur abandonne.
match state.listings_in_flight.get(country) {
Some(pending) => pending.clone(),
None => {
let inner = Arc::clone(&self.inner);
let task = tokio::spawn(async move {
let _guard = InFlightGuard(Arc::clone(&inner), country);
fetch_listing(&inner, country).await
});
let pending = async move { task.await.ok().flatten() }.boxed().shared();
state.listings_in_flight.insert(country, pending.clone());
pending
}
}
};
pending.await
}
}
async fn fetch_listing(inner: &Inner, country: &'static str) -> Option<Arc<Listing>> {
let url = format!(
"{}/repos/tv-logo/tv-logos/contents/countries/{country}",
inner.api_base
);
match request_listing(inner, &url).await {
Ok(urls) => {
let count = urls.len();
let listing = Arc::new(Listing {
urls,
fetched_at: Instant::now(),
});
{
let mut state = inner.lock();
state.listings.insert(country, Arc::clone(&listing));
state.listing_failed_at.remove(country);
}
println!("[LogoCatalog] {country} : {count} logos");
Some(listing)
}
Err(error) => {
let stale = {
let mut state = inner.lock();
state.listing_failed_at.insert(country, Instant::now());
state.listings.get(country).cloned()
};
println!(
"[LogoCatalog] liste {country} indisponible : {}",
redact_url(&error)
);
stale
}
}
}
/// Liste des PNG d'un dossier pays : clé normalisée → URL brute.
async fn request_listing(inner: &Inner, url: &str) -> Result<HashMap<String, String>, String> {
let request = async {
let response = inner
.client
.get(url)
.header(ACCEPT, "application/vnd.github+json")
.header(USER_AGENT, USER_AGENT_VALUE)
.send()
.await
.map_err(|e| error_text(&e))?;
let status = response.status().as_u16();
if status != 200 {
return Err(format!("Bad state: HTTP {status}"));
}
response.bytes().await.map_err(|e| error_text(&e))
};
let body = tokio::time::timeout(REQUEST_TIMEOUT, request)
.await
.map_err(|_| "TimeoutException after 0:00:20.000000: Future not completed".to_string())??;
let decoded: serde_json::Value = serde_json::from_str(&String::from_utf8_lossy(&body))
.map_err(|e| format!("FormatException: {e}"))?;
let mut urls = HashMap::new();
if let serde_json::Value::Array(entries) = decoded {
for entry in entries {
let serde_json::Value::Object(entry) = entry else {
continue;
};
let name = entry
.get("name")
.and_then(dart_to_string)
.unwrap_or_default();
let Some(download) = entry.get("download_url").and_then(dart_to_string) else {
continue;
};
if !name.ends_with(".png") {
continue;
}
urls.insert(key_from_file_name(&name), download);
}
}
Ok(urls)
}
/// `value?.toString()` de Dart : `None` pour `null`.
fn dart_to_string(value: &serde_json::Value) -> Option<String> {
match value {
serde_json::Value::Null => None,
serde_json::Value::String(s) => Some(s.clone()),
other => Some(other.to_string()),
}
}
/// Texte d'une erreur avec ses causes.
fn error_text(error: &dyn std::error::Error) -> String {
let mut text = error.to_string();
let mut source = error.source();
while let Some(cause) = source {
text.push_str(": ");
text.push_str(&cause.to_string());
source = cause.source();
}
text
}
/// `rmc-sport-2-fr.png` → `rmc-sport-2`.
fn key_from_file_name(name: &str) -> String {
let base = &name[..name.len() - ".png".len()];
let bytes = base.as_bytes();
let n = bytes.len();
if n >= 3
&& bytes[n - 3] == b'-'
&& bytes[n - 2].is_ascii_lowercase()
&& bytes[n - 1].is_ascii_lowercase()
{
base[..n - 3].to_string()
} else {
base.to_string()
}
}
/// Code pays du préfixe panneau → dossier du dépôt `tv-logos`.
fn country_folder(code: &str) -> Option<&'static str> {
Some(match code {
"fr" => "france",
"be" => "belgium",
"ch" => "switzerland",
"uk" | "gb" => "united-kingdom",
"us" => "united-states",
"ca" => "canada",
"de" => "germany",
"es" => "spain",
"it" => "italy",
"pt" => "portugal",
"nl" => "netherlands",
_ => return None,
})
}
/// Préfixe pays des noms panneau : `FR - `, `FR: `, `|FR| `.
///
/// `\W` et `\s` reproduits avec leur sens Dart (ASCII pour `\W`, espaces
/// JavaScript pour `\s`), les classes de `regex` étant Unicode.
static COUNTRY_PREFIX: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(
r"^[^A-Za-z0-9_]*([A-Za-z]{2})[\t\n\x0B\x0C\r \xA0\x{1680}\x{2000}-\x{200A}\x{2028}\x{2029}\x{202F}\x{205F}\x{3000}\x{FEFF}\-:|]+",
)
.unwrap()
});
/// Dossier `tv-logos` à consulter pour `channel_name`. France par défaut :
/// c'est l'audience de l'application.
pub fn country_folder_for(channel_name: &str) -> &'static str {
COUNTRY_PREFIX
.captures(channel_name)
.and_then(|caps| country_folder(&caps[1].to_lowercase()))
.unwrap_or("france")
}
/// Mots sans rapport avec l'identité de la chaîne : qualité, codec, variante
/// de flux. Ils sont propres au panneau et absents des noms de logos.
const NOISE: &[&str] = &[
"fhd", "uhd", "hd", "sd", "4k", "8k", "hdr", "hevc", "h264", "h265", "1080p", "720p", "50fps",
"fps", "raw", "backup", "vip", "multi", "vf", "vo", "vostfr",
];
fn fold_accent(c: char) -> char {
match c {
'à' | 'á' | 'â' | 'ä' => 'a',
'è' | 'é' | 'ê' | 'ë' => 'e',
'ì' | 'í' | 'î' | 'ï' => 'i',
'ò' | 'ó' | 'ô' | 'ö' => 'o',
'ù' | 'ú' | 'û' | 'ü' => 'u',
'ç' => 'c',
other => other,
}
}
fn tokens(channel_name: &str) -> Vec<String> {
let mut name = channel_name;
if let Some(caps) = COUNTRY_PREFIX.captures(name)
&& country_folder(&caps[1].to_lowercase()).is_some()
{
name = &name[caps.get(0).map_or(0, |m| m.end())..];
}
let folded: String = name.to_lowercase().chars().map(fold_accent).collect();
folded
.replace('+', " plus ")
.replace('&', " and ")
.split(|c: char| !matches!(c, 'a'..='z' | '0'..='9'))
.filter(|t| !t.is_empty() && !NOISE.contains(t))
.map(str::to_string)
.collect()
}
/// Clé de logo la plus proche de `channel_name` parmi `available`, ou `None`.
///
/// Par ordre de confiance : nom exact, nom sans « live », variante plus
/// longue du même identifiant (`bein-sports-1` → `bein-sports-1-french`),
/// puis la marque sans numéro de canal (`rmc-sport-live-5` → `rmc-sport-1`).
/// Aucune approximation sur un mot seul : « France » ne doit pas devenir
/// France 24.
pub fn match_logo_key<'a>(
channel_name: &str,
available: impl IntoIterator<Item = &'a String>,
) -> Option<String> {
let keys: HashSet<&str> = available.into_iter().map(String::as_str).collect();
let tokens = tokens(channel_name);
if tokens.is_empty() {
return None;
}
let without_live = |parts: &[String]| -> Vec<String> {
parts.iter().filter(|t| *t != "live").cloned().collect()
};
let exact = |parts: &[String]| -> Option<String> {
let slug = parts.join("-");
(!parts.is_empty() && keys.contains(slug.as_str())).then_some(slug)
};
let longer_variant = |parts: &[String]| -> Option<String> {
if parts.len() < 2 {
return None;
}
let prefix = format!("{}-", parts.join("-"));
// Plus courte d'abord (longueur en unités UTF-16 comme en Dart), puis
// ordre alphabétique.
keys.iter()
.filter(|k| k.starts_with(&prefix))
.min_by(|a, b| {
a.encode_utf16()
.count()
.cmp(&b.encode_utf16().count())
.then_with(|| a.cmp(b))
})
.map(|k| k.to_string())
};
let no_live = without_live(&tokens);
let direct = exact(&tokens)
.or_else(|| exact(&no_live))
.or_else(|| longer_variant(&tokens))
.or_else(|| longer_variant(&no_live));
if direct.is_some() {
return direct;
}
// Marque sans numéro de canal, seulement s'il reste au moins deux mots.
let last = tokens.last()?;
if !last.is_empty() && last.bytes().all(|b| b.is_ascii_digit()) {
let brand = without_live(&tokens[..tokens.len() - 1]);
if brand.len() >= 2 {
return exact(&brand).or_else(|| longer_variant(&brand));
}
}
None
}
#[cfg(test)]
mod tests {
use super::*;
use axum::Router;
use axum::http::{StatusCode, Uri};
use std::sync::atomic::{AtomicUsize, Ordering};
/// Extrait réel de `tv-logo/tv-logos/countries/france` (noms sans
/// `-fr.png`).
const FRANCE: &[&str] = &[
"bein-sports-1-french",
"bein-sports-2-french",
"bein-sports",
"canal-plus",
"canal-plus-sport",
"canal-plus-sport-360",
"eurosport-1",
"france-2",
"france-24",
"lequipe",
"rmc-sport-1",
"rmc-sport-2",
"rmc-sport-access-1",
];
fn matched(name: &str) -> Option<String> {
let keys: Vec<String> = FRANCE.iter().map(|s| s.to_string()).collect();
match_logo_key(name, &keys)
}
#[test]
fn correspondance_exacte_apres_nettoyage_du_nom_panneau() {
// Préfixe pays et suffixe de qualité sont du bruit propre au panneau.
assert_eq!(
matched("FR - RMC SPORT 2 FHD").as_deref(),
Some("rmc-sport-2")
);
assert_eq!(matched("FR: France 2 HD").as_deref(), Some("france-2"));
assert_eq!(
matched("|FR| EUROSPORT 1 UHD").as_deref(),
Some("eurosport-1")
);
}
#[test]
fn traduit_le_plus_de_canal_plus() {
assert_eq!(
matched("FR - CANAL+ SPORT FHD").as_deref(),
Some("canal-plus-sport")
);
assert_eq!(matched("FR - CANAL+").as_deref(), Some("canal-plus"));
}
#[test]
fn accepte_une_variante_plus_longue_du_meme_identifiant() {
// Le dépôt nomme `bein-sports-1-french`, le panneau `BEIN SPORTS 1`.
assert_eq!(
matched("FR - BEIN SPORTS 1 FHD").as_deref(),
Some("bein-sports-1-french")
);
}
#[test]
fn retombe_sur_la_marque_quand_le_numero_de_canal_est_inconnu() {
// Les canaux événementiels (« Live 5 ») n'ont pas de logo propre :
// celui de la marque vaut mieux qu'une tuile vide.
assert_eq!(
matched("FR - RMC SPORT LIVE 5 FHD").as_deref(),
Some("rmc-sport-1")
);
assert_eq!(
matched("FR - CANAL+ SPORT 2").as_deref(),
Some("canal-plus-sport")
);
}
#[test]
fn ne_devine_pas_sur_un_seul_mot() {
// « France » seul ne doit pas donner le logo de France 24.
assert_eq!(matched("FR - FRANCE"), None);
}
#[test]
fn rend_none_quand_rien_ne_correspond() {
assert_eq!(matched("FR - DAZN FHD"), None);
assert_eq!(matched(""), None);
}
#[test]
fn country_folder_lit_le_prefixe_pays_du_nom() {
assert_eq!(country_folder_for("FR - TF1"), "france");
assert_eq!(country_folder_for("BE: RTL TVI"), "belgium");
assert_eq!(country_folder_for("|UK| BBC ONE"), "united-kingdom");
}
#[test]
fn country_folder_france_par_defaut_sans_prefixe_reconnu() {
assert_eq!(country_folder_for("TF1 HD"), "france");
}
#[test]
fn cle_depuis_le_nom_de_fichier() {
assert_eq!(key_from_file_name("rmc-sport-2-fr.png"), "rmc-sport-2");
assert_eq!(key_from_file_name("lequipe.png"), "lequipe");
assert_eq!(key_from_file_name("bbc-one-uk.png"), "bbc-one");
}
/// Mini serveur HTTP local (remplace le `MockClient` Dart) : compte les
/// requêtes et répond selon le chemin. `{base}` dans le corps est
/// remplacé par l'adresse du serveur.
async fn serve(
respond: impl Fn(&Uri) -> (StatusCode, String) + Send + Sync + 'static,
) -> (String, Arc<Mutex<Vec<String>>>) {
let requested = Arc::new(Mutex::new(Vec::new()));
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let base = format!("http://{}", listener.local_addr().unwrap());
let respond = Arc::new(respond);
let log = Arc::clone(&requested);
let base_for_body = base.clone();
let app = Router::new().fallback(move |uri: Uri| {
let respond = Arc::clone(&respond);
let log = Arc::clone(&log);
let base = base_for_body.clone();
async move {
log.lock().unwrap().push(uri.to_string());
let (status, body) = respond(&uri);
(status, body.replace("{base}", &base))
}
});
tokio::spawn(async move { axum::serve(listener, app).await.unwrap() });
(base, requested)
}
#[tokio::test]
async fn telecharge_le_logo_apparie_et_le_garde_en_cache() {
let (base, requested) = serve(|uri| {
if uri.path().starts_with("/repos/") {
(
StatusCode::OK,
serde_json::json!([
{"name": "rmc-sport-2-fr.png", "download_url": "{base}/raw/rmc-sport-2-fr.png"},
{"name": "README.md", "download_url": "{base}/r"},
])
.to_string(),
)
} else {
(StatusCode::OK, "\u{1}\u{2}\u{3}".to_string())
}
})
.await;
let catalog = LogoCatalog::with_api_base(reqwest::Client::new(), base);
let first = catalog.logo_for("FR - RMC SPORT 2 FHD").await;
let second = catalog.logo_for("FR - RMC SPORT 2 FHD").await;
assert_eq!(first.as_deref(), Some(&[1u8, 2, 3][..]));
assert_eq!(second.as_deref(), Some(&[1u8, 2, 3][..]));
// Une liste et un logo, pas un aller-retour par affichage.
let requested = requested.lock().unwrap().clone();
assert_eq!(requested.len(), 2, "{requested:?}");
assert_eq!(
requested[0],
"/repos/tv-logo/tv-logos/contents/countries/france"
);
}
#[tokio::test]
async fn rend_none_sans_correspondance_sans_telecharger_d_image() {
let downloads = Arc::new(AtomicUsize::new(0));
let counter = Arc::clone(&downloads);
let (base, _) = serve(move |uri| {
if uri.path().starts_with("/repos/") {
(StatusCode::OK, "[]".to_string())
} else {
counter.fetch_add(1, Ordering::SeqCst);
(StatusCode::OK, "\u{1}".to_string())
}
})
.await;
let catalog = LogoCatalog::with_api_base(reqwest::Client::new(), base);
assert_eq!(catalog.logo_for("FR - CHAINE INCONNUE").await, None);
assert_eq!(downloads.load(Ordering::SeqCst), 0);
}
#[tokio::test]
async fn espace_les_tentatives_quand_la_liste_est_injoignable() {
let (base, requested) =
serve(|_| (StatusCode::FORBIDDEN, "rate limited".to_string())).await;
let catalog = LogoCatalog::with_api_base(reqwest::Client::new(), base);
catalog.logo_for("FR - RMC SPORT 2").await;
catalog.logo_for("FR - RMC SPORT 1").await;
assert_eq!(requested.lock().unwrap().len(), 1);
}
#[tokio::test]
async fn une_seule_requete_de_liste_pour_une_rafale_de_tuiles() {
let (base, requested) = serve(|uri| {
if uri.path().starts_with("/repos/") {
(StatusCode::OK, "[]".to_string())
} else {
(StatusCode::OK, String::new())
}
})
.await;
let catalog = LogoCatalog::with_api_base(reqwest::Client::new(), base);
futures::future::join_all((0..8).map(|i| {
let name = format!("FR - CHAINE {i}");
let catalog = &catalog;
async move { catalog.logo_for(&name).await }
}))
.await;
assert_eq!(requested.lock().unwrap().len(), 1);
}
}
+8
View File
@@ -0,0 +1,8 @@
pub mod cleanup;
pub mod ffmpeg_sessions;
pub mod gpu_fallback;
pub mod logo_catalog;
pub mod recording_decision;
pub mod recording_scheduler;
pub mod upstream_slots;
pub mod xmltv;
+241
View File
@@ -0,0 +1,241 @@
//! Logique de décision pure du planificateur d'enregistrements, isolée pour
//! être testable.
use chrono::{DateTime, Duration, Utc};
#[derive(Debug, PartialEq, Eq)]
pub enum RecordingAction {
/// Démarrer maintenant.
Start,
/// Capacité atteinte mais fenêtre encore ouverte : réessayer plus tard.
Wait,
/// L'heure de fin est passée avant que la capture ait pu démarrer.
Fail,
/// Pas encore l'heure.
None,
}
/// Que faire d'un enregistrement planifié à ce tick du scheduler ?
pub fn decide_recording_action(
now: DateTime<Utc>,
start: DateTime<Utc>,
end: DateTime<Utc>,
active_count: usize,
max_concurrent: usize,
) -> RecordingAction {
if now > end {
return RecordingAction::Fail;
}
if now <= start {
return RecordingAction::None;
}
if active_count >= max_concurrent {
return RecordingAction::Wait;
}
RecordingAction::Start
}
/// Sort d'un enregistrement resté au statut « recording » en base alors
/// qu'aucun processus FFmpeg ne lui correspond (redémarrage du conteneur…).
#[derive(Debug, PartialEq, Eq)]
pub enum OrphanAction {
/// La fenêtre est encore ouverte : relancer la capture.
Resume,
/// La fenêtre est passée mais un fichier partiel existe : le conserver.
Finish,
/// Rien d'exploitable n'a été capturé.
Fail,
}
pub fn decide_orphan_action(
now: DateTime<Utc>,
end: DateTime<Utc>,
has_file: bool,
) -> OrphanAction {
let remaining = end - now;
if remaining > Duration::seconds(60) {
return OrphanAction::Resume;
}
if has_file {
OrphanAction::Finish
} else {
OrphanAction::Fail
}
}
/// Suite à donner quand un processus FFmpeg se termine.
#[derive(Debug, PartialEq, Eq)]
pub enum PostExitAction {
/// La capture est allée jusqu'au bout (ou assez loin) : terminé.
Complete,
/// FFmpeg s'est arrêté trop tôt : relancer sur la fin de fenêtre.
Retry,
/// Arrêt prématuré sans rien d'exploitable.
Fail,
}
/// Échecs FFmpeg consécutifs tolérés avant d'abandonner un enregistrement
/// dont la fenêtre est encore ouverte. Le compteur est remis à zéro dès
/// qu'un lancement a duré assez longtemps pour être considéré sain.
pub const MAX_FFMPEG_ATTEMPTS: u32 = 30;
/// FFmpeg rend la main dès que la source se tarit : sur un flux IPTV, une
/// coupure amont à mi-parcours ne doit pas condamner l'heure restante.
pub fn decide_post_exit_action(
now: DateTime<Utc>,
end: DateTime<Utc>,
exit_code: i32,
consecutive_failures: u32,
has_file: bool,
) -> PostExitAction {
let remaining = end - now;
if remaining > Duration::seconds(60) && consecutive_failures + 1 < MAX_FFMPEG_ATTEMPTS {
return PostExitAction::Retry;
}
// `255` est le code renvoyé par FFmpeg quand on l'interrompt proprement.
let clean_exit = exit_code == 0 || exit_code == 255;
if clean_exit || has_file {
PostExitAction::Complete
} else {
PostExitAction::Fail
}
}
/// Durée de capture (`-t`) : le temps restant jusqu'à la fin programmée,
/// et non la durée théorique du programme — un démarrage tardif (ou une
/// relance après coupure) ne doit pas décaler la fin de l'enregistrement.
pub fn capture_duration(now: DateTime<Utc>, end: DateTime<Utc>) -> Duration {
let remaining = end - now;
let minimum = Duration::seconds(30);
if remaining < minimum {
minimum
} else {
remaining
}
}
/// Délai avant relance de FFmpeg, croissant avec les échecs consécutifs.
///
/// Rouvrir la source dans la seconde se fait refuser sur les comptes limités
/// en connexions simultanées ; s'acharner à cette cadence pendant une panne
/// amont ne ferait qu'épuiser le quota de relances.
pub fn ffmpeg_retry_delay(consecutive_failures: u32) -> std::time::Duration {
let steps = (consecutive_failures as i64 - 1).clamp(0, 4) as u32;
let seconds = 3u64 * (1 << steps);
std::time::Duration::from_secs(seconds.min(30))
}
#[cfg(test)]
mod tests {
use super::*;
fn now() -> DateTime<Utc> {
"2026-08-27T21:00:00Z".parse().unwrap()
}
#[test]
fn actions_de_planification() {
let n = now();
let m = Duration::minutes;
assert_eq!(
decide_recording_action(n, n - m(1), n + m(60), 0, 2),
RecordingAction::Start
);
assert_eq!(
decide_recording_action(n, n - m(1), n + m(60), 2, 2),
RecordingAction::Wait
);
assert_eq!(
decide_recording_action(n, n - m(120), n - m(5), 0, 2),
RecordingAction::Fail
);
assert_eq!(
decide_recording_action(n, n + m(30), n + m(60), 0, 2),
RecordingAction::None
);
assert_eq!(
decide_recording_action(n, n - m(1), n + m(60), 1, 2),
RecordingAction::Start
);
}
#[test]
fn actions_orphelines() {
let n = now();
assert_eq!(
decide_orphan_action(n, n + Duration::minutes(30), true),
OrphanAction::Resume
);
assert_eq!(
decide_orphan_action(n, n + Duration::minutes(30), false),
OrphanAction::Resume
);
assert_eq!(
decide_orphan_action(n, n - Duration::minutes(5), true),
OrphanAction::Finish
);
assert_eq!(
decide_orphan_action(n, n - Duration::minutes(5), false),
OrphanAction::Fail
);
assert_eq!(
decide_orphan_action(n, n + Duration::seconds(20), true),
OrphanAction::Finish
);
}
#[test]
fn actions_apres_sortie() {
let n = now();
let m40 = n + Duration::minutes(40);
assert_eq!(
decide_post_exit_action(n, m40, 1, 0, true),
PostExitAction::Retry
);
assert_eq!(
decide_post_exit_action(n, m40, 0, 2, true),
PostExitAction::Retry
);
assert_eq!(
decide_post_exit_action(n, m40, 1, MAX_FFMPEG_ATTEMPTS - 1, true),
PostExitAction::Complete
);
assert_eq!(
decide_post_exit_action(n, n, 0, 0, true),
PostExitAction::Complete
);
assert_eq!(
decide_post_exit_action(n, n, 1, 0, true),
PostExitAction::Complete
);
assert_eq!(
decide_post_exit_action(n, n, 1, 0, false),
PostExitAction::Fail
);
}
#[test]
fn duree_de_capture() {
let n = now();
assert_eq!(
capture_duration(n, n + Duration::minutes(58)),
Duration::minutes(58)
);
assert_eq!(
capture_duration(n, n - Duration::minutes(5)),
Duration::seconds(30)
);
}
#[test]
fn delai_de_relance() {
let s = std::time::Duration::from_secs;
assert_eq!(ffmpeg_retry_delay(1), s(3));
assert_eq!(ffmpeg_retry_delay(2), s(6));
assert_eq!(ffmpeg_retry_delay(3), s(12));
assert_eq!(ffmpeg_retry_delay(4), s(24));
assert_eq!(ffmpeg_retry_delay(5), s(30));
assert_eq!(ffmpeg_retry_delay(50), s(30));
assert_eq!(ffmpeg_retry_delay(0), s(3));
}
}
File diff suppressed because it is too large. Load diff
+429
View File
@@ -0,0 +1,429 @@
//! Connexions ouvertes vers le fournisseur Xtream, par compte.
//!
//! POURQUOI : la plupart des abonnements n'autorisent qu'UNE connexion
//! simultanée (`max_connections: "1"`, vérifié en prod). Or un FFmpeg de
//! lecture survit au spectateur : 4 min pour un direct HLS, jusqu'à 15 min
//! ou la fin du téléchargement pour un film, le temps de détecter la
//! déconnexion pour `turbo.ts`. Le flux suivant tombait sur un slot occupé :
//! le panneau répondait `HTTP 551` ou ne répondait pas (timeout), d'où les
//! films « indisponibles » et les zaps qui échouent.
//!
//! Règle : avant d'ouvrir une nouvelle connexion amont, on coupe les
//! connexions ORPHELINES du même compte (plus personne ne les consomme),
//! les plus anciennes d'abord, jusqu'à lui faire de la place.
//!
//! Un flux encore regardé n'est jamais coupé : deux lecteurs ouverts sur un
//! compte à une connexion se coupaient sinon en boucle (constaté en prod).
//! Le second lecteur est refusé par le panneau et le premier continue.
//!
//! Les enregistrements n'y sont pas inscrits : ils ne sont jamais coupés.
use crate::models::PlaylistConfig;
use futures::future::BoxFuture;
use std::collections::{HashMap, HashSet};
use std::sync::{Arc, Mutex};
use std::time::{Duration, Instant};
type Release = Box<dyn Fn() + Send + Sync>;
type IsActive = Box<dyn Fn() -> bool + Send + Sync>;
struct Slot {
token: u64,
account: String,
/// Ordre d'inscription : les plus anciennes connexions partent d'abord.
since: u64,
release: Release,
is_active: Option<IsActive>,
}
impl Slot {
fn active(&self) -> bool {
self.is_active.as_ref().is_some_and(|f| f())
}
}
#[derive(Default)]
struct Inner {
slots: HashMap<String, Slot>,
next: u64,
}
#[derive(Default)]
pub struct UpstreamSlots {
inner: Mutex<Inner>,
}
impl UpstreamSlots {
pub fn new() -> Self {
Self::default()
}
/// Inscrit la connexion [id] du compte [account]. [release] la coupe.
///
/// Renvoie un jeton à repasser à [unregister] : une session relancée sous
/// le même identifiant ne doit pas être désinscrite par la fin de
/// l'ancien processus. [is_active] : vrai tant qu'un spectateur consomme
/// le flux ; une connexion active n'est jamais coupée.
pub fn register(
&self,
id: &str,
account: &str,
release: Release,
is_active: Option<IsActive>,
) -> u64 {
let mut inner = self.inner.lock().unwrap();
inner.next += 1;
let token = inner.next;
inner.slots.insert(
id.to_string(),
Slot {
token,
account: account.to_string(),
since: token,
release,
is_active,
},
);
token
}
pub fn unregister(&self, id: &str, token: Option<u64>) {
let mut inner = self.inner.lock().unwrap();
let Some(slot) = inner.slots.get(id) else {
return;
};
if token.is_some_and(|t| t != slot.token) {
return;
}
inner.slots.remove(id);
}
#[cfg_attr(not(test), allow(dead_code))]
pub fn count_for(&self, account: &str) -> usize {
self.inner
.lock()
.unwrap()
.slots
.values()
.filter(|s| s.account == account)
.count()
}
/// Libère assez de connexions de [account] pour qu'une nouvelle tienne
/// sous [max]. [keep] (la session demandée) n'est jamais coupée.
///
/// [max] <= 0 = quota inconnu : on ne coupe rien plutôt que de risquer
/// d'interrompre un autre spectateur.
pub fn make_room(&self, account: &str, max: i64, keep: &str) -> Vec<String> {
if max <= 0 {
return Vec::new();
}
let released: Vec<(String, Release)> = {
let mut inner = self.inner.lock().unwrap();
let others: Vec<&String> = inner
.slots
.iter()
.filter(|(id, s)| s.account == account && id.as_str() != keep)
.map(|(id, _)| id)
.collect();
let excess = others.len() as i64 - (max - 1);
if excess <= 0 {
return Vec::new();
}
let mut orphans: Vec<(String, u64)> = others
.into_iter()
.filter(|id| !inner.slots[*id].active())
.map(|id| (id.clone(), inner.slots[id].since))
.collect();
orphans.sort_by_key(|(_, since)| *since);
orphans
.into_iter()
.take(excess as usize)
.filter_map(|(id, _)| inner.slots.remove(&id).map(|s| (id, s.release)))
.collect()
};
// Coupures hors du verrou : un `release` peut toucher d'autres registres.
released
.into_iter()
.map(|(id, release)| {
let _ = std::panic::catch_unwind(std::panic::AssertUnwindSafe(&release));
id
})
.collect()
}
}
/// Clé de compte : deux playlists sur les mêmes identifiants partagent le
/// même quota chez le fournisseur.
pub fn account_key_of(p: &PlaylistConfig) -> String {
format!("{}|{}", p.dns, p.username)
}
/// `user_info.max_connections` d'une réponse `player_api.php`, ou `None`.
pub fn parse_max_connections(json: &serde_json::Value) -> Option<i64> {
let raw = json.get("user_info")?.as_object()?.get("max_connections")?;
let value = match raw {
serde_json::Value::Number(n) => n.as_i64(),
serde_json::Value::String(s) => s.parse().ok(),
_ => None,
}?;
(value > 0).then_some(value)
}
pub type FetchLimit = Arc<dyn Fn(PlaylistConfig) -> BoxFuture<'static, Option<i64>> + Send + Sync>;
/// Quota de connexions par compte, lu chez le fournisseur et mis en cache.
///
/// Ne fait JAMAIS attendre un flux : `player_api.php` met jusqu'à 4 s à
/// répondre (mesuré en prod). La valeur connue (même périmée), sinon
/// [FALLBACK], est rendue immédiatement ; la lecture chez le panneau se fait
/// en arrière-plan. Sans risque : le quota ne sert qu'à décider quels flux
/// ORPHELINS couper.
pub struct AccountLimits {
ttl: Duration,
fetch: FetchLimit,
cache: Arc<Mutex<HashMap<String, (i64, Instant)>>>,
refreshing: Arc<Mutex<HashSet<String>>>,
}
impl AccountLimits {
/// Valeur retenue tant que le panneau n'a pas répondu : un seul flux, le
/// cas de loin le plus courant chez les fournisseurs Xtream.
pub const FALLBACK: i64 = 1;
pub fn new(ttl: Duration, fetch: FetchLimit) -> Self {
Self {
ttl,
fetch,
cache: Arc::default(),
refreshing: Arc::default(),
}
}
/// Lecture réelle chez le panneau.
pub fn from_panel(client: reqwest::Client) -> Self {
let fetch: FetchLimit = Arc::new(move |p: PlaylistConfig| {
let client = client.clone();
Box::pin(async move {
let mut url = reqwest::Url::parse(&format!("{}/player_api.php", p.dns)).ok()?;
url.query_pairs_mut()
.append_pair("username", &p.username)
.append_pair("password", &p.password);
let send = client
.get(url)
.header("User-Agent", "VLC/3.0.18 LibVLC/3.0.18")
.send();
let response = tokio::time::timeout(Duration::from_secs(5), send)
.await
.ok()?
.ok()?;
if response.status().as_u16() != 200 {
return None;
}
let body = tokio::time::timeout(Duration::from_secs(5), response.bytes())
.await
.ok()?
.ok()?;
let json: serde_json::Value = serde_json::from_slice(&body).ok()?;
parse_max_connections(&json)
})
});
Self::new(Duration::from_secs(600), fetch)
}
pub fn max_for(&self, p: &PlaylistConfig) -> i64 {
let key = account_key_of(p);
let cached = self.cache.lock().unwrap().get(&key).copied();
if cached.is_none_or(|(_, at)| at.elapsed() >= self.ttl) {
self.refresh(key, p.clone());
}
cached.map(|(max, _)| max).unwrap_or(Self::FALLBACK)
}
fn refresh(&self, key: String, p: PlaylistConfig) {
if !self.refreshing.lock().unwrap().insert(key.clone()) {
return; // une seule lecture à la fois
}
let fetch = self.fetch.clone();
let cache = self.cache.clone();
let refreshing = self.refreshing.clone();
tokio::spawn(async move {
// Un échec n'est pas mis en cache : on réessaiera au prochain flux.
if let Some(value) = fetch(p).await {
cache
.lock()
.unwrap()
.insert(key.clone(), (value, Instant::now()));
}
refreshing.lock().unwrap().remove(&key);
});
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::atomic::{AtomicUsize, Ordering};
fn collector() -> (Arc<Mutex<Vec<String>>>, impl Fn(&str) -> Release) {
let released = Arc::new(Mutex::new(Vec::new()));
let r = released.clone();
let make = move |id: &str| -> Release {
let r = r.clone();
let id = id.to_string();
Box::new(move || r.lock().unwrap().push(id.clone()))
};
(released, make)
}
#[test]
fn compte_a_une_connexion_le_flux_precedent_est_coupe() {
let slots = UpstreamSlots::new();
let (released, make) = collector();
slots.register("live_1_source", "acc", make("live_1_source"), None);
assert_eq!(
slots.make_room("acc", 1, "live_2_source"),
vec!["live_1_source"]
);
assert_eq!(*released.lock().unwrap(), vec!["live_1_source"]);
assert_eq!(slots.count_for("acc"), 0);
}
#[test]
fn la_session_demandee_n_est_jamais_coupee() {
let slots = UpstreamSlots::new();
let (released, make) = collector();
slots.register("live_1_source", "acc", make("live_1_source"), None);
assert!(slots.make_room("acc", 1, "live_1_source").is_empty());
assert!(released.lock().unwrap().is_empty());
}
#[test]
fn les_plus_anciennes_partent_d_abord() {
let slots = UpstreamSlots::new();
let (_released, make) = collector();
for id in ["a", "b", "c"] {
slots.register(id, "acc", make(id), None);
}
assert_eq!(slots.make_room("acc", 3, "new"), vec!["a"]);
assert_eq!(slots.count_for("acc"), 2);
}
#[test]
fn un_autre_compte_n_est_jamais_touche() {
let slots = UpstreamSlots::new();
let (_released, make) = collector();
slots.register("x", "other", make("x"), None);
assert!(slots.make_room("acc", 1, "new").is_empty());
assert_eq!(slots.count_for("other"), 1);
}
#[test]
fn jeton_perime_ne_libere_pas_la_session_relancee() {
let slots = UpstreamSlots::new();
let old = slots.register("s", "acc", Box::new(|| {}), None);
slots.register("s", "acc", Box::new(|| {}), None);
slots.unregister("s", Some(old));
assert_eq!(slots.count_for("acc"), 1);
}
#[test]
fn un_flux_regarde_n_est_jamais_coupe() {
let slots = UpstreamSlots::new();
let (released, make) = collector();
slots.register("watched", "acc", make("watched"), Some(Box::new(|| true)));
assert!(slots.make_room("acc", 1, "new").is_empty());
assert!(released.lock().unwrap().is_empty());
assert_eq!(slots.count_for("acc"), 1);
}
#[test]
fn seuls_les_orphelins_partent() {
let slots = UpstreamSlots::new();
let (released, make) = collector();
slots.register("watched", "acc", make("watched"), Some(Box::new(|| true)));
slots.register("orphan", "acc", make("orphan"), None);
assert_eq!(slots.make_room("acc", 1, "new"), vec!["orphan"]);
assert_eq!(*released.lock().unwrap(), vec!["orphan"]);
}
#[test]
fn quota_inconnu_rien_n_est_coupe() {
let slots = UpstreamSlots::new();
let (_released, make) = collector();
slots.register("a", "acc", make("a"), None);
assert!(slots.make_room("acc", 0, "new").is_empty());
}
fn playlist() -> PlaylistConfig {
PlaylistConfig {
dns: "http://panel.test".into(),
username: "u".into(),
password: "x".into(),
}
}
#[tokio::test]
async fn ne_fait_jamais_attendre_un_flux() {
let (tx, rx) = tokio::sync::oneshot::channel::<Option<i64>>();
let rx = Arc::new(Mutex::new(Some(rx)));
let fetch: FetchLimit = Arc::new(move |_| {
let rx = rx.lock().unwrap().take();
Box::pin(async move {
match rx {
Some(rx) => rx.await.ok().flatten(),
None => None,
}
})
});
let limits = AccountLimits::new(Duration::from_secs(600), fetch);
assert_eq!(limits.max_for(&playlist()), AccountLimits::FALLBACK);
tx.send(Some(3)).unwrap();
tokio::time::sleep(Duration::from_millis(20)).await;
assert_eq!(limits.max_for(&playlist()), 3);
}
#[tokio::test]
async fn valeur_perimee_servie_pendant_le_rafraichissement() {
let calls = Arc::new(AtomicUsize::new(0));
let c = calls.clone();
let fetch: FetchLimit = Arc::new(move |_| {
let n = c.fetch_add(1, Ordering::SeqCst) + 1;
Box::pin(async move { Some(if n == 1 { 2 } else { 5 }) })
});
let limits = AccountLimits::new(Duration::ZERO, fetch);
limits.max_for(&playlist());
tokio::time::sleep(Duration::from_millis(20)).await;
assert_eq!(limits.max_for(&playlist()), 2);
tokio::time::sleep(Duration::from_millis(20)).await;
assert_eq!(limits.max_for(&playlist()), 5);
}
#[tokio::test]
async fn panneau_en_echec_repli_sans_exception() {
let fetch: FetchLimit = Arc::new(|_| Box::pin(async { None }));
let limits = AccountLimits::new(Duration::from_secs(600), fetch);
assert_eq!(limits.max_for(&playlist()), AccountLimits::FALLBACK);
tokio::time::sleep(Duration::from_millis(20)).await;
assert_eq!(limits.max_for(&playlist()), AccountLimits::FALLBACK);
}
#[test]
fn lit_max_connections() {
use serde_json::json;
assert_eq!(
parse_max_connections(&json!({"user_info": {"max_connections": "1"}})),
Some(1)
);
assert_eq!(
parse_max_connections(&json!({"user_info": {"max_connections": 3}})),
Some(3)
);
assert_eq!(parse_max_connections(&json!({"user_info": {}})), None);
assert_eq!(parse_max_connections(&json!("oops")), None);
assert_eq!(
parse_max_connections(&json!({"user_info": {"max_connections": "0"}})),
None
);
}
}
File diff suppressed because it is too large. Load diff
+65
View File
@@ -0,0 +1,65 @@
//! État partagé du serveur.
use crate::api::epg::EpgApi;
use crate::api::proxy::ProxyHandler;
use crate::api::streaming::Streaming;
use crate::db::AppDatabase;
use crate::http::Req;
use crate::models::PlaylistConfig;
use crate::server::RateLimiter;
use crate::services::cleanup::CleanupService;
use crate::services::logo_catalog::LogoCatalog;
use crate::services::recording_scheduler::RecordingScheduler;
use std::collections::HashMap;
use std::sync::Arc;
pub struct AppState {
pub db: Arc<AppDatabase>,
pub web_path: String,
/// Points d'entrée réécrits avec `?v=<empreinte>` (asset_versioning).
pub versioned: HashMap<String, String>,
/// Client HTTP qui suit les redirections.
pub http: reqwest::Client,
/// Client HTTP sans suivi des redirections (revalidées à la main).
pub http_manual: reqwest::Client,
pub scheduler: Arc<RecordingScheduler>,
pub streaming: Arc<Streaming>,
pub proxy: ProxyHandler,
pub epg: EpgApi,
pub logo_catalog: Arc<LogoCatalog>,
pub cleanup: Arc<CleanupService>,
pub rate_limit: RateLimiter,
pub login_rate_limit: RateLimiter,
pub allowed_origin: Option<String>,
pub trusted_proxies: Vec<String>,
}
impl AppState {
/// Playlist Xtream de la requête : la première playlist de l'utilisateur
/// authentifié ; à défaut (proxy, streaming), celle du premier
/// utilisateur de la base.
pub fn get_playlist(&self, req: &Req) -> Option<PlaylistConfig> {
let playlist = match &req.user {
Some(user) => self.db.get_playlists(&user.id).into_iter().next(),
None => {
let users = self.db.get_all_users();
users
.first()
.and_then(|u| self.db.get_playlists(&u.id).into_iter().next())
}
};
match playlist {
Some(p) => Some(PlaylistConfig {
dns: p.server_url,
username: p.username,
password: p.password,
}),
None => {
println!("[getPlaylist] WARNING: No playlist found, returning null");
None
}
}
}
}
pub type SharedState = Arc<AppState>;
+135
View File
@@ -0,0 +1,135 @@
//! Versionnage des assets non hachés de la build web.
//!
//! POURQUOI : `main.dart.js`, `flutter_bootstrap.js` et `xf-player-core.js`
//! gardent le même nom d'une build à l'autre. Un reverse proxy qui met les
//! `.js` en cache (option « Cache Assets » de Nginx Proxy Manager, constatée
//! en prod : cache jusqu'à 12 h, même sur un rechargement forcé) servait donc
//! l'ANCIEN lecteur et l'ANCIENNE app avec le NOUVEAU serveur après chaque
//! déploiement. En ajoutant `?v=<empreinte du contenu>` aux références,
//! chaque version a sa propre URL : aucun cache intermédiaire ne peut la
//! confondre avec la précédente.
use md5::{Digest, Md5};
use std::collections::HashMap;
use std::path::Path;
/// Empreinte courte et stable de [bytes].
pub fn content_version(bytes: &[u8]) -> String {
let digest = Md5::digest(bytes);
let hex: String = digest.iter().map(|b| format!("{b:02x}")).collect();
hex[..12].to_string()
}
/// Ajoute `?v=<version>` à chaque référence entre guillemets à un asset de
/// [versions] (`"main.dart.js"` → `"main.dart.js?v=…"`).
///
/// Seules les références exactes sont réécrites : `vendor/x.js` ou une
/// référence déjà versionnée restent intactes.
pub fn version_asset_refs(content: &str, versions: &[(&str, String)]) -> String {
let mut out = content.to_string();
for (asset, version) in versions {
for quote in ['"', '\''] {
let from = format!("{quote}{asset}{quote}");
let to = format!("{quote}{asset}?v={version}{quote}");
out = out.replace(&from, &to);
}
}
out
}
/// Contenus réécrits, indexés par chemin relatif (`index.html`…), prêts à
/// être servis à la place des fichiers d'origine.
///
/// Calculé une fois au démarrage : la build web est figée dans l'image.
pub fn build_versioned_entry_points(web_path: &str) -> HashMap<String, String> {
let base = Path::new(web_path);
let read = |name: &str| std::fs::read_to_string(base.join(name)).ok();
let version_of = |name: &str| {
std::fs::read(base.join(name))
.ok()
.map(|b| content_version(&b))
};
let mut rewritten = HashMap::new();
// Le bootstrap référence main.dart.js : sa version dépend donc de celle
// de main.dart.js, d'où une version calculée APRÈS réécriture.
if let (Some(bootstrap), Some(main_version)) =
(read("flutter_bootstrap.js"), version_of("main.dart.js"))
{
let versioned = version_asset_refs(&bootstrap, &[("main.dart.js", main_version)]);
let bootstrap_version = content_version(versioned.as_bytes());
rewritten.insert("flutter_bootstrap.js".to_string(), versioned);
if let Some(index) = read("index.html") {
rewritten.insert(
"index.html".to_string(),
version_asset_refs(&index, &[("flutter_bootstrap.js", bootstrap_version)]),
);
}
}
let player_assets: Vec<(&str, String)> = ["xf-player-core.js", "xf-player.css"]
.into_iter()
.filter_map(|name| version_of(name).map(|v| (name, v)))
.collect();
for page in ["player.html", "player_lite.html", "player_mobile.html"] {
if let Some(html) = read(page) {
rewritten.insert(page.to_string(), version_asset_refs(&html, &player_assets));
}
}
rewritten
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn versionne_la_balise_script_du_lecteur() {
assert_eq!(
version_asset_refs(
r#"<script src="xf-player-core.js"></script>"#,
&[("xf-player-core.js", "abc123".into())]
),
r#"<script src="xf-player-core.js?v=abc123"></script>"#
);
}
#[test]
fn versionne_main_js_path_dans_la_config_flutter() {
assert_eq!(
version_asset_refs(
r#"{"compileTarget":"dart2js","mainJsPath":"main.dart.js"}"#,
&[("main.dart.js", "f00".into())]
),
r#"{"compileTarget":"dart2js","mainJsPath":"main.dart.js?v=f00"}"#
);
}
#[test]
fn ne_touche_ni_un_nom_contenant_l_asset_ni_une_ref_versionnee() {
let html = r#"<script src="vendor/xf-player-core.js"></script><script src="xf-player-core.js?v=old"></script>"#;
assert_eq!(
version_asset_refs(html, &[("xf-player-core.js", "new".into())]),
html
);
}
#[test]
fn guillemets_simples_acceptes() {
assert_eq!(
version_asset_refs(
"<link href='xf-player.css'>",
&[("xf-player.css", "1".into())]
),
"<link href='xf-player.css?v=1'>"
);
}
#[test]
fn empreinte_stable() {
assert_eq!(content_version(&[1, 2, 3]), content_version(&[1, 2, 3]));
assert_ne!(content_version(&[1, 2, 3]), content_version(&[1, 2, 4]));
assert_eq!(content_version(&[1, 2, 3]).len(), 12);
}
}
+38
View File
@@ -0,0 +1,38 @@
//! Décodage base64 des titres/descriptions EPG du panneau Xtream.
use base64::Engine;
use base64::engine::general_purpose::{STANDARD, URL_SAFE};
/// Texte décodé si [raw] est du base64 valide contenant de l'UTF-8 valide,
/// sinon `None` (l'appelant garde alors la valeur brute, comme
/// `utf8.decode(base64Decode(x))` qui levait une exception).
pub fn decode_text(raw: &str) -> Option<String> {
let bytes = STANDARD
.decode(raw.as_bytes())
.or_else(|_| URL_SAFE.decode(raw.as_bytes()))
.ok()?;
String::from_utf8(bytes).ok()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn decode_le_base64_du_panneau() {
assert_eq!(
decode_text("Sm91cm5hbCBkZSAyMGg=").as_deref(),
Some("Journal de 20h")
);
assert_eq!(
decode_text("TCdpbmZvIGR1IHNvaXI=").as_deref(),
Some("L'info du soir")
);
}
#[test]
fn garde_un_titre_en_clair() {
assert_eq!(decode_text("Journal"), None);
assert_eq!(decode_text("Le 13h"), None);
}
}
+55
View File
@@ -0,0 +1,55 @@
//! Masque les identifiants IPTV des URL avant qu'elles n'atteignent les logs.
//!
//! Xtream Codes embarque les identifiants à la fois dans la query string
//! (`?username=u&password=p`) et dans le chemin
//! (`/live/<user>/<pass>/<id>.ts`, `/movie/...`, `/series/...`).
use regex::Regex;
use std::sync::LazyLock;
static QUERY_CREDS: LazyLock<Regex> =
LazyLock::new(|| Regex::new(r"(?i)(username|password)=[^&\s]*").unwrap());
static PATH_CREDS: LazyLock<Regex> =
LazyLock::new(|| Regex::new(r"(?i)/(live|movie|series)/[^/\s]+/[^/\s]+/").unwrap());
/// Renvoie [url] avec les identifiants remplacés par `***`.
pub fn redact_url(url: &str) -> String {
let redacted = QUERY_CREDS.replace_all(url, "$1=***");
PATH_CREDS
.replace_all(&redacted, "/$1/***/***/")
.into_owned()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn masque_la_query_string() {
let out = redact_url(
"http://srv:8080/player_api.php?username=john&password=hunter2&action=get_live_streams",
);
assert!(!out.contains("john"));
assert!(!out.contains("hunter2"));
assert!(out.contains("username=***"));
assert!(out.contains("password=***"));
assert!(out.contains("action=get_live_streams"));
}
#[test]
fn masque_les_segments_de_chemin() {
for kind in ["live", "movie", "series"] {
let out = redact_url(&format!("http://srv:8080/{kind}/john/hunter2/12345.ts"));
assert!(!out.contains("john"), "{kind}");
assert!(!out.contains("hunter2"), "{kind}");
assert!(out.contains(&format!("/{kind}/***/***/12345.ts")), "{kind}");
}
}
#[test]
fn laisse_les_url_sans_identifiants() {
let url = "http://srv:8080/images/logo.png";
assert_eq!(redact_url(url), url);
}
}
+113
View File
@@ -0,0 +1,113 @@
//! Inspection des fichiers média locaux (enregistrements) via ffprobe.
//!
//! Les résultats sont mémorisés tant que le fichier ne change pas : une
//! liste d'enregistrements est rafraîchie toutes les 5 s pendant une capture,
//! et la playlist de lecture est rechargée en continu — sans cache, ffprobe
//! tournerait en boucle.
use std::collections::HashMap;
use std::sync::{LazyLock, Mutex};
use std::time::UNIX_EPOCH;
struct CacheEntry {
size: u64,
mtime_ms: u128,
value: String,
}
static CACHE: LazyLock<Mutex<HashMap<String, CacheEntry>>> =
LazyLock::new(|| Mutex::new(HashMap::new()));
/// Binaire ffprobe : l'image Docker l'installe dans `/usr/local/bin`
/// à côté de FFmpeg ; `FFPROBE_PATH` permet de le surcharger.
pub fn ffprobe_path() -> String {
if let Ok(path) = std::env::var("FFPROBE_PATH")
&& !path.is_empty()
{
return path;
}
if cfg!(target_os = "linux") && std::path::Path::new("/usr/local/bin/ffprobe").exists() {
return "/usr/local/bin/ffprobe".to_string();
}
"ffprobe".to_string()
}
/// Durée du média en secondes, ou `None` si elle n'est pas mesurable.
pub async fn duration(path: &str) -> Option<f64> {
let value = probe(path, "duration", &["-show_entries", "format=duration"]).await?;
let seconds: f64 = value.parse().ok()?;
(seconds.is_finite() && seconds > 0.0).then_some(seconds)
}
/// Codec de la première piste vidéo (`h264`, `hevc`, `mpeg2video`…).
pub async fn video_codec(path: &str) -> Option<String> {
let value = probe(
path,
"videoCodec",
&[
"-select_streams",
"v:0",
"-show_entries",
"stream=codec_name",
],
)
.await?;
(!value.is_empty()).then(|| value.to_lowercase())
}
fn stat(path: &str) -> Option<(u64, u128)> {
let meta = std::fs::metadata(path).ok()?;
let mtime = meta
.modified()
.ok()?
.duration_since(UNIX_EPOCH)
.ok()?
.as_millis();
Some((meta.len(), mtime))
}
/// Exécute ffprobe une fois par (fichier, champ) et mémorise le résultat
/// tant que taille et date de modification sont inchangées.
async fn probe(path: &str, field: &str, args: &[&str]) -> Option<String> {
let (size, mtime_ms) = stat(path)?;
let key = format!("{field}@{path}");
{
let cache = CACHE.lock().unwrap();
if let Some(entry) = cache.get(&key)
&& entry.size == size
&& entry.mtime_ms == mtime_ms
{
return Some(entry.value.clone());
}
}
let output = tokio::process::Command::new(ffprobe_path())
.args(["-v", "error"])
.args(args)
.args(["-of", "default=noprint_wrappers=1:nokey=1", path])
.stdin(std::process::Stdio::null())
.output()
.await
.ok()?;
// ffprobe imprime une ligne par flux : seule la première nous intéresse.
let stdout = String::from_utf8_lossy(&output.stdout);
let first = stdout
.trim()
.split('\n')
.next()
.unwrap_or("")
.trim()
.to_string();
if first.is_empty() || first == "N/A" {
return None;
}
CACHE.lock().unwrap().insert(
key,
CacheEntry {
size,
mtime_ms,
value: first.clone(),
},
);
Some(first)
}
+7
View File
@@ -0,0 +1,7 @@
pub mod asset_versioning;
pub mod b64;
pub mod log_redactor;
pub mod media_probe;
pub mod password;
pub mod process;
pub mod safe_path;
+77
View File
@@ -0,0 +1,77 @@
//! Hachage des mots de passe : bcrypt, avec lecture des anciens hachages
//! SHA-256 non salés (migrés paresseusement à la connexion suivante).
use regex::Regex;
use sha2::{Digest, Sha256};
use std::sync::LazyLock;
/// Coût 10 : identique à l'ancien serveur, les hachages existants restent
/// vérifiables et le temps de connexion ne change pas.
const BCRYPT_COST: u32 = 10;
/// Les hachages historiques sont des empreintes SHA-256 hexadécimales.
static LEGACY: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"^[0-9a-f]{64}$").unwrap());
pub fn hash(password: &str) -> Result<String, String> {
bcrypt::hash(password, BCRYPT_COST).map_err(|e| e.to_string())
}
/// Vérifie [password] contre le hachage stocké (bcrypt ou SHA-256 historique).
pub fn verify(password: &str, stored: &str) -> bool {
if is_legacy(stored) {
let digest = Sha256::digest(password.as_bytes());
let hex: String = digest.iter().map(|b| format!("{b:02x}")).collect();
return hex == stored;
}
// Hachage malformé (ni SHA-256 ni bcrypt valide) : jamais valide.
bcrypt::verify(password, stored).unwrap_or(false)
}
pub fn is_legacy(stored: &str) -> bool {
LEGACY.is_match(stored)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn bcrypt_aller_retour() {
let h = hash("s3cret!").unwrap();
assert!(verify("s3cret!", &h));
assert!(!verify("wrong", &h));
}
#[test]
fn les_hachages_sha256_historiques_restent_valides() {
// sha256("admin")
let legacy = "8c6976e5b5410415bde908bd4dee15dfb167a9c873fc4bb8a81f6f2ab448a918";
assert!(verify("admin", legacy));
assert!(!verify("other", legacy));
}
#[test]
fn is_legacy_ne_reconnait_que_le_sha256_hex() {
assert!(is_legacy(
"8c6976e5b5410415bde908bd4dee15dfb167a9c873fc4bb8a81f6f2ab448a918"
));
assert!(!is_legacy("$2a$10$abcdefghijklmnopqrstuv"));
assert!(!is_legacy(&hash("x").unwrap()));
}
#[test]
fn lit_les_hachages_2a_de_l_ancien_serveur() {
// Hachage produit par le paquet Dart `bcrypt` (préfixe $2a$).
let dart = bcrypt::hash_with_salt("admin", 10, [7u8; 16])
.unwrap()
.format_for_version(bcrypt::Version::TwoA);
assert!(dart.starts_with("$2a$10$"));
assert!(verify("admin", &dart));
}
#[test]
fn un_hachage_malforme_n_est_jamais_valide() {
assert!(!verify("x", "not-a-hash"));
assert!(!verify("", ""));
}
}
+123
View File
@@ -0,0 +1,123 @@
//! Processus enfants (FFmpeg) pilotés par signal.
//!
//! POURQUOI : `tokio::process::Child::kill` envoie SIGKILL. FFmpeg tué ainsi
//! ne finalise pas son fichier (index MKV absent, playlist HLS sans fin) :
//! l'ancien serveur envoyait SIGTERM, que FFmpeg traite comme un arrêt
//! propre. Le processus est donc confié à une tâche qui attend sa fin et
//! relaie les demandes d'arrêt sous forme de vrais signaux.
use tokio::process::Child;
use tokio::sync::{mpsc, watch};
#[derive(Clone, Copy, Debug)]
enum Signal {
Term,
Kill,
}
/// Processus suivi : signal d'arrêt, code de sortie observable.
#[derive(Clone)]
pub struct ManagedChild {
signals: mpsc::UnboundedSender<Signal>,
exit: watch::Receiver<Option<i32>>,
}
impl ManagedChild {
/// Prend la main sur [child] (ses flux stdout/stderr doivent déjà avoir
/// été extraits par l'appelant s'il les lit).
pub fn spawn(mut child: Child) -> Self {
let (signal_tx, mut signal_rx) = mpsc::unbounded_channel::<Signal>();
let (exit_tx, exit_rx) = watch::channel(None);
#[cfg(unix)]
let pid = child.id();
tokio::spawn(async move {
loop {
tokio::select! {
status = child.wait() => {
let code = match status {
Ok(status) => exit_code(status),
Err(_) => -1,
};
let _ = exit_tx.send(Some(code));
break;
}
Some(signal) = signal_rx.recv() => {
match signal {
Signal::Term => {
#[cfg(unix)]
{
if let Some(pid) = pid {
// Le processus n'est pas encore récolté
// (wait() n'a pas abouti) : le pid est sûr.
unsafe { libc::kill(pid as libc::pid_t, libc::SIGTERM); }
}
}
#[cfg(not(unix))]
{
let _ = child.start_kill();
}
}
Signal::Kill => {
let _ = child.start_kill();
}
}
}
}
}
});
Self {
signals: signal_tx,
exit: exit_rx,
}
}
/// Arrêt propre (SIGTERM). Sans effet si le processus est déjà terminé.
pub fn terminate(&self) {
let _ = self.signals.send(Signal::Term);
}
/// Arrêt forcé (SIGKILL).
pub fn kill(&self) {
let _ = self.signals.send(Signal::Kill);
}
/// Code de sortie, ou `None` si le processus tourne encore.
pub fn exit_code(&self) -> Option<i32> {
*self.exit.borrow()
}
pub fn has_exited(&self) -> bool {
self.exit_code().is_some()
}
/// Attend la fin du processus et renvoie son code de sortie.
pub async fn wait(&self) -> i32 {
let mut rx = self.exit.clone();
loop {
if let Some(code) = *rx.borrow_and_update() {
return code;
}
if rx.changed().await.is_err() {
return rx.borrow().unwrap_or(-1);
}
}
}
}
/// Code de sortie à la manière de Dart : négatif (−signal) pour un
/// processus tué par un signal.
fn exit_code(status: std::process::ExitStatus) -> i32 {
if let Some(code) = status.code() {
return code;
}
#[cfg(unix)]
{
use std::os::unix::process::ExitStatusExt;
if let Some(signal) = status.signal() {
return -signal;
}
}
-1
}
+75
View File
@@ -0,0 +1,75 @@
//! Validation de chemins contre la traversée de répertoires.
//!
//! Travaille sur des chemins POSIX (le serveur tourne dans un conteneur
//! Linux), de façon purement lexicale comme `package:path`.
/// Normalise un chemin POSIX absolu : résout `.` et `..`, retire les `/`
/// redondants.
fn normalize(path: &str) -> String {
let mut parts: Vec<&str> = Vec::new();
for segment in path.split('/') {
match segment {
"" | "." => {}
".." => {
parts.pop();
}
other => parts.push(other),
}
}
format!("/{}", parts.join("/"))
}
fn absolute(path: &str) -> String {
if path.starts_with('/') {
return path.to_string();
}
let cwd = std::env::current_dir()
.map(|p| p.to_string_lossy().replace('\\', "/"))
.unwrap_or_else(|_| "/".to_string());
format!("{}/{}", cwd.trim_end_matches('/'), path)
}
/// Résout [candidate] et ne le renvoie que s'il reste dans [base_dir].
/// `None` quand le chemin s'en échappe (segments `..`, chemin absolu ailleurs).
pub fn resolve_within(base_dir: &str, candidate: &str) -> Option<String> {
let base = normalize(&absolute(base_dir));
let resolved = if candidate.starts_with('/') {
normalize(candidate)
} else {
normalize(&format!("{base}/{candidate}"))
};
let within =
resolved == base || resolved.starts_with(&format!("{}/", base.trim_end_matches('/')));
within.then_some(resolved)
}
#[cfg(test)]
mod tests {
use super::*;
const BASE: &str = "/app/recordings";
#[test]
fn accepte_les_fichiers_du_dossier() {
assert!(resolve_within(BASE, "/app/recordings/show.log").is_some());
assert!(resolve_within(BASE, "show.log").is_some());
assert!(resolve_within(BASE, "/app/recordings/sub/dir/file.log").is_some());
}
#[test]
fn rejette_la_traversee() {
assert!(resolve_within(BASE, "/app/recordings/../data/xtremflow.db").is_none());
assert!(resolve_within(BASE, "../../etc/passwd").is_none());
}
#[test]
fn rejette_les_chemins_absolus_ailleurs() {
assert!(resolve_within(BASE, "/etc/passwd").is_none());
assert!(resolve_within(BASE, "/app/data/xtremflow.db").is_none());
}
#[test]
fn rejette_un_voisin_au_prefixe_commun() {
assert!(resolve_within(BASE, "/app/recordings-evil/file.log").is_none());
}
}