261 lines
7.9 KiB
Rust
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());
|
|
}
|
|
}
|