1use 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#[derive( Embed )]
26#[folder = "static/"]
27struct StaticAssets;
28
29
30#[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
39pub struct WebServerHandle {
41 shutdown_tx: tokio::sync::oneshot::Sender<()>,
42}
43
44
45impl WebServerHandle {
46 pub fn shutdown( self ) {
48 let _ = self.shutdown_tx.send(());
49 }
50}
51
52
53pub 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
107async 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
121async 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
156async 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
165async 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}