1use std::collections::HashMap;
4use std::path::Path;
5
6use anyhow::Result;
7use serde::{Deserialize, Serialize};
8
9#[derive(Debug, Clone, Serialize, Deserialize)]
11pub struct Volume {
12 pub percent: u16,
14 pub muted: bool,
16}
17
18impl Default for Volume {
19 fn default() -> Self {
20 Self {
21 percent: 100,
22 muted: false,
23 }
24 }
25}
26
27#[derive(Debug, Clone, Default, Serialize, Deserialize)]
29pub struct ClientConfig {
30 #[serde(default)]
32 pub name: String,
33 #[serde(default)]
35 pub volume: Volume,
36 #[serde(default)]
38 pub latency: i32,
39}
40
41#[derive(Debug, Clone, Serialize, Deserialize)]
43pub struct Client {
44 pub id: String,
46 pub host_name: String,
48 pub mac: String,
50 pub connected: bool,
52 pub config: ClientConfig,
54}
55
56#[derive(Debug, Clone, Serialize, Deserialize)]
58pub struct Group {
59 pub id: String,
61 pub name: String,
63 pub stream_id: String,
65 pub muted: bool,
67 pub clients: Vec<String>,
69}
70
71#[derive(Debug, Clone, Serialize, Deserialize)]
73pub struct StreamInfo {
74 pub id: String,
76 pub status: String,
78 pub uri: String,
80 #[serde(default)]
82 pub properties: std::collections::HashMap<String, serde_json::Value>,
83}
84
85#[derive(Debug, Clone, Serialize, Deserialize, Default)]
87pub struct ServerState {
88 pub clients: HashMap<String, Client>,
90 pub groups: Vec<Group>,
92 pub streams: Vec<StreamInfo>,
94}
95
96impl ServerState {
97 pub fn load(path: &Path) -> Self {
99 let state: Self = std::fs::read_to_string(path)
100 .ok()
101 .and_then(|s| serde_json::from_str(&s).ok())
102 .unwrap_or_default();
103 tracing::debug!(path = %path.display(), clients = state.clients.len(), groups = state.groups.len(), "state loaded");
104 state
105 }
106
107 pub fn save(&self, path: &Path) -> Result<()> {
109 if let Some(parent) = path.parent() {
110 std::fs::create_dir_all(parent)?;
111 }
112 let json = serde_json::to_string_pretty(self)?;
113 std::fs::write(path, json)?;
114 tracing::debug!(path = %path.display(), "state saved");
115 Ok(())
116 }
117
118 pub fn get_or_create_client(&mut self, id: &str, host_name: &str, mac: &str) -> &mut Client {
120 if !self.clients.contains_key(id) {
121 tracing::debug!(client_id = id, host_name, mac, "client created");
122 self.clients.insert(
123 id.to_string(),
124 Client {
125 id: id.to_string(),
126 host_name: host_name.to_string(),
127 mac: mac.to_string(),
128 connected: false,
129 config: ClientConfig::default(),
130 },
131 );
132 } else {
133 let c = self.clients.get_mut(id).unwrap();
135 c.host_name = host_name.to_string();
136 c.mac = mac.to_string();
137 }
138 self.clients.get_mut(id).expect("just inserted")
139 }
140
141 pub fn group_for_client(&mut self, client_id: &str, default_stream: &str) -> &mut Group {
143 let idx = self
145 .groups
146 .iter()
147 .position(|g| g.clients.contains(&client_id.to_string()));
148
149 if let Some(idx) = idx {
150 return &mut self.groups[idx];
151 }
152
153 let group = Group {
155 id: generate_id(),
156 name: String::new(),
157 stream_id: default_stream.to_string(),
158 muted: false,
159 clients: vec![client_id.to_string()],
160 };
161 self.groups.push(group);
162 self.groups.last_mut().expect("just pushed")
163 }
164
165 pub fn remove_client_from_groups(&mut self, client_id: &str) {
167 for group in &mut self.groups {
168 group.clients.retain(|c| c != client_id);
169 }
170 self.groups.retain(|g| !g.clients.is_empty());
172 }
173
174 pub fn set_group_clients(&mut self, group_id: &str, client_ids: &[String]) {
180 let stream_id = self
182 .groups
183 .iter()
184 .find(|g| g.id == group_id)
185 .map(|g| g.stream_id.clone())
186 .unwrap_or_default();
187
188 if let Some(group) = self.groups.iter_mut().find(|g| g.id == group_id) {
190 let evicted: Vec<String> = group
191 .clients
192 .iter()
193 .filter(|c| !client_ids.contains(c))
194 .cloned()
195 .collect();
196 group.clients.retain(|c| client_ids.contains(c));
197 for cid in evicted {
198 let new_group = Group {
199 id: generate_id(),
200 name: String::new(),
201 stream_id: stream_id.clone(),
202 muted: false,
203 clients: vec![cid],
204 };
205 self.groups.push(new_group);
206 }
207 }
208
209 for cid in client_ids {
211 let already_in_target = self
212 .groups
213 .iter()
214 .any(|g| g.id == group_id && g.clients.contains(cid));
215 if already_in_target {
216 continue;
217 }
218 for group in &mut self.groups {
220 group.clients.retain(|c| c != cid);
221 }
222 if let Some(group) = self.groups.iter_mut().find(|g| g.id == group_id) {
224 group.clients.push(cid.clone());
225 }
226 }
227
228 self.groups.retain(|g| !g.clients.is_empty());
230 }
231
232 pub fn set_group_stream(&mut self, group_id: &str, stream_id: &str) {
234 if let Some(group) = self.groups.iter_mut().find(|g| g.id == group_id) {
235 group.stream_id = stream_id.to_string();
236 }
237 }
238
239 pub fn to_status(&self) -> crate::status::ServerStatus {
241 use crate::status;
242 let groups = self
243 .groups
244 .iter()
245 .map(|g| {
246 let clients = g
247 .clients
248 .iter()
249 .filter_map(|cid| self.clients.get(cid))
250 .map(|c| status::Client {
251 id: c.id.clone(),
252 connected: c.connected,
253 config: status::ClientConfig {
254 name: c.config.name.clone(),
255 volume: status::Volume {
256 percent: c.config.volume.percent,
257 muted: c.config.volume.muted,
258 },
259 latency: c.config.latency,
260 ..Default::default()
261 },
262 host: status::Host {
263 name: c.host_name.clone(),
264 mac: c.mac.clone(),
265 ..Default::default()
266 },
267 ..Default::default()
268 })
269 .collect();
270 status::Group {
271 id: g.id.clone(),
272 name: g.name.clone(),
273 stream_id: g.stream_id.clone(),
274 muted: g.muted,
275 clients,
276 }
277 })
278 .collect();
279 let streams = self
280 .streams
281 .iter()
282 .map(|s| status::Stream {
283 id: s.id.clone(),
284 status: status::StreamStatus::from(s.status.as_str()),
285 uri: status::StreamUri {
286 raw: s.uri.clone(),
287 ..Default::default()
288 },
289 ..Default::default()
290 })
291 .collect();
292 status::ServerStatus {
293 server: status::Server {
294 groups,
295 streams,
296 ..Default::default()
297 },
298 }
299 }
300}
301
302fn generate_id() -> String {
303 uuid::Uuid::new_v4().to_string()
304}
305
306#[cfg(test)]
307mod tests {
308 use super::*;
309
310 #[test]
311 fn client_lifecycle() {
312 let mut state = ServerState::default();
313 let c = state.get_or_create_client("abc", "myhost", "aa:bb:cc:dd:ee:ff");
314 assert_eq!(c.id, "abc");
315 assert_eq!(c.config.volume.percent, 100);
316
317 let g = state.group_for_client("abc", "default");
318 assert_eq!(g.clients, vec!["abc"]);
319 let gid = g.id.clone();
320
321 let g2 = state.group_for_client("abc", "default");
323 assert_eq!(g2.id, gid);
324 }
325
326 #[test]
327 fn set_group_clients_moves_and_evicts() {
328 let mut state = ServerState::default();
329 state.get_or_create_client("c1", "h1", "m1");
330 state.get_or_create_client("c2", "h2", "m2");
331 state.get_or_create_client("c3", "h3", "m3");
332 let g1 = state.group_for_client("c1", "s1").id.clone();
333 state.group_for_client("c2", "s1");
334 state.group_for_client("c3", "s1");
335 assert_eq!(state.groups.len(), 3);
336
337 state.set_group_clients(&g1, &["c1".into(), "c2".into(), "c3".into()]);
339 assert_eq!(state.groups.len(), 1);
340 assert_eq!(state.groups[0].clients.len(), 3);
341
342 state.set_group_clients(&g1, &["c1".into(), "c3".into()]);
344 assert_eq!(state.groups.len(), 2);
345 let evicted_group = state.groups.iter().find(|g| g.id != g1).unwrap();
346 assert_eq!(evicted_group.clients, vec!["c2"]);
347 assert_eq!(evicted_group.stream_id, "s1");
348 }
349
350 #[test]
351 fn json_roundtrip() {
352 let mut state = ServerState::default();
353 state.get_or_create_client("c1", "host1", "mac1");
354 state.group_for_client("c1", "default");
355 state.streams.push(StreamInfo {
356 id: "default".into(),
357 status: "playing".into(),
358 uri: "pipe:///tmp/snapfifo".into(),
359 properties: Default::default(),
360 });
361
362 let json = serde_json::to_string(&state).unwrap();
363 let restored: ServerState = serde_json::from_str(&json).unwrap();
364 assert_eq!(restored.clients.len(), 1);
365 assert_eq!(restored.groups.len(), 1);
366 assert_eq!(restored.streams.len(), 1);
367 }
368
369 #[test]
370 fn status_json() {
371 let mut state = ServerState::default();
372 state.get_or_create_client("c1", "host1", "mac1");
373 state.group_for_client("c1", "default");
374 let status = state.to_status();
375 assert_eq!(status.server.groups.len(), 1);
376 assert_eq!(status.server.groups[0].clients.len(), 1);
377 }
378
379 #[test]
380 fn generate_id_is_uuid_format() {
381 let id = generate_id();
382 let parts: Vec<&str> = id.split('-').collect();
384 assert_eq!(parts.len(), 5, "expected 5 UUID parts, got: {id}");
385 assert_eq!(parts[0].len(), 8);
386 assert_eq!(parts[1].len(), 4);
387 assert_eq!(parts[2].len(), 4);
388 assert_eq!(parts[3].len(), 4);
389 assert_eq!(parts[4].len(), 12);
390 assert!(id.replace('-', "").chars().all(|c| c.is_ascii_hexdigit()));
392 }
393}