use std::{ collections::HashMap, fs, io, path::{Path, PathBuf}, sync::{Arc, Mutex}, time::{SystemTime, UNIX_EPOCH}, }; use std::os::unix::fs::MetadataExt; use crate::music::parse::parse_music_metadata_for_path; use crate::origins::attrs::FileAttrs; use crate::server::manifest::ManifestEntry; use tracing::info; /// Server-side entry for one file. Built once on startup from a directory /// scan and refreshed by the watcher on inotify events. #[derive(Debug, Clone, PartialEq)] pub struct FileEntry { pub abs_path: PathBuf, pub rel_path: String, pub attrs: FileAttrs, pub music_metadata: Option, } #[derive(Clone)] pub struct ServerState { inner: Arc>>, } impl ServerState { pub fn new() -> Self { return ServerState { inner: Arc::new(Mutex::new(HashMap::new())), }; } /// Replace the entire map with a fresh scan of `source`. Used on startup /// and on watcher-driven reconciliations. pub fn replace_all(&self, source: &Path) -> io::Result { let entries = scan_directory(source)?; let new_map: HashMap = entries.into_iter().collect(); let mut map = self.inner.lock().unwrap(); if *map == new_map { return Ok(false); } let count = new_map.len(); *map = new_map; info!(count, "server state: scan complete (changed)"); return Ok(true); } /// Snapshot the current state into a manifest. Order is by inode ascending /// so two scans of an unchanged library serialize identically. pub fn manifest(&self) -> Vec { let map = self.inner.lock().unwrap(); let mut entries: Vec = map .iter() .map(|(id, file)| ManifestEntry { id: *id, rel_path: file.rel_path.clone(), size: file.attrs.size, mtime: secs(file.attrs.mtime), ctime: secs(file.attrs.ctime), crtime: secs(file.attrs.crtime), music_metadata: file.music_metadata.clone(), }) .collect(); entries.sort_by_key(|e| e.id); return entries; } pub fn lookup(&self, id: u64) -> Option { return self.inner.lock().unwrap().get(&id).cloned(); } } fn secs(time: SystemTime) -> u64 { return time .duration_since(UNIX_EPOCH) .map(|d| d.as_secs()) .unwrap_or(0); } fn scan_directory(source: &Path) -> io::Result> { let mut out = Vec::new(); walk(source, source, &mut out)?; return Ok(out); } fn walk(source: &Path, dir: &Path, out: &mut Vec<(u64, FileEntry)>) -> io::Result<()> { for entry in fs::read_dir(dir)? { let entry = entry?; let path = entry.path(); let file_type = entry.file_type()?; if file_type.is_dir() { walk(source, &path, out)?; continue; } if !file_type.is_file() { continue; } let metadata = entry.metadata()?; let rel_path = path .strip_prefix(source) .map(|p| p.to_path_buf()) .unwrap_or_else(|_| path.clone()) .to_string_lossy() .replace('\\', "/"); let inode = metadata.ino(); let music_metadata = parse_music_metadata_for_path(&path); let file_entry = FileEntry { abs_path: path, rel_path, attrs: FileAttrs::from(&metadata), music_metadata, }; out.push((inode, file_entry)); } return Ok(()); } #[cfg(test)] mod tests { use super::*; use std::io::Write; #[test] fn replace_all_loads_files_into_state() { let tmp = tempfile::tempdir().unwrap(); let source = tmp.path(); let mut f = fs::File::create(source.join("a.txt")).unwrap(); f.write_all(b"hello").unwrap(); drop(f); let mut f = fs::File::create(source.join("b.txt")).unwrap(); f.write_all(b"world!").unwrap(); drop(f); let state = ServerState::new(); state.replace_all(source).unwrap(); let manifest = state.manifest(); assert_eq!(manifest.len(), 2); assert!(manifest.iter().any(|e| e.rel_path == "a.txt")); assert!(manifest.iter().any(|e| e.rel_path == "b.txt")); } #[test] fn manifest_entries_sorted_by_id_ascending() { let tmp = tempfile::tempdir().unwrap(); let source = tmp.path(); // Create files in any order; inode order is determined by the FS. for name in ["z.txt", "a.txt", "m.txt"] { fs::write(source.join(name), b"x").unwrap(); } let state = ServerState::new(); state.replace_all(source).unwrap(); let ids: Vec = state.manifest().iter().map(|e| e.id).collect(); let mut sorted = ids.clone(); sorted.sort(); assert_eq!(ids, sorted); } #[test] fn replace_all_clears_existing_entries() { let tmp = tempfile::tempdir().unwrap(); let source = tmp.path(); fs::write(source.join("a.txt"), b"x").unwrap(); let state = ServerState::new(); state.replace_all(source).unwrap(); assert_eq!(state.manifest().len(), 1); fs::remove_file(source.join("a.txt")).unwrap(); state.replace_all(source).unwrap(); assert_eq!(state.manifest().len(), 0); } #[test] fn replace_all_returns_true_on_first_scan() { let tmp = tempfile::tempdir().unwrap(); let source = tmp.path(); fs::write(source.join("a.txt"), b"x").unwrap(); let state = ServerState::new(); assert!(state.replace_all(source).unwrap()); } #[test] fn replace_all_returns_false_when_unchanged() { let tmp = tempfile::tempdir().unwrap(); let source = tmp.path(); fs::write(source.join("a.txt"), b"hello").unwrap(); fs::write(source.join("b.txt"), b"world").unwrap(); let state = ServerState::new(); state.replace_all(source).unwrap(); assert!(!state.replace_all(source).unwrap()); } #[test] fn replace_all_returns_true_after_file_added() { let tmp = tempfile::tempdir().unwrap(); let source = tmp.path(); fs::write(source.join("a.txt"), b"x").unwrap(); let state = ServerState::new(); state.replace_all(source).unwrap(); fs::write(source.join("b.txt"), b"y").unwrap(); assert!(state.replace_all(source).unwrap()); } #[test] fn replace_all_returns_true_after_file_removed() { let tmp = tempfile::tempdir().unwrap(); let source = tmp.path(); fs::write(source.join("a.txt"), b"x").unwrap(); fs::write(source.join("b.txt"), b"y").unwrap(); let state = ServerState::new(); state.replace_all(source).unwrap(); fs::remove_file(source.join("a.txt")).unwrap(); assert!(state.replace_all(source).unwrap()); } #[test] fn replace_all_returns_true_after_file_renamed() { let tmp = tempfile::tempdir().unwrap(); let source = tmp.path(); fs::write(source.join("old.txt"), b"x").unwrap(); let state = ServerState::new(); state.replace_all(source).unwrap(); assert!(!state.replace_all(source).unwrap()); fs::rename(source.join("old.txt"), source.join("new.txt")).unwrap(); assert!(state.replace_all(source).unwrap()); } #[test] fn replace_all_returns_true_after_content_modified() { let tmp = tempfile::tempdir().unwrap(); let source = tmp.path(); fs::write(source.join("a.txt"), b"original").unwrap(); let state = ServerState::new(); state.replace_all(source).unwrap(); assert!(!state.replace_all(source).unwrap()); fs::write(source.join("a.txt"), b"modified content").unwrap(); assert!(state.replace_all(source).unwrap()); } }