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        }
133        self.clients.get_mut(id).expect("just inserted")
134    }
135
136    /// Find which group a client belongs to, or create a new group.
137    pub fn group_for_client(&mut self, client_id: &str, default_stream: &str) -> &mut Group {
138        // Check if client is already in a group
139        let idx = self
140            .groups
141            .iter()
142            .position(|g| g.clients.contains(&client_id.to_string()));
143
144        if let Some(idx) = idx {
145            return &mut self.groups[idx];
146        }
147
148        // Create new group with this client
149        let group = Group {
150            id: generate_id(),
151            name: String::new(),
152            stream_id: default_stream.to_string(),
153            muted: false,
154            clients: vec![client_id.to_string()],
155        };
156        self.groups.push(group);
157        self.groups.last_mut().expect("just pushed")
158    }
159
160    /// Remove a client from all groups.
161    pub fn remove_client_from_groups(&mut self, client_id: &str) {
162        for group in &mut self.groups {
163            group.clients.retain(|c| c != client_id);
164        }
165        // Remove empty groups
166        self.groups.retain(|g| !g.clients.is_empty());
167    }
168
169    /// Move a client to a different group.
170    pub fn move_client_to_group(&mut self, client_id: &str, group_id: &str) {
171        tracing::debug!(client_id, group_id, "moving client to group");
172        self.remove_client_from_groups(client_id);
173        if let Some(group) = self.groups.iter_mut().find(|g| g.id == group_id) {
174            group.clients.push(client_id.to_string());
175        }
176    }
177
178    /// Set a group's stream.
179    pub fn set_group_stream(&mut self, group_id: &str, stream_id: &str) {
180        if let Some(group) = self.groups.iter_mut().find(|g| g.id == group_id) {
181            group.stream_id = stream_id.to_string();
182        }
183    }
184
185    /// Build JSON status for the full server (matches C++ Server.GetStatus).
186    pub fn to_status_json(&self) -> serde_json::Value {
187        let groups: Vec<serde_json::Value> = self
188            .groups
189            .iter()
190            .map(|g| {
191                let clients: Vec<serde_json::Value> = g
192                    .clients
193                    .iter()
194                    .filter_map(|cid| self.clients.get(cid))
195                    .map(|c| {
196                        serde_json::json!({
197                            "id": c.id,
198                            "host": { "name": c.host_name, "mac": c.mac },
199                            "connected": c.connected,
200                            "config": {
201                                "name": c.config.name,
202                                "volume": { "percent": c.config.volume.percent, "muted": c.config.volume.muted },
203                                "latency": c.config.latency,
204                            }
205                        })
206                    })
207                    .collect();
208                serde_json::json!({
209                    "id": g.id,
210                    "name": g.name,
211                    "stream_id": g.stream_id,
212                    "muted": g.muted,
213                    "clients": clients,
214                })
215            })
216            .collect();
217
218        let streams: Vec<serde_json::Value> = self
219            .streams
220            .iter()
221            .map(|s| {
222                serde_json::json!({
223                    "id": s.id,
224                    "status": s.status,
225                    "uri": { "raw": s.uri },
226                })
227            })
228            .collect();
229
230        serde_json::json!({
231            "server": {
232                "groups": groups,
233                "streams": streams,
234            }
235        })
236    }
237}
238
239fn generate_id() -> String {
240    use std::time::{SystemTime, UNIX_EPOCH};
241    let t = SystemTime::now()
242        .duration_since(UNIX_EPOCH)
243        .unwrap_or_default()
244        .as_nanos();
245    format!("{t:032x}")
246}
247
248#[cfg(test)]
249mod tests {
250    use super::*;
251
252    #[test]
253    fn client_lifecycle() {
254        let mut state = ServerState::default();
255        let c = state.get_or_create_client("abc", "myhost", "aa:bb:cc:dd:ee:ff");
256        assert_eq!(c.id, "abc");
257        assert_eq!(c.config.volume.percent, 100);
258
259        let g = state.group_for_client("abc", "default");
260        assert_eq!(g.clients, vec!["abc"]);
261        let gid = g.id.clone();
262
263        // Same client → same group
264        let g2 = state.group_for_client("abc", "default");
265        assert_eq!(g2.id, gid);
266    }
267
268    #[test]
269    fn move_client() {
270        let mut state = ServerState::default();
271        state.get_or_create_client("c1", "h1", "m1");
272        state.get_or_create_client("c2", "h2", "m2");
273        state.group_for_client("c1", "s1");
274        state.group_for_client("c2", "s1");
275
276        assert_eq!(state.groups.len(), 2);
277
278        let g2_id = state.groups[1].id.clone();
279        state.move_client_to_group("c1", &g2_id);
280
281        assert_eq!(state.groups.len(), 1); // empty group removed
282        assert_eq!(state.groups[0].clients.len(), 2);
283    }
284
285    #[test]
286    fn json_roundtrip() {
287        let mut state = ServerState::default();
288        state.get_or_create_client("c1", "host1", "mac1");
289        state.group_for_client("c1", "default");
290        state.streams.push(StreamInfo {
291            id: "default".into(),
292            status: "playing".into(),
293            uri: "pipe:///tmp/snapfifo".into(),
294            properties: Default::default(),
295        });
296
297        let json = serde_json::to_string(&state).unwrap();
298        let restored: ServerState = serde_json::from_str(&json).unwrap();
299        assert_eq!(restored.clients.len(), 1);
300        assert_eq!(restored.groups.len(), 1);
301        assert_eq!(restored.streams.len(), 1);
302    }
303
304    #[test]
305    fn status_json() {
306        let mut state = ServerState::default();
307        state.get_or_create_client("c1", "host1", "mac1");
308        state.group_for_client("c1", "default");
309        let status = state.to_status_json();
310        assert!(status["server"]["groups"].is_array());
311    }
312}