use std::collections::HashSet;
use std::sync::Arc;
use axum::extract::ws::{Message, WebSocket, WebSocketUpgrade};
use axum::extract::State;
use axum::response::IntoResponse;
use serde::Deserialize;
use uuid::Uuid;
use super::state::AppState;
#[derive(Debug, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
enum ClientMessage {
Subscribe { session_id: Uuid },
Unsubscribe { session_id: Uuid },
Ping,
}
pub(crate) async fn ws_handler(
ws: WebSocketUpgrade,
State(state): State<Arc<AppState>>,
) -> impl IntoResponse {
ws.on_upgrade(move |socket| handle_socket(socket, state))
}
async fn handle_socket(mut socket: WebSocket, state: Arc<AppState>) {
let mut events = state.event_tx.subscribe();
let mut subscribed: HashSet<Uuid> = HashSet::new();
loop {
tokio::select! {
incoming = socket.recv() => {
match incoming {
Some(Ok(Message::Text(text))) => {
match serde_json::from_str::<ClientMessage>(&text) {
Ok(ClientMessage::Subscribe { session_id }) => {
subscribed.insert(session_id);
}
Ok(ClientMessage::Unsubscribe { session_id }) => {
subscribed.remove(&session_id);
}
Ok(ClientMessage::Ping) => {
let pong = Message::Text(r#"{"type":"pong"}"#.into());
if socket.send(pong).await.is_err() {
break;
}
}
Err(_) => continue,
}
}
Some(Ok(Message::Close(_))) | None => break,
Some(Ok(_)) => continue,
Some(Err(_)) => break,
}
}
event = events.recv() => {
match event {
Ok(event) if subscribed.contains(&event.session_id()) => {
let Ok(payload) = serde_json::to_string(&event) else { continue };
if socket.send(Message::Text(payload.into())).await.is_err() {
break;
}
}
Ok(_) => continue,
Err(tokio::sync::broadcast::error::RecvError::Lagged(_)) => continue,
Err(tokio::sync::broadcast::error::RecvError::Closed) => break,
}
}
}
}
}
#[cfg(test)]
mod tests {
use std::time::Duration;
use futures_util::{SinkExt, StreamExt};
use serde_json::{json, Value};
use tokio::net::TcpListener;
use tokio_tungstenite::tungstenite::Message as WsMessage;
use tokio_tungstenite::{MaybeTlsStream, WebSocketStream};
use super::*;
use crate::config::{Config, PlatformQuality};
use crate::server::router;
use crate::server::state::ServerEvent;
type WsStream = WebSocketStream<MaybeTlsStream<tokio::net::TcpStream>>;
fn sample_config() -> Config {
Config {
download_path: "~/Downloads/vdl".to_string(),
default_format: "mp4".to_string(),
default_video_quality: "1080".to_string(),
platform_quality: PlatformQuality {
youtube: "1080".to_string(),
tiktok: "best".to_string(),
instagram: "best".to_string(),
twitter: "best".to_string(),
spotify: "best".to_string(),
},
bins_dir: "~/.local/share/vdl/bins".to_string(),
cookies_file: None,
cookies_from_browser: None,
confirm_before_download: true,
search_results_count: 8,
termux_mode: false,
no_progress: true,
}
}
async fn spawn_test_server() -> (Arc<AppState>, String) {
let state = Arc::new(AppState::new(sample_config()));
let app = router(Arc::clone(&state));
let listener = TcpListener::bind("127.0.0.1:0")
.await
.expect("listener should bind to an ephemeral port");
let addr = listener
.local_addr()
.expect("listener should expose its address");
tokio::spawn(async move {
axum::serve(listener, app)
.await
.expect("test server should run without error");
});
(state, format!("ws://{addr}/ws"))
}
async fn next_json(socket: &mut WsStream) -> Value {
loop {
let message = socket
.next()
.await
.expect("socket should yield a message before closing")
.expect("message should not be a protocol error");
if let WsMessage::Text(text) = message {
return serde_json::from_str(&text).expect("message should be valid JSON");
}
}
}
#[tokio::test]
async fn ping_receives_pong() {
let (_state, url) = spawn_test_server().await;
let (mut socket, _response) = tokio_tungstenite::connect_async(&url)
.await
.expect("websocket handshake should succeed");
socket
.send(WsMessage::Text(
json!({ "type": "ping" }).to_string().into(),
))
.await
.expect("ping should send");
let response = next_json(&mut socket).await;
assert_eq!(response["type"], "pong");
}
#[tokio::test]
async fn subscribed_clients_receive_only_their_sessions_events() {
let (state, url) = spawn_test_server().await;
let (mut socket, _response) = tokio_tungstenite::connect_async(&url)
.await
.expect("websocket handshake should succeed");
let watched = Uuid::new_v4();
let ignored = Uuid::new_v4();
socket
.send(WsMessage::Text(
json!({ "type": "subscribe", "session_id": watched })
.to_string()
.into(),
))
.await
.expect("subscribe should send");
tokio::time::sleep(Duration::from_millis(50)).await;
let _ = state.event_tx.send(ServerEvent::Status {
session_id: ignored,
status: "downloading".to_string(),
message: None,
});
let _ = state.event_tx.send(ServerEvent::Status {
session_id: watched,
status: "downloading".to_string(),
message: None,
});
let response = next_json(&mut socket).await;
assert_eq!(response["type"], "status");
assert_eq!(response["session_id"], watched.to_string());
}
}