Skip to main content

snapcast_server/
state.rs

1//! Server state model — clients, groups, streams with JSON persistence.
2
3use std::collections::HashMap;
4use std::path::Path;
5
6use anyhow::Result;
7use serde::{Deserialize, Serialize};
8
9/// Volume settings.
10#[derive(Debug, Clone, Serialize, Deserialize)]
11pub struct Volume {
12    /// Volume percentage (0–100).
13    pub percent: u16,
14    /// Mute state.
15    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/// Client configuration (persisted).
28#[derive(Debug, Clone, Default, Serialize, Deserialize)]
29pub struct ClientConfig {
30    /// Display name (user-assigned).
31    #[serde(default)]
32    pub name: String,
33    /// Volume.
34    #[serde(default)]
35    pub volume: Volume,
36    /// Additional latency in milliseconds.
37    #[serde(default)]
38    pub latency: i32,
39}
40
41/// A connected or previously-seen client.
42#[derive(Debug, Clone, Serialize, Deserialize)]
43pub struct Client {
44    /// Unique client ID.
45    pub id: String,
46    /// Hostname.
47    pub host_name: String,
48    /// MAC address.
49    pub mac: String,
50    /// Whether currently connected.
51    pub connected: bool,
52    /// Client configuration.
53    pub config: ClientConfig,
54}
55
56/// A group of clients sharing the same stream.
57#[derive(Debug, Clone, Serialize, Deserialize)]
58pub struct Group {
59    /// Unique group ID.
60    pub id: String,
61    /// Display name.
62    pub name: String,
63    /// Stream ID this group is playing.
64    pub stream_id: String,
65    /// Group mute state.
66    pub muted: bool,
67    /// Client IDs in this group.
68    pub clients: Vec<String>,
69}
70
71/// A stream source.
72#[derive(Debug, Clone, Serialize, Deserialize)]
73pub struct StreamInfo {
74    /// Stream ID (= name from URI).
75    pub id: String,
76    /// Status: "playing", "idle", "unknown".
77    pub status: String,
78    /// Source URI.
79    pub uri: String,
80    /// Stream properties (metadata: artist, title, etc.).
81    #[serde(default)]
82    pub properties: std::collections::HashMap<String, serde_json::Value>,
83}
84
85/// Complete server state.
86#[derive(Debug, Clone, Serialize, Deserialize, Default)]
87pub struct ServerState {
88    /// All known clients (by ID).
89    pub clients: HashMap<String, Client>,
90    /// All groups.
91    pub groups: Vec<Group>,
92    /// All streams.
93    pub streams: Vec<StreamInfo>,
94}
95
96impl ServerState {
97    /// Load state from a JSON file, or return default if not found.
98    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    /// Save state to a JSON file.
108    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    /// Get or create a client entry. Returns mutable reference.
119    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            // Update host info on reconnect (may change after OS reinstall etc.)
134            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    /// Find which group a client belongs to, or create a new group.
142    pub fn group_for_client(&mut self, client_id: &str, default_stream: &str) -> &mut Group {
143        // Check if client is already in a group
144        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        // Create new group with this client
154        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    /// Remove a client from all groups.
166    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        // Remove empty groups
171        self.groups.retain(|g| !g.clients.is_empty());
172    }
173
174    /// Set the clients of a group (C++ Group.SetClients semantics).
175    ///
176    /// - Clients removed from the target group get their own new group (inheriting the stream).
177    /// - Clients added are moved from their old groups (empty old groups are removed).
178    /// - If the target group ends up empty, it is removed.
179    pub fn set_group_clients(&mut self, group_id: &str, client_ids: &[String]) {
180        // Find the target group's stream for inheritance
181        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        // 1. Evict clients NOT in the new list → create new group for each
189        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        // 2. Add clients to the target group (move from old groups)
210        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            // Remove from old group
219            for group in &mut self.groups {
220                group.clients.retain(|c| c != cid);
221            }
222            // Add to target
223            if let Some(group) = self.groups.iter_mut().find(|g| g.id == group_id) {
224                group.clients.push(cid.clone());
225            }
226        }
227
228        // 3. Remove empty groups
229        self.groups.retain(|g| !g.clients.is_empty());
230    }
231
232    /// Set a group's stream.
233    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    /// Build typed status snapshot.
240    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        // Same client → same group
322        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        // Move c2 and c3 into g1 (c1's group)
338        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        // Evict c2 — should get its own group inheriting the stream
343        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        // xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx
383        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        // All hex
391        assert!(id.replace('-', "").chars().all(|c| c.is_ascii_hexdigit()));
392    }
393}