Create server side

This commit is contained in:
Alexander
2026-06-29 21:16:55 +02:00
parent 80dfe50aaa
commit ebf1c5b5e1
21 changed files with 1890 additions and 11 deletions
+179
View File
@@ -0,0 +1,179 @@
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;
/// 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)]
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<()> {
let entries = scan_directory(source)?;
let mut map = self.inner.lock().unwrap();
map.clear();
for (id, entry) in entries {
map.insert(id, entry);
}
return Ok(());
}
/// 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);
}
}