Skip to main content

mur_common/
media.rs

1//! Shared media runtime types + small leaf helpers. Consumed by both `mur-core`
2//! (VLC control, media tools) and `mur-agent-runtime` (WatchScheduler), which
3//! cannot depend on `mur-core` — so the snapshot-selection and VLC-status-parsing
4//! logic that both need lives here rather than being duplicated.
5
6use serde::{Deserialize, Serialize};
7use std::path::{Path, PathBuf};
8
9/// Per-session VLC HTTP connection details. Generated once and persisted so
10/// repeated tool calls reach the same running VLC instance.
11#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
12pub struct VlcRuntime {
13    pub port: u16,
14    pub password: String,
15    /// Directory VLC writes snapshots to (`--snapshot-path`).
16    pub snapshot_dir: PathBuf,
17}
18
19/// The shared runtime state directory (`~/.mur/runtime`). Holds `vlc.json`,
20/// `watch.json`, and the VLC snapshot dir — all owned by the runtime and lying
21/// outside any single agent's home, so the sandbox must grant this directory
22/// (not the individual files) for co-watching to work under enforced confinement.
23pub fn runtime_dir(mur_home: &Path) -> PathBuf {
24    mur_home.join("runtime")
25}
26
27/// Path to the persisted VLC runtime config.
28pub fn runtime_path(mur_home: &Path) -> PathBuf {
29    runtime_dir(mur_home).join("vlc.json")
30}
31
32/// Load the persisted VLC runtime (`vlc.json`), or `None` if absent/unparseable.
33/// Used by the runtime supervisor to allowlist VLC's HTTP port in the kernel
34/// sandbox, and anywhere else that needs the current VLC connection details.
35pub 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// ── VLC status parsing (shared by mur-core's tools and the runtime scheduler) ──
41
42/// Parsed subset of VLC's `requests/status.xml`.
43#[derive(Debug, Clone, PartialEq, Serialize)]
44pub struct VlcStatus {
45    pub state: String, // "playing" | "paused" | "stopped"
46    pub time: i64,     // seconds elapsed
47    pub length: i64,   // seconds total
48    pub volume: i64,   // raw VLC volume (256 == 100%)
49}
50
51/// Parse the subset of VLC's `status.xml` using a proper XML reader.
52/// Missing fields default sensibly.
53pub 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    // Element depth: <root> is depth 1, so the top-level playback fields' text
65    // sits at depth 2. Gating on this prevents identically-named tags nested
66    // deeper in VLC's <information>/<stats> subtrees from clobbering the real
67    // top-level values.
68    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            // Clear the active tag when it closes. Without this, the pretty-printed
77            // whitespace VLC emits *after* `</state>` arrives as a Text event while
78            // `in_tag` is still "state" and clobbers the value (e.g. state == "\n").
79            Ok(Event::End(_)) => {
80                depth -= 1;
81                in_tag.clear();
82            }
83            // Self-closing element (e.g. `<state/>`): no text to read; just reset
84            // the active tag so a later Text isn't attributed to it.
85            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
111// ── Snapshot file selection (shared by scene_explain and the runtime scheduler) ──
112
113/// Return the most recently modified regular file in `dir`, if any.
114pub 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
130/// Like [`newest_file`], but returns `None` when the newest file equals `exclude`
131/// — the snapshot that already existed before a capture was requested. This
132/// requires a *freshly captured* frame rather than silently falling back to a
133/// stale snapshot from a previous session.
134///
135/// VLC names each snapshot uniquely (`vlcsnap-<timestamp>.png`), so "the newest
136/// file is a different path than the pre-capture one" is a reliable freshness
137/// signal that is independent of filesystem mtime granularity. (Comparing a file
138/// mtime against a wall-clock `SystemTime::now()` would spuriously reject genuinely
139/// fresh frames on coarse-mtime volumes — HFS+/exFAT/FAT/network mounts floor mtime
140/// to whole seconds, below a sub-second `now()`.)
141pub 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        // vlc.json, watch.json and the dir grant must agree on the same parent.
159        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/// Whether the user has agreed to proactive interjections this session.
253#[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/// Persisted proactive-watch session state. Written by the MCP `watch_*` tools
263/// (via `mur-core`) and read by the runtime `WatchScheduler`.
264#[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
273/// Path to the persisted watch session.
274pub fn watch_path(mur_home: &Path) -> PathBuf {
275    runtime_dir(mur_home).join("watch.json")
276}
277
278/// Load the watch session, or a default (all-off) session if absent/unparseable.
279pub 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
286/// Persist the watch session atomically (temp + rename).
287pub 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}