use serde::{Deserialize, Serialize};
use std::path::{Path, PathBuf};
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct VlcRuntime {
pub port: u16,
pub password: String,
pub snapshot_dir: PathBuf,
}
pub fn runtime_dir(mur_home: &Path) -> PathBuf {
mur_home.join("runtime")
}
pub fn runtime_path(mur_home: &Path) -> PathBuf {
runtime_dir(mur_home).join("vlc.json")
}
pub fn load_runtime(mur_home: &Path) -> Option<VlcRuntime> {
let raw = std::fs::read_to_string(runtime_path(mur_home)).ok()?;
serde_json::from_str(&raw).ok()
}
#[derive(Debug, Clone, PartialEq, Serialize)]
pub struct VlcStatus {
pub state: String, pub time: i64, pub length: i64, pub volume: i64, }
pub fn parse_status_xml(xml: &str) -> VlcStatus {
use quick_xml::Reader;
use quick_xml::events::Event;
let mut reader = Reader::from_str(xml);
let mut state = "stopped".to_string();
let mut time = 0i64;
let mut length = 0i64;
let mut volume = 0i64;
let mut buf = Vec::new();
let mut in_tag = String::new();
let mut depth = 0i32;
loop {
match reader.read_event_into(&mut buf) {
Ok(Event::Start(ref e)) => {
depth += 1;
in_tag = String::from_utf8_lossy(e.name().as_ref()).into_owned();
}
Ok(Event::End(_)) => {
depth -= 1;
in_tag.clear();
}
Ok(Event::Empty(_)) => {
in_tag.clear();
}
Ok(Event::Text(ref e)) if depth == 2 => {
let text = e.unescape().unwrap_or_default().to_string();
match in_tag.as_str() {
"state" => state = text,
"time" => time = text.trim().parse().unwrap_or(0),
"length" => length = text.trim().parse().unwrap_or(0),
"volume" => volume = text.trim().parse().unwrap_or(0),
_ => {}
}
}
Ok(Event::Eof) => break,
_ => {}
}
buf.clear();
}
VlcStatus {
state,
time,
length,
volume,
}
}
pub fn newest_file(dir: &Path) -> Option<PathBuf> {
let mut best: Option<(std::time::SystemTime, PathBuf)> = None;
for entry in std::fs::read_dir(dir).ok()? {
let entry = entry.ok()?;
let path = entry.path();
if !path.is_file() {
continue;
}
let mtime = entry.metadata().ok()?.modified().ok()?;
if best.as_ref().map(|(t, _)| mtime > *t).unwrap_or(true) {
best = Some((mtime, path));
}
}
best.map(|(_, p)| p)
}
pub fn newest_file_excluding(dir: &Path, exclude: Option<&Path>) -> Option<PathBuf> {
let newest = newest_file(dir)?;
match exclude {
Some(prev) if newest == prev => None,
_ => Some(newest),
}
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
#[test]
fn runtime_path_under_runtime_dir() {
let p = runtime_path(Path::new("/tmp/h"));
assert!(p.ends_with("runtime/vlc.json"));
assert_eq!(p.parent().unwrap(), runtime_dir(Path::new("/tmp/h")));
assert_eq!(
watch_path(Path::new("/tmp/h")).parent().unwrap(),
runtime_dir(Path::new("/tmp/h"))
);
}
#[test]
fn load_runtime_absent_is_none() {
let home = TempDir::new().unwrap();
assert!(load_runtime(home.path()).is_none());
}
#[test]
fn load_runtime_roundtrips_port() {
let home = TempDir::new().unwrap();
let dir = home.path().join("runtime");
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(
runtime_path(home.path()),
r#"{"port":61886,"password":"pw","snapshot_dir":"/tmp/s"}"#,
)
.unwrap();
assert_eq!(load_runtime(home.path()).unwrap().port, 61886);
}
#[test]
fn parse_status_extracts_fields() {
let xml = "<root><volume>256</volume><state>playing</state><time>42</time><length>3600</length></root>";
let s = parse_status_xml(xml);
assert_eq!(s.state, "playing");
assert_eq!(s.time, 42);
assert_eq!(s.length, 3600);
assert_eq!(s.volume, 256);
}
#[test]
fn parse_status_pretty_printed_does_not_clobber_state() {
let xml = "<root>\n <volume>256</volume>\n <state>playing</state>\n <time>42</time>\n <length>3600</length>\n</root>\n";
let s = parse_status_xml(xml);
assert_eq!(s.state, "playing");
assert_eq!(s.time, 42);
assert_eq!(s.length, 3600);
assert_eq!(s.volume, 256);
}
#[test]
fn parse_status_ignores_nested_same_named_tags() {
let xml = "<root>\n\
\x20 <state>playing</state>\n\
\x20 <length>3600</length>\n\
\x20 <information>\n\
\x20 <category name=\"meta\">\n\
\x20 <length>0</length>\n\
\x20 <state>stopped</state>\n\
\x20 </category>\n\
\x20 </information>\n\
</root>";
let s = parse_status_xml(xml);
assert_eq!(s.state, "playing");
assert_eq!(s.length, 3600);
}
#[test]
fn newest_file_picks_latest() {
let dir = TempDir::new().unwrap();
std::fs::write(dir.path().join("a.png"), b"a").unwrap();
std::thread::sleep(std::time::Duration::from_millis(20));
std::fs::write(dir.path().join("b.png"), b"b").unwrap();
assert_eq!(
newest_file(dir.path()).unwrap().file_name().unwrap(),
"b.png"
);
}
#[test]
fn newest_file_excluding_rejects_stale_and_accepts_fresh() {
let dir = TempDir::new().unwrap();
std::fs::write(dir.path().join("stale.png"), b"old").unwrap();
let baseline = newest_file(dir.path());
assert!(newest_file_excluding(dir.path(), baseline.as_deref()).is_none());
std::thread::sleep(std::time::Duration::from_millis(20));
std::fs::write(dir.path().join("fresh.png"), b"new").unwrap();
assert_eq!(
newest_file_excluding(dir.path(), baseline.as_deref())
.unwrap()
.file_name()
.unwrap(),
"fresh.png"
);
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "snake_case")]
pub enum Consent {
#[default]
Unasked,
Granted,
Declined,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
pub struct WatchSession {
pub active: bool,
pub muted: bool,
pub last_interjection_ms: i64,
pub last_scene_phash: u64,
pub consent: Consent,
}
pub fn watch_path(mur_home: &Path) -> PathBuf {
runtime_dir(mur_home).join("watch.json")
}
pub fn load_watch(mur_home: &Path) -> WatchSession {
std::fs::read_to_string(watch_path(mur_home))
.ok()
.and_then(|b| serde_json::from_str(&b).ok())
.unwrap_or_default()
}
pub fn save_watch(mur_home: &Path, s: &WatchSession) -> std::io::Result<()> {
let path = watch_path(mur_home);
if let Some(p) = path.parent() {
std::fs::create_dir_all(p)?;
}
let tmp = path.with_extension("json.tmp");
let data = serde_json::to_vec_pretty(s).expect("serialize WatchSession");
std::fs::write(&tmp, data)?;
std::fs::rename(&tmp, &path)
}
#[cfg(test)]
mod watch_tests {
use super::*;
use tempfile::TempDir;
#[test]
fn absent_session_is_default_off() {
let home = TempDir::new().unwrap();
let s = load_watch(home.path());
assert!(!s.active);
assert_eq!(s.consent, Consent::Unasked);
}
#[test]
fn session_roundtrips() {
let home = TempDir::new().unwrap();
let s = WatchSession {
active: true,
muted: false,
last_interjection_ms: 123,
last_scene_phash: 0xABCD,
consent: Consent::Granted,
};
save_watch(home.path(), &s).unwrap();
assert_eq!(load_watch(home.path()), s);
}
}