use std::collections::HashMap;
use std::path::Path;
use anyhow::Result;
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Volume {
pub percent: u16,
pub muted: bool,
}
impl Default for Volume {
fn default() -> Self {
Self {
percent: 100,
muted: false,
}
}
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct ClientConfig {
#[serde(default)]
pub name: String,
#[serde(default)]
pub volume: Volume,
#[serde(default)]
pub latency: i32,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Client {
pub id: String,
pub host_name: String,
pub mac: String,
pub connected: bool,
pub config: ClientConfig,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Group {
pub id: String,
pub name: String,
pub stream_id: String,
pub muted: bool,
pub clients: Vec<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct StreamInfo {
pub id: String,
pub status: String,
pub uri: String,
#[serde(default)]
pub properties: std::collections::HashMap<String, serde_json::Value>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct ServerState {
pub clients: HashMap<String, Client>,
pub groups: Vec<Group>,
pub streams: Vec<StreamInfo>,
}
impl ServerState {
pub fn load(path: &Path) -> Self {
let state: Self = std::fs::read_to_string(path)
.ok()
.and_then(|s| serde_json::from_str(&s).ok())
.unwrap_or_default();
tracing::debug!(path = %path.display(), clients = state.clients.len(), groups = state.groups.len(), "state loaded");
state
}
pub fn save(&self, path: &Path) -> Result<()> {
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)?;
}
let json = serde_json::to_string_pretty(self)?;
std::fs::write(path, json)?;
tracing::debug!(path = %path.display(), "state saved");
Ok(())
}
pub fn get_or_create_client(&mut self, id: &str, host_name: &str, mac: &str) -> &mut Client {
if !self.clients.contains_key(id) {
tracing::debug!(client_id = id, host_name, mac, "client created");
self.clients.insert(
id.to_string(),
Client {
id: id.to_string(),
host_name: host_name.to_string(),
mac: mac.to_string(),
connected: false,
config: ClientConfig::default(),
},
);
}
self.clients.get_mut(id).expect("just inserted")
}
pub fn group_for_client(&mut self, client_id: &str, default_stream: &str) -> &mut Group {
let idx = self
.groups
.iter()
.position(|g| g.clients.contains(&client_id.to_string()));
if let Some(idx) = idx {
return &mut self.groups[idx];
}
let group = Group {
id: generate_id(),
name: String::new(),
stream_id: default_stream.to_string(),
muted: false,
clients: vec![client_id.to_string()],
};
self.groups.push(group);
self.groups.last_mut().expect("just pushed")
}
pub fn remove_client_from_groups(&mut self, client_id: &str) {
for group in &mut self.groups {
group.clients.retain(|c| c != client_id);
}
self.groups.retain(|g| !g.clients.is_empty());
}
pub fn move_client_to_group(&mut self, client_id: &str, group_id: &str) {
tracing::debug!(client_id, group_id, "moving client to group");
self.remove_client_from_groups(client_id);
if let Some(group) = self.groups.iter_mut().find(|g| g.id == group_id) {
group.clients.push(client_id.to_string());
}
}
pub fn set_group_stream(&mut self, group_id: &str, stream_id: &str) {
if let Some(group) = self.groups.iter_mut().find(|g| g.id == group_id) {
group.stream_id = stream_id.to_string();
}
}
pub fn to_status_json(&self) -> serde_json::Value {
let groups: Vec<serde_json::Value> = self
.groups
.iter()
.map(|g| {
let clients: Vec<serde_json::Value> = g
.clients
.iter()
.filter_map(|cid| self.clients.get(cid))
.map(|c| {
serde_json::json!({
"id": c.id,
"host": { "name": c.host_name, "mac": c.mac },
"connected": c.connected,
"config": {
"name": c.config.name,
"volume": { "percent": c.config.volume.percent, "muted": c.config.volume.muted },
"latency": c.config.latency,
}
})
})
.collect();
serde_json::json!({
"id": g.id,
"name": g.name,
"stream_id": g.stream_id,
"muted": g.muted,
"clients": clients,
})
})
.collect();
let streams: Vec<serde_json::Value> = self
.streams
.iter()
.map(|s| {
serde_json::json!({
"id": s.id,
"status": s.status,
"uri": { "raw": s.uri },
})
})
.collect();
serde_json::json!({
"server": {
"groups": groups,
"streams": streams,
}
})
}
}
fn generate_id() -> String {
use std::time::{SystemTime, UNIX_EPOCH};
let t = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_nanos();
format!("{t:032x}")
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn client_lifecycle() {
let mut state = ServerState::default();
let c = state.get_or_create_client("abc", "myhost", "aa:bb:cc:dd:ee:ff");
assert_eq!(c.id, "abc");
assert_eq!(c.config.volume.percent, 100);
let g = state.group_for_client("abc", "default");
assert_eq!(g.clients, vec!["abc"]);
let gid = g.id.clone();
let g2 = state.group_for_client("abc", "default");
assert_eq!(g2.id, gid);
}
#[test]
fn move_client() {
let mut state = ServerState::default();
state.get_or_create_client("c1", "h1", "m1");
state.get_or_create_client("c2", "h2", "m2");
state.group_for_client("c1", "s1");
state.group_for_client("c2", "s1");
assert_eq!(state.groups.len(), 2);
let g2_id = state.groups[1].id.clone();
state.move_client_to_group("c1", &g2_id);
assert_eq!(state.groups.len(), 1); assert_eq!(state.groups[0].clients.len(), 2);
}
#[test]
fn json_roundtrip() {
let mut state = ServerState::default();
state.get_or_create_client("c1", "host1", "mac1");
state.group_for_client("c1", "default");
state.streams.push(StreamInfo {
id: "default".into(),
status: "playing".into(),
uri: "pipe:///tmp/snapfifo".into(),
properties: Default::default(),
});
let json = serde_json::to_string(&state).unwrap();
let restored: ServerState = serde_json::from_str(&json).unwrap();
assert_eq!(restored.clients.len(), 1);
assert_eq!(restored.groups.len(), 1);
assert_eq!(restored.streams.len(), 1);
}
#[test]
fn status_json() {
let mut state = ServerState::default();
state.get_or_create_client("c1", "host1", "mac1");
state.group_for_client("c1", "default");
let status = state.to_status_json();
assert!(status["server"]["groups"].is_array());
}
}