mirror of
https://github.com/R0m1k3/xtremflow.git
synced 2026-10-15 03:07:53 +02:00
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:
1 parent
f82c70ae05
commit
3ea6fa0ae3
109 files changed
+14008
-11047
No files matched your search
Generated
+2063
File diff suppressed because it is too large.
Load diff
@@ -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
|
||||
@@ -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(),
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
@@ -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(¤t);
|
||||
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));
|
||||
}
|
||||
}
|
||||
@@ -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: ®ex::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);
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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"}))
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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']));
|
||||
}
|
||||
}
|
||||
@@ -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"], "é");
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)));
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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
@@ -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
@@ -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>;
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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("", ""));
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user