mirror of
https://github.com/R0m1k3/xtremflow.git
synced 2026-10-15 19:28:16 +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
@@ -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,
|
||||
})
|
||||
}
|
||||
Reference in new issue
Block a user