Backend (Go): - Config: CORS, RabbitMQ, rate limit, main config updates - Routes: core, distribution, tracks routing changes - Middleware: rate limiter, endpoint limiter, response cache hardening - Handlers: distribution, search handler fixes - Workers: job worker improvements - Upload validator and logging config additions - New migrations: products, orders, performance indexes - Seed tooling and data Stream Server (Rust): - Audio processing, config, routes, simple stream server updates - Dockerfile improvements Infrastructure: - docker-compose.yml updates - nginx-rtmp config changes - Makefile improvements (config, dev, high, infra) - Root package.json and lock file updates - .env.example updates Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
330 lines
9.4 KiB
Rust
330 lines
9.4 KiB
Rust
use axum::{
|
|
extract::{Path, Query, State},
|
|
http::{header, HeaderMap, StatusCode},
|
|
response::Json,
|
|
routing::get,
|
|
Router,
|
|
};
|
|
use serde::{Deserialize, Serialize};
|
|
use std::{net::SocketAddr, path::PathBuf};
|
|
|
|
fn unix_timestamp_secs() -> u64 {
|
|
std::time::SystemTime::now()
|
|
.duration_since(std::time::UNIX_EPOCH)
|
|
.map(|d| d.as_secs())
|
|
.unwrap_or(0)
|
|
}
|
|
use tokio::{fs, io::AsyncReadExt, signal};
|
|
use tower_http::{
|
|
compression::CompressionLayer,
|
|
cors::{Any, CorsLayer},
|
|
};
|
|
// Note: Use tracing::info! macro directly instead of importing
|
|
|
|
#[derive(Clone)]
|
|
#[allow(dead_code)]
|
|
struct AppState {
|
|
audio_dir: PathBuf,
|
|
port: u16,
|
|
}
|
|
|
|
#[derive(Serialize)]
|
|
struct HealthResponse {
|
|
status: String,
|
|
timestamp: u64,
|
|
service: String,
|
|
version: String,
|
|
}
|
|
|
|
#[derive(Serialize)]
|
|
struct StreamInfo {
|
|
filename: String,
|
|
size: Option<u64>,
|
|
content_type: String,
|
|
}
|
|
|
|
#[derive(Deserialize)]
|
|
#[allow(dead_code)]
|
|
struct StreamParams {
|
|
quality: Option<String>,
|
|
start: Option<u64>,
|
|
duration: Option<u64>,
|
|
}
|
|
|
|
#[tokio::main]
|
|
async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|
// Configuration du logging
|
|
tracing_subscriber::fmt::init();
|
|
tracing::info!("🎵 Démarrage du Stream Server Simplifié");
|
|
|
|
// Configuration
|
|
let audio_dir =
|
|
std::env::var("AUDIO_DIR").unwrap_or_else(|_| "/opt/veza-stream/audio".to_string());
|
|
let port = std::env::var("PORT")
|
|
.unwrap_or_else(|_| "8000".to_string())
|
|
.parse::<u16>()
|
|
.unwrap_or(8000);
|
|
|
|
// Créer le répertoire audio s'il n'existe pas
|
|
tokio::fs::create_dir_all(&audio_dir).await?;
|
|
|
|
let state = AppState {
|
|
audio_dir: PathBuf::from(audio_dir.clone()),
|
|
port,
|
|
};
|
|
|
|
tracing::info!("📁 Répertoire audio: {}", audio_dir);
|
|
tracing::info!("🔌 Port: {}", port);
|
|
|
|
// Configuration CORS permissive
|
|
let cors = CorsLayer::new()
|
|
.allow_origin(Any)
|
|
.allow_methods([axum::http::Method::GET, axum::http::Method::OPTIONS])
|
|
.allow_headers(Any);
|
|
|
|
// Routes
|
|
let app = Router::new()
|
|
.route("/", get(root))
|
|
.route("/health", get(health_check))
|
|
.route("/stream/{filename}", get(stream_audio))
|
|
.route("/info/{filename}", get(audio_info))
|
|
.route("/list", get(list_audio_files))
|
|
.layer(CompressionLayer::new())
|
|
.layer(cors)
|
|
.with_state(state);
|
|
|
|
// Démarrage du serveur
|
|
let addr = SocketAddr::from(([0, 0, 0, 0], port));
|
|
tracing::info!("🌐 Serveur démarré sur http://{}", addr);
|
|
|
|
let listener = tokio::net::TcpListener::bind(&addr).await?;
|
|
axum::serve(listener, app)
|
|
.with_graceful_shutdown(shutdown_signal())
|
|
.await?;
|
|
|
|
tracing::info!("👋 Serveur arrêté");
|
|
Ok(())
|
|
}
|
|
|
|
async fn root() -> &'static str {
|
|
"🎵 Veza Stream Server - Serveur de streaming audio simplifié"
|
|
}
|
|
|
|
async fn health_check() -> Json<HealthResponse> {
|
|
Json(HealthResponse {
|
|
status: "healthy".to_string(),
|
|
timestamp: unix_timestamp_secs(),
|
|
service: "veza-stream-server".to_string(),
|
|
version: "0.2.0".to_string(),
|
|
})
|
|
}
|
|
|
|
async fn list_audio_files(State(state): State<AppState>) -> Result<Json<Vec<String>>, StatusCode> {
|
|
let mut files = Vec::new();
|
|
|
|
let mut dir = match fs::read_dir(&state.audio_dir).await {
|
|
Ok(dir) => dir,
|
|
Err(_) => return Ok(Json(files)),
|
|
};
|
|
|
|
while let Some(entry) = dir.next_entry().await.unwrap_or(None) {
|
|
if let Some(name) = entry.file_name().to_str() {
|
|
if name.ends_with(".mp3") || name.ends_with(".wav") || name.ends_with(".flac") {
|
|
files.push(name.to_string());
|
|
}
|
|
}
|
|
}
|
|
|
|
files.sort();
|
|
Ok(Json(files))
|
|
}
|
|
|
|
async fn audio_info(
|
|
Path(filename): Path<String>,
|
|
State(state): State<AppState>,
|
|
) -> Result<Json<StreamInfo>, StatusCode> {
|
|
let file_path = state.audio_dir.join(&filename);
|
|
|
|
if !file_path.exists() {
|
|
return Err(StatusCode::NOT_FOUND);
|
|
}
|
|
|
|
let metadata = match fs::metadata(&file_path).await {
|
|
Ok(meta) => meta,
|
|
Err(_) => return Err(StatusCode::INTERNAL_SERVER_ERROR),
|
|
};
|
|
|
|
let content_type = match file_path.extension().and_then(|s| s.to_str()) {
|
|
Some("mp3") => "audio/mpeg",
|
|
Some("wav") => "audio/wav",
|
|
Some("flac") => "audio/flac",
|
|
_ => "application/octet-stream",
|
|
};
|
|
|
|
Ok(Json(StreamInfo {
|
|
filename,
|
|
size: Some(metadata.len()),
|
|
content_type: content_type.to_string(),
|
|
}))
|
|
}
|
|
|
|
async fn stream_audio(
|
|
Path(filename): Path<String>,
|
|
Query(_params): Query<StreamParams>,
|
|
State(state): State<AppState>,
|
|
headers: HeaderMap,
|
|
) -> Result<axum::response::Response, StatusCode> {
|
|
let file_path = state.audio_dir.join(&filename);
|
|
|
|
if !file_path.exists() {
|
|
tracing::warn!("Fichier non trouvé: {:?}", file_path);
|
|
return Err(StatusCode::NOT_FOUND);
|
|
}
|
|
|
|
let file = match fs::File::open(&file_path).await {
|
|
Ok(file) => file,
|
|
Err(e) => {
|
|
tracing::error!("Erreur ouverture fichier: {}", e);
|
|
return Err(StatusCode::INTERNAL_SERVER_ERROR);
|
|
}
|
|
};
|
|
|
|
let metadata = match file.metadata().await {
|
|
Ok(meta) => meta,
|
|
Err(_) => return Err(StatusCode::INTERNAL_SERVER_ERROR),
|
|
};
|
|
|
|
let file_size = metadata.len();
|
|
let content_type = match file_path.extension().and_then(|s| s.to_str()) {
|
|
Some("mp3") => "audio/mpeg",
|
|
Some("wav") => "audio/wav",
|
|
Some("flac") => "audio/flac",
|
|
_ => "application/octet-stream",
|
|
};
|
|
|
|
// Support pour les requêtes Range (streaming partiel)
|
|
if let Some(range) = headers.get(header::RANGE) {
|
|
if let Ok(range_str) = range.to_str() {
|
|
if let Some(range_value) = parse_range_header(range_str, file_size) {
|
|
let (start, end) = range_value;
|
|
let content_length = end - start + 1;
|
|
|
|
tracing::info!(
|
|
"Streaming partiel: {}-{}/{} pour {}",
|
|
start,
|
|
end,
|
|
file_size,
|
|
filename
|
|
);
|
|
|
|
let stream = create_partial_stream(file, start, content_length).await?;
|
|
|
|
return Ok(axum::response::Response::builder()
|
|
.status(StatusCode::PARTIAL_CONTENT)
|
|
.header(header::CONTENT_TYPE, content_type)
|
|
.header(header::CONTENT_LENGTH, content_length.to_string())
|
|
.header(
|
|
header::CONTENT_RANGE,
|
|
format!("bytes {}-{}/{}", start, end, file_size),
|
|
)
|
|
.header(header::ACCEPT_RANGES, "bytes")
|
|
.body(axum::body::Body::from_stream(stream))
|
|
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Streaming complet
|
|
tracing::info!("Streaming complet: {} ({} bytes)", filename, file_size);
|
|
let stream = create_full_stream(file).await?;
|
|
|
|
axum::response::Response::builder()
|
|
.status(StatusCode::OK)
|
|
.header(header::CONTENT_TYPE, content_type)
|
|
.header(header::CONTENT_LENGTH, file_size.to_string())
|
|
.header(header::ACCEPT_RANGES, "bytes")
|
|
.body(axum::body::Body::from_stream(stream))
|
|
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)
|
|
}
|
|
|
|
fn parse_range_header(range: &str, file_size: u64) -> Option<(u64, u64)> {
|
|
if !range.starts_with("bytes=") {
|
|
return None;
|
|
}
|
|
|
|
let range = &range[6..];
|
|
let parts: Vec<&str> = range.split('-').collect();
|
|
|
|
if parts.len() != 2 {
|
|
return None;
|
|
}
|
|
|
|
let start = if parts[0].is_empty() {
|
|
file_size.saturating_sub(parts[1].parse::<u64>().ok()?)
|
|
} else {
|
|
parts[0].parse::<u64>().ok()?
|
|
};
|
|
|
|
let end = if parts[1].is_empty() {
|
|
file_size - 1
|
|
} else {
|
|
parts[1].parse::<u64>().ok()?.min(file_size - 1)
|
|
};
|
|
|
|
if start <= end && end < file_size {
|
|
Some((start, end))
|
|
} else {
|
|
None
|
|
}
|
|
}
|
|
|
|
async fn create_full_stream(
|
|
file: tokio::fs::File,
|
|
) -> Result<impl futures_util::Stream<Item = Result<bytes::Bytes, std::io::Error>>, StatusCode> {
|
|
use tokio_util::io::ReaderStream;
|
|
Ok(ReaderStream::new(file))
|
|
}
|
|
|
|
async fn create_partial_stream(
|
|
mut file: tokio::fs::File,
|
|
start: u64,
|
|
length: u64,
|
|
) -> Result<impl futures_util::Stream<Item = Result<bytes::Bytes, std::io::Error>>, StatusCode> {
|
|
use tokio::io::{AsyncSeekExt, SeekFrom};
|
|
use tokio_util::io::ReaderStream;
|
|
|
|
if let Err(_) = file.seek(SeekFrom::Start(start)).await {
|
|
return Err(StatusCode::INTERNAL_SERVER_ERROR);
|
|
}
|
|
|
|
let limited_reader = file.take(length);
|
|
Ok(ReaderStream::new(limited_reader))
|
|
}
|
|
|
|
async fn shutdown_signal() {
|
|
let ctrl_c = async {
|
|
signal::ctrl_c()
|
|
.await
|
|
.expect("Impossible d'installer le handler Ctrl+C");
|
|
};
|
|
|
|
#[cfg(unix)]
|
|
let terminate = async {
|
|
signal::unix::signal(signal::unix::SignalKind::terminate())
|
|
.expect("Impossible d'installer le handler SIGTERM")
|
|
.recv()
|
|
.await;
|
|
};
|
|
|
|
#[cfg(not(unix))]
|
|
let terminate = std::future::pending::<()>();
|
|
|
|
tokio::select! {
|
|
_ = ctrl_c => {
|
|
tracing::info!("📱 Signal Ctrl+C reçu, arrêt du serveur...");
|
|
},
|
|
_ = terminate => {
|
|
tracing::info!("📱 Signal SIGTERM reçu, arrêt du serveur...");
|
|
}
|
|
}
|
|
}
|