1use serde::{Deserialize, Serialize};
7use std::path::{Path, PathBuf};
8
9#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
12pub struct VlcRuntime {
13 pub port: u16,
14 pub password: String,
15 pub snapshot_dir: PathBuf,
17}
18
19pub fn runtime_dir(mur_home: &Path) -> PathBuf {
24 mur_home.join("runtime")
25}
26
27pub fn runtime_path(mur_home: &Path) -> PathBuf {
29 runtime_dir(mur_home).join("vlc.json")
30}
31
32pub fn load_runtime(mur_home: &Path) -> Option<VlcRuntime> {
36 let raw = std::fs::read_to_string(runtime_path(mur_home)).ok()?;
37 serde_json::from_str(&raw).ok()
38}
39
40#[derive(Debug, Clone, PartialEq, Serialize)]
44pub struct VlcStatus {
45 pub state: String, pub time: i64, pub length: i64, pub volume: i64, }
50
51pub fn parse_status_xml(xml: &str) -> VlcStatus {
54 use quick_xml::Reader;
55 use quick_xml::events::Event;
56
57 let mut reader = Reader::from_str(xml);
58 let mut state = "stopped".to_string();
59 let mut time = 0i64;
60 let mut length = 0i64;
61 let mut volume = 0i64;
62 let mut buf = Vec::new();
63 let mut in_tag = String::new();
64 let mut depth = 0i32;
69
70 loop {
71 match reader.read_event_into(&mut buf) {
72 Ok(Event::Start(ref e)) => {
73 depth += 1;
74 in_tag = String::from_utf8_lossy(e.name().as_ref()).into_owned();
75 }
76 Ok(Event::End(_)) => {
80 depth -= 1;
81 in_tag.clear();
82 }
83 Ok(Event::Empty(_)) => {
86 in_tag.clear();
87 }
88 Ok(Event::Text(ref e)) if depth == 2 => {
89 let text = e.unescape().unwrap_or_default().to_string();
90 match in_tag.as_str() {
91 "state" => state = text,
92 "time" => time = text.trim().parse().unwrap_or(0),
93 "length" => length = text.trim().parse().unwrap_or(0),
94 "volume" => volume = text.trim().parse().unwrap_or(0),
95 _ => {}
96 }
97 }
98 Ok(Event::Eof) => break,
99 _ => {}
100 }
101 buf.clear();
102 }
103 VlcStatus {
104 state,
105 time,
106 length,
107 volume,
108 }
109}
110
111pub fn newest_file(dir: &Path) -> Option<PathBuf> {
115 let mut best: Option<(std::time::SystemTime, PathBuf)> = None;
116 for entry in std::fs::read_dir(dir).ok()? {
117 let entry = entry.ok()?;
118 let path = entry.path();
119 if !path.is_file() {
120 continue;
121 }
122 let mtime = entry.metadata().ok()?.modified().ok()?;
123 if best.as_ref().map(|(t, _)| mtime > *t).unwrap_or(true) {
124 best = Some((mtime, path));
125 }
126 }
127 best.map(|(_, p)| p)
128}
129
130pub fn newest_file_excluding(dir: &Path, exclude: Option<&Path>) -> Option<PathBuf> {
142 let newest = newest_file(dir)?;
143 match exclude {
144 Some(prev) if newest == prev => None,
145 _ => Some(newest),
146 }
147}
148
149#[cfg(test)]
150mod tests {
151 use super::*;
152 use tempfile::TempDir;
153
154 #[test]
155 fn runtime_path_under_runtime_dir() {
156 let p = runtime_path(Path::new("/tmp/h"));
157 assert!(p.ends_with("runtime/vlc.json"));
158 assert_eq!(p.parent().unwrap(), runtime_dir(Path::new("/tmp/h")));
160 assert_eq!(
161 watch_path(Path::new("/tmp/h")).parent().unwrap(),
162 runtime_dir(Path::new("/tmp/h"))
163 );
164 }
165
166 #[test]
167 fn load_runtime_absent_is_none() {
168 let home = TempDir::new().unwrap();
169 assert!(load_runtime(home.path()).is_none());
170 }
171
172 #[test]
173 fn load_runtime_roundtrips_port() {
174 let home = TempDir::new().unwrap();
175 let dir = home.path().join("runtime");
176 std::fs::create_dir_all(&dir).unwrap();
177 std::fs::write(
178 runtime_path(home.path()),
179 r#"{"port":61886,"password":"pw","snapshot_dir":"/tmp/s"}"#,
180 )
181 .unwrap();
182 assert_eq!(load_runtime(home.path()).unwrap().port, 61886);
183 }
184
185 #[test]
186 fn parse_status_extracts_fields() {
187 let xml = "<root><volume>256</volume><state>playing</state><time>42</time><length>3600</length></root>";
188 let s = parse_status_xml(xml);
189 assert_eq!(s.state, "playing");
190 assert_eq!(s.time, 42);
191 assert_eq!(s.length, 3600);
192 assert_eq!(s.volume, 256);
193 }
194
195 #[test]
196 fn parse_status_pretty_printed_does_not_clobber_state() {
197 let xml = "<root>\n <volume>256</volume>\n <state>playing</state>\n <time>42</time>\n <length>3600</length>\n</root>\n";
198 let s = parse_status_xml(xml);
199 assert_eq!(s.state, "playing");
200 assert_eq!(s.time, 42);
201 assert_eq!(s.length, 3600);
202 assert_eq!(s.volume, 256);
203 }
204
205 #[test]
206 fn parse_status_ignores_nested_same_named_tags() {
207 let xml = "<root>\n\
208 \x20 <state>playing</state>\n\
209 \x20 <length>3600</length>\n\
210 \x20 <information>\n\
211 \x20 <category name=\"meta\">\n\
212 \x20 <length>0</length>\n\
213 \x20 <state>stopped</state>\n\
214 \x20 </category>\n\
215 \x20 </information>\n\
216 </root>";
217 let s = parse_status_xml(xml);
218 assert_eq!(s.state, "playing");
219 assert_eq!(s.length, 3600);
220 }
221
222 #[test]
223 fn newest_file_picks_latest() {
224 let dir = TempDir::new().unwrap();
225 std::fs::write(dir.path().join("a.png"), b"a").unwrap();
226 std::thread::sleep(std::time::Duration::from_millis(20));
227 std::fs::write(dir.path().join("b.png"), b"b").unwrap();
228 assert_eq!(
229 newest_file(dir.path()).unwrap().file_name().unwrap(),
230 "b.png"
231 );
232 }
233
234 #[test]
235 fn newest_file_excluding_rejects_stale_and_accepts_fresh() {
236 let dir = TempDir::new().unwrap();
237 std::fs::write(dir.path().join("stale.png"), b"old").unwrap();
238 let baseline = newest_file(dir.path());
239 assert!(newest_file_excluding(dir.path(), baseline.as_deref()).is_none());
240 std::thread::sleep(std::time::Duration::from_millis(20));
241 std::fs::write(dir.path().join("fresh.png"), b"new").unwrap();
242 assert_eq!(
243 newest_file_excluding(dir.path(), baseline.as_deref())
244 .unwrap()
245 .file_name()
246 .unwrap(),
247 "fresh.png"
248 );
249 }
250}
251
252#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
254#[serde(rename_all = "snake_case")]
255pub enum Consent {
256 #[default]
257 Unasked,
258 Granted,
259 Declined,
260}
261
262#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
265pub struct WatchSession {
266 pub active: bool,
267 pub muted: bool,
268 pub last_interjection_ms: i64,
269 pub last_scene_phash: u64,
270 pub consent: Consent,
271}
272
273pub fn watch_path(mur_home: &Path) -> PathBuf {
275 runtime_dir(mur_home).join("watch.json")
276}
277
278pub fn load_watch(mur_home: &Path) -> WatchSession {
280 std::fs::read_to_string(watch_path(mur_home))
281 .ok()
282 .and_then(|b| serde_json::from_str(&b).ok())
283 .unwrap_or_default()
284}
285
286pub fn save_watch(mur_home: &Path, s: &WatchSession) -> std::io::Result<()> {
288 let path = watch_path(mur_home);
289 if let Some(p) = path.parent() {
290 std::fs::create_dir_all(p)?;
291 }
292 let tmp = path.with_extension("json.tmp");
293 let data = serde_json::to_vec_pretty(s).expect("serialize WatchSession");
294 std::fs::write(&tmp, data)?;
295 std::fs::rename(&tmp, &path)
296}
297
298#[cfg(test)]
299mod watch_tests {
300 use super::*;
301 use tempfile::TempDir;
302
303 #[test]
304 fn absent_session_is_default_off() {
305 let home = TempDir::new().unwrap();
306 let s = load_watch(home.path());
307 assert!(!s.active);
308 assert_eq!(s.consent, Consent::Unasked);
309 }
310
311 #[test]
312 fn session_roundtrips() {
313 let home = TempDir::new().unwrap();
314 let s = WatchSession {
315 active: true,
316 muted: false,
317 last_interjection_ms: 123,
318 last_scene_phash: 0xABCD,
319 consent: Consent::Granted,
320 };
321 save_watch(home.path(), &s).unwrap();
322 assert_eq!(load_watch(home.path()), s);
323 }
324}