diff --git a/justfile b/justfile index e8b71e2..33f40e3 100644 --- a/justfile +++ b/justfile @@ -6,6 +6,12 @@ build: up profile: devenv up --profile {{profile}} +vm command: + scripts/vm.sh {{command}} + +redeploy: + scripts/vm.sh redeploy + e2e: scripts/e2e/run.sh diff --git a/src/music/metadata.rs b/src/music/metadata.rs index 0a2c169..ef752bb 100644 --- a/src/music/metadata.rs +++ b/src/music/metadata.rs @@ -1,6 +1,6 @@ use symphonia::core::meta::{MetadataRevision, StandardTagKey}; -#[derive(Debug, Default, Clone)] +#[derive(Debug, Default, Clone, PartialEq, Eq)] pub struct MusicMetadata { pub artist: Vec, pub album_artist: Option, diff --git a/src/origins/network/mod.rs b/src/origins/network/mod.rs index fb1fd62..15b716e 100644 --- a/src/origins/network/mod.rs +++ b/src/origins/network/mod.rs @@ -115,6 +115,7 @@ impl NetworkOrigin { io_err(e) })? .into_iter() + .filter(|m| m.file_type == "file") .map(|m| (m.inode as u64, m.hash as u64)) .collect(); diff --git a/src/origins/network/watcher.rs b/src/origins/network/watcher.rs index 66b7ba6..3dc8ef1 100644 --- a/src/origins/network/watcher.rs +++ b/src/origins/network/watcher.rs @@ -132,6 +132,7 @@ impl WatcherState { .all(&self.client) .await? .into_iter() + .filter(|m| m.file_type == "file") .map(|m| (m.inode as u64, m.hash as u64)) .collect(); diff --git a/src/server/state.rs b/src/server/state.rs index 6c05da0..cc7c735 100644 --- a/src/server/state.rs +++ b/src/server/state.rs @@ -15,7 +15,7 @@ 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)] +#[derive(Debug, Clone, PartialEq)] pub struct FileEntry { pub abs_path: PathBuf, pub rel_path: String, @@ -37,16 +37,17 @@ impl ServerState { /// 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<()> { + pub fn replace_all(&self, source: &Path) -> io::Result { let entries = scan_directory(source)?; - let count = entries.len(); + let new_map: HashMap = entries.into_iter().collect(); let mut map = self.inner.lock().unwrap(); - map.clear(); - for (id, entry) in entries { - map.insert(id, entry); + if *map == new_map { + return Ok(false); } - info!(count, "server state: scan complete"); - return Ok(()); + 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 @@ -179,4 +180,81 @@ mod tests { 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()); + } } diff --git a/src/server/watcher.rs b/src/server/watcher.rs index dc17f3d..d3e3ae9 100644 --- a/src/server/watcher.rs +++ b/src/server/watcher.rs @@ -90,25 +90,29 @@ fn run_watcher_loop( EventKind::Remove(_) => ChangeKind::Remove, _ => continue, }; - if let Err(e) = state.replace_all(&source) { - error!(error = %e, "server watcher: state refresh failed"); - continue; + match state.replace_all(&source) { + Ok(true) => { + let _ = events_tx.send(ChangeEvent { kind }); + } + Ok(false) => {} + Err(e) => { + error!(error = %e, "server watcher: state refresh failed"); + } } - let _ = events_tx.send(ChangeEvent { kind }); } _ => {} }, Err(e) => error!(error = %e, "server watcher: inotify error"), }, - Err(std::sync::mpsc::RecvTimeoutError::Timeout) => { - if let Err(e) = state.replace_all(&source) { - error!(error = %e, "server watcher: poll rescan failed"); - continue; + Err(std::sync::mpsc::RecvTimeoutError::Timeout) => match state.replace_all(&source) { + Ok(true) => { + let _ = events_tx.send(ChangeEvent { + kind: ChangeKind::Modify, + }); } - let _ = events_tx.send(ChangeEvent { - kind: ChangeKind::Modify, - }); - } + Ok(false) => {} + Err(e) => error!(error = %e, "server watcher: poll rescan failed"), + }, Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => break, } }