Skip to main content

oxidio_web/
server.rs

1//! Axum HTTP server with embedded static assets and WebSocket upgrade.
2
3use std::net::SocketAddr;
4use std::path::PathBuf;
5use std::sync::{ Arc, Mutex };
6
7use axum::extract::{ State, WebSocketUpgrade };
8use axum::http::{ header, StatusCode, Uri };
9use axum::response::{ Html, IntoResponse };
10use axum::routing::get;
11use axum::Router;
12use rust_embed::Embed;
13use tokio::sync::broadcast;
14use tower_http::cors::CorsLayer;
15
16use oxidio_ctl::CommandSender;
17use oxidio_protocol::StateUpdate;
18
19use crate::websocket;
20
21
22/// Embedded static assets (HTML, JS, CSS, WASM).
23///
24/// These are compiled into the binary from the `static/` directory.
25#[derive( Embed )]
26#[folder = "static/"]
27struct StaticAssets;
28
29
30/// Shared state for the axum handlers.
31#[derive( Clone )]
32struct AppState {
33    sender: CommandSender,
34    broadcast_tx: Arc<broadcast::Sender<StateUpdate>>,
35    cover_art_path: Arc<Mutex<Option<PathBuf>>>,
36}
37
38
39/// Handle returned from `start_web_server` to manage the server lifecycle.
40pub struct WebServerHandle {
41    shutdown_tx: tokio::sync::oneshot::Sender<()>,
42}
43
44
45impl WebServerHandle {
46    /// Signals the web server to shut down gracefully.
47    pub fn shutdown( self ) {
48        let _ = self.shutdown_tx.send(());
49    }
50}
51
52
53/// Starts the web server in the background.
54///
55/// @param bind - Address to bind to (e.g. "127.0.0.1")
56/// @param port - Port number
57/// @param sender - Command sender for WebSocket clients
58/// @param broadcast_tx - Broadcast sender for subscribing new clients
59/// @param cover_art_path - Shared cover art path from the processor
60///
61/// @returns A handle for shutting down the server
62pub async fn start_web_server(
63    bind: &str,
64    port: u16,
65    sender: CommandSender,
66    broadcast_tx: broadcast::Sender<StateUpdate>,
67    cover_art_path: Arc<Mutex<Option<PathBuf>>>,
68) -> Result<WebServerHandle, std::io::Error> {
69    let state = AppState {
70        sender,
71        broadcast_tx: Arc::new( broadcast_tx ),
72        cover_art_path,
73    };
74
75    let app = Router::new()
76        .route( "/ws", get( ws_handler ) )
77        .route( "/api/cover", get( cover_art_handler ) )
78        .route( "/", get( index_handler ) )
79        .fallback( static_handler )
80        .layer( CorsLayer::permissive() )
81        .with_state( state );
82
83    let addr: SocketAddr = format!( "{}:{}", bind, port )
84        .parse()
85        .expect( "Invalid bind address" );
86
87    let listener = tokio::net::TcpListener::bind( addr ).await?;
88    let ( shutdown_tx, shutdown_rx ) = tokio::sync::oneshot::channel::<()>();
89
90    tracing::info!( "Web server listening on http://{}", addr );
91
92    tokio::spawn( async move {
93        axum::serve( listener, app )
94            .with_graceful_shutdown( async {
95                let _ = shutdown_rx.await;
96            })
97            .await
98            .unwrap_or_else( |e| {
99                tracing::error!( "Web server error: {}", e );
100            });
101    });
102
103    Ok( WebServerHandle { shutdown_tx } )
104}
105
106
107/// WebSocket upgrade handler.
108async fn ws_handler(
109    ws: WebSocketUpgrade,
110    State( state ): State<AppState>,
111) -> impl IntoResponse {
112    let sender = state.sender.clone();
113    let state_rx = state.broadcast_tx.subscribe();
114
115    ws.on_upgrade( move |socket| {
116        websocket::handle_ws( socket, sender, state_rx )
117    })
118}
119
120
121/// Serves cover art for the currently playing track.
122///
123/// Returns the image file with appropriate content type, or 404 if no
124/// cover art is available.
125async fn cover_art_handler(
126    State( state ): State<AppState>,
127) -> impl IntoResponse {
128    let art_path = state.cover_art_path.lock()
129        .ok()
130        .and_then( |guard| guard.clone() );
131
132    match art_path {
133        Some( path ) => {
134            match tokio::fs::read( &path ).await {
135                Ok( data ) => {
136                    let mime = mime_guess::from_path( &path ).first_or_octet_stream();
137                    (
138                        StatusCode::OK,
139                        [( header::CONTENT_TYPE, mime.as_ref().to_string() ),
140                         ( header::CACHE_CONTROL, "no-cache".to_string() )],
141                        data,
142                    ).into_response()
143                }
144                Err( _ ) => {
145                    ( StatusCode::NOT_FOUND, "Cover art file not readable" ).into_response()
146                }
147            }
148        }
149        None => {
150            ( StatusCode::NOT_FOUND, "No cover art available" ).into_response()
151        }
152    }
153}
154
155
156/// Serves the index.html page.
157async fn index_handler() -> impl IntoResponse {
158    match StaticAssets::get( "index.html" ) {
159        Some( content ) => Html( content.data.to_vec() ).into_response(),
160        None => ( StatusCode::NOT_FOUND, "index.html not found" ).into_response(),
161    }
162}
163
164
165/// Serves embedded static assets by path.
166async fn static_handler( uri: Uri ) -> impl IntoResponse {
167    let path = uri.path().trim_start_matches( '/' );
168
169    match StaticAssets::get( path ) {
170        Some( content ) => {
171            let mime = mime_guess::from_path( path ).first_or_octet_stream();
172            ( [( header::CONTENT_TYPE, mime.as_ref() )], content.data ).into_response()
173        }
174        None => ( StatusCode::NOT_FOUND, "Not found" ).into_response(),
175    }
176}