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, time::{SystemTime, UNIX_EPOCH}, }; 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)] 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, content_type: String, } #[derive(Deserialize)] struct StreamParams { quality: Option, start: Option, duration: Option, } #[tokio::main] async fn main() -> Result<(), Box> { // 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::() .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 { Json(HealthResponse { status: "healthy".to_string(), timestamp: SystemTime::now() .duration_since(UNIX_EPOCH) .unwrap() .as_secs(), service: "veza-stream-server".to_string(), version: "0.2.0".to_string(), }) } async fn list_audio_files(State(state): State) -> Result>, 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, State(state): State, ) -> Result, 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, Query(_params): Query, State(state): State, headers: HeaderMap, ) -> Result { 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)) .unwrap()); } } } // Streaming complet tracing::info!("Streaming complet: {} ({} bytes)", filename, file_size); let stream = create_full_stream(file).await?; Ok(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)) .unwrap()) } 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::().ok()?) } else { parts[0].parse::().ok()? }; let end = if parts[1].is_empty() { file_size - 1 } else { parts[1].parse::().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>, 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>, 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..."); } } }