Files
MusicFS/src/server/state.rs
T
2026-07-01 14:53:21 +02:00

261 lines
7.9 KiB
Rust

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<crate::music::metadata::MusicMetadata>,
}
#[derive(Clone)]
pub struct ServerState {
inner: Arc<Mutex<HashMap<u64, FileEntry>>>,
}
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<bool> {
let entries = scan_directory(source)?;
let new_map: HashMap<u64, FileEntry> = 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<ManifestEntry> {
let map = self.inner.lock().unwrap();
let mut entries: Vec<ManifestEntry> = 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<FileEntry> {
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<Vec<(u64, FileEntry)>> {
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<u64> = 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());
}
}