Implement Week 3 virtual tree with path resolver and FUSE integration
This commit is contained in:
Generated
+1
@@ -406,6 +406,7 @@ version = "0.1.0"
|
||||
dependencies = [
|
||||
"fuser",
|
||||
"libc",
|
||||
"musicfs-cache",
|
||||
"musicfs-core",
|
||||
"tokio",
|
||||
"tracing",
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
mod db;
|
||||
mod metadata;
|
||||
mod tree;
|
||||
|
||||
pub use db::Database;
|
||||
pub use metadata::MetadataCache;
|
||||
pub use tree::{
|
||||
DirNode, FileNode, Inode, RefreshPolicy, TreeBuilder, VirtualNode, VirtualTree, ROOT_INODE,
|
||||
};
|
||||
|
||||
@@ -0,0 +1,444 @@
|
||||
use musicfs_core::{FileId, FileMeta, VirtualPath};
|
||||
use std::collections::{BTreeMap, HashMap};
|
||||
use std::ffi::{OsStr, OsString};
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
use std::sync::RwLock;
|
||||
use std::time::{Duration, SystemTime};
|
||||
|
||||
pub type Inode = u64;
|
||||
pub const ROOT_INODE: Inode = 1;
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum VirtualNode {
|
||||
Directory(DirNode),
|
||||
File(FileNode),
|
||||
}
|
||||
|
||||
impl VirtualNode {
|
||||
pub fn inode(&self) -> Inode {
|
||||
match self {
|
||||
VirtualNode::Directory(d) => d.inode,
|
||||
VirtualNode::File(f) => f.inode,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn name(&self) -> &OsStr {
|
||||
match self {
|
||||
VirtualNode::Directory(d) => &d.name,
|
||||
VirtualNode::File(f) => &f.name,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn is_dir(&self) -> bool {
|
||||
matches!(self, VirtualNode::Directory(_))
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct DirNode {
|
||||
pub inode: Inode,
|
||||
pub parent: Inode,
|
||||
pub name: OsString,
|
||||
pub children: BTreeMap<OsString, Inode>,
|
||||
pub mtime: SystemTime,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct FileNode {
|
||||
pub inode: Inode,
|
||||
pub name: OsString,
|
||||
pub file_id: FileId,
|
||||
pub size: u64,
|
||||
pub mtime: SystemTime,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct RefreshPolicy {
|
||||
pub ttl: Duration,
|
||||
pub refresh_on_access: bool,
|
||||
pub background_interval: Option<Duration>,
|
||||
}
|
||||
|
||||
impl Default for RefreshPolicy {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
ttl: Duration::from_secs(300),
|
||||
refresh_on_access: false,
|
||||
background_interval: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub struct VirtualTree {
|
||||
nodes: HashMap<Inode, VirtualNode>,
|
||||
path_to_inode: HashMap<VirtualPath, Inode>,
|
||||
next_inode: AtomicU64,
|
||||
last_refresh: RwLock<SystemTime>,
|
||||
refresh_policy: RefreshPolicy,
|
||||
}
|
||||
|
||||
impl VirtualTree {
|
||||
pub fn new() -> Self {
|
||||
Self::with_policy(RefreshPolicy::default())
|
||||
}
|
||||
|
||||
pub fn with_policy(policy: RefreshPolicy) -> Self {
|
||||
let mut tree = Self {
|
||||
nodes: HashMap::new(),
|
||||
path_to_inode: HashMap::new(),
|
||||
next_inode: AtomicU64::new(ROOT_INODE + 1),
|
||||
last_refresh: RwLock::new(SystemTime::now()),
|
||||
refresh_policy: policy,
|
||||
};
|
||||
|
||||
tree.nodes.insert(
|
||||
ROOT_INODE,
|
||||
VirtualNode::Directory(DirNode {
|
||||
inode: ROOT_INODE,
|
||||
parent: ROOT_INODE,
|
||||
name: OsString::from(""),
|
||||
children: BTreeMap::new(),
|
||||
mtime: SystemTime::now(),
|
||||
}),
|
||||
);
|
||||
tree.path_to_inode
|
||||
.insert(VirtualPath::new("/"), ROOT_INODE);
|
||||
|
||||
tree
|
||||
}
|
||||
|
||||
fn alloc_inode(&self) -> Inode {
|
||||
self.next_inode.fetch_add(1, Ordering::SeqCst)
|
||||
}
|
||||
|
||||
pub fn get(&self, inode: Inode) -> Option<&VirtualNode> {
|
||||
self.nodes.get(&inode)
|
||||
}
|
||||
|
||||
pub fn get_by_path(&self, path: &VirtualPath) -> Option<&VirtualNode> {
|
||||
self.path_to_inode
|
||||
.get(path)
|
||||
.and_then(|ino| self.nodes.get(ino))
|
||||
}
|
||||
|
||||
pub fn lookup(&self, parent_inode: Inode, name: &OsStr) -> Option<Inode> {
|
||||
if let Some(VirtualNode::Directory(dir)) = self.nodes.get(&parent_inode) {
|
||||
dir.children.get(name).copied()
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
pub fn readdir(&self, inode: Inode) -> Option<Vec<(OsString, Inode, bool)>> {
|
||||
if let Some(VirtualNode::Directory(dir)) = self.nodes.get(&inode) {
|
||||
Some(
|
||||
dir.children
|
||||
.iter()
|
||||
.map(|(name, &ino)| {
|
||||
let is_dir = self.nodes.get(&ino).map(|n| n.is_dir()).unwrap_or(false);
|
||||
(name.clone(), ino, is_dir)
|
||||
})
|
||||
.collect(),
|
||||
)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
pub fn get_parent(&self, inode: Inode) -> Option<Inode> {
|
||||
match self.nodes.get(&inode) {
|
||||
Some(VirtualNode::Directory(dir)) => Some(dir.parent),
|
||||
Some(VirtualNode::File(_)) => self.find_parent_by_path_lookup(inode),
|
||||
None => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn find_parent_by_path_lookup(&self, inode: Inode) -> Option<Inode> {
|
||||
for (path, &ino) in &self.path_to_inode {
|
||||
if ino == inode {
|
||||
return std::path::Path::new(path.as_str())
|
||||
.parent()
|
||||
.and_then(|p| {
|
||||
self.path_to_inode
|
||||
.get(&VirtualPath::new(p.to_string_lossy().into_owned()))
|
||||
.copied()
|
||||
});
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
pub fn insert_file(&mut self, meta: &FileMeta) -> Inode {
|
||||
let path = &meta.virtual_path;
|
||||
|
||||
let parent_inode = self.ensure_parents(path);
|
||||
|
||||
let inode = self.alloc_inode();
|
||||
let name = std::path::Path::new(path.as_str())
|
||||
.file_name()
|
||||
.unwrap_or_default()
|
||||
.to_os_string();
|
||||
|
||||
let file_node = FileNode {
|
||||
inode,
|
||||
name: name.clone(),
|
||||
file_id: meta.id,
|
||||
size: meta.size,
|
||||
mtime: meta.mtime,
|
||||
};
|
||||
|
||||
self.nodes.insert(inode, VirtualNode::File(file_node));
|
||||
self.path_to_inode.insert(path.clone(), inode);
|
||||
|
||||
if let Some(VirtualNode::Directory(dir)) = self.nodes.get_mut(&parent_inode) {
|
||||
dir.children.insert(name, inode);
|
||||
}
|
||||
|
||||
inode
|
||||
}
|
||||
|
||||
fn ensure_parents(&mut self, path: &VirtualPath) -> Inode {
|
||||
let path_str = path.as_str();
|
||||
let components: Vec<&str> = path_str
|
||||
.trim_start_matches('/')
|
||||
.split('/')
|
||||
.filter(|s| !s.is_empty())
|
||||
.collect();
|
||||
|
||||
if components.len() <= 1 {
|
||||
return ROOT_INODE;
|
||||
}
|
||||
|
||||
let mut current_inode = ROOT_INODE;
|
||||
let mut current_path = String::from("/");
|
||||
|
||||
for component in &components[..components.len() - 1] {
|
||||
current_path.push_str(component);
|
||||
|
||||
let vpath = VirtualPath::new(¤t_path);
|
||||
|
||||
if let Some(&existing) = self.path_to_inode.get(&vpath) {
|
||||
current_inode = existing;
|
||||
} else {
|
||||
let new_inode = self.alloc_inode();
|
||||
let name = OsString::from(*component);
|
||||
|
||||
let dir_node = DirNode {
|
||||
inode: new_inode,
|
||||
parent: current_inode,
|
||||
name: name.clone(),
|
||||
children: BTreeMap::new(),
|
||||
mtime: SystemTime::now(),
|
||||
};
|
||||
|
||||
self.nodes
|
||||
.insert(new_inode, VirtualNode::Directory(dir_node));
|
||||
self.path_to_inode.insert(vpath, new_inode);
|
||||
|
||||
if let Some(VirtualNode::Directory(parent)) = self.nodes.get_mut(¤t_inode) {
|
||||
parent.children.insert(name, new_inode);
|
||||
}
|
||||
|
||||
current_inode = new_inode;
|
||||
}
|
||||
|
||||
current_path.push('/');
|
||||
}
|
||||
|
||||
current_inode
|
||||
}
|
||||
|
||||
pub fn remove_file(&mut self, path: &VirtualPath) -> Option<FileId> {
|
||||
let inode = self.path_to_inode.remove(path)?;
|
||||
|
||||
if let Some(VirtualNode::File(file)) = self.nodes.remove(&inode) {
|
||||
let parent_path = std::path::Path::new(path.as_str())
|
||||
.parent()
|
||||
.map(|p| VirtualPath::new(p.to_string_lossy().into_owned()))
|
||||
.unwrap_or_else(|| VirtualPath::new("/"));
|
||||
|
||||
if let Some(&parent_inode) = self.path_to_inode.get(&parent_path) {
|
||||
if let Some(VirtualNode::Directory(dir)) = self.nodes.get_mut(&parent_inode) {
|
||||
dir.children.remove(&file.name);
|
||||
}
|
||||
}
|
||||
|
||||
Some(file.file_id)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
pub fn file_count(&self) -> usize {
|
||||
self.nodes
|
||||
.values()
|
||||
.filter(|n| matches!(n, VirtualNode::File(_)))
|
||||
.count()
|
||||
}
|
||||
|
||||
pub fn dir_count(&self) -> usize {
|
||||
self.nodes
|
||||
.values()
|
||||
.filter(|n| matches!(n, VirtualNode::Directory(_)))
|
||||
.count()
|
||||
}
|
||||
|
||||
pub fn needs_refresh(&self) -> bool {
|
||||
let last = *self.last_refresh.read().unwrap();
|
||||
last.elapsed().unwrap_or(Duration::MAX) > self.refresh_policy.ttl
|
||||
}
|
||||
|
||||
pub fn force_refresh(&mut self) {
|
||||
self.nodes.retain(|&ino, _| ino == ROOT_INODE);
|
||||
self.path_to_inode.retain(|p, _| p.as_str() == "/");
|
||||
|
||||
if let Some(VirtualNode::Directory(root)) = self.nodes.get_mut(&ROOT_INODE) {
|
||||
root.children.clear();
|
||||
}
|
||||
|
||||
*self.last_refresh.write().unwrap() = SystemTime::now();
|
||||
}
|
||||
|
||||
pub fn mark_refreshed(&self) {
|
||||
*self.last_refresh.write().unwrap() = SystemTime::now();
|
||||
}
|
||||
|
||||
pub fn refresh_policy(&self) -> &RefreshPolicy {
|
||||
&self.refresh_policy
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for VirtualTree {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
pub struct TreeBuilder {
|
||||
tree: VirtualTree,
|
||||
}
|
||||
|
||||
impl TreeBuilder {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
tree: VirtualTree::new(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn with_policy(policy: RefreshPolicy) -> Self {
|
||||
Self {
|
||||
tree: VirtualTree::with_policy(policy),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn add_file(&mut self, meta: &FileMeta) {
|
||||
self.tree.insert_file(meta);
|
||||
}
|
||||
|
||||
pub fn build(self) -> VirtualTree {
|
||||
self.tree
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for TreeBuilder {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use musicfs_core::{OriginId, RealPath};
|
||||
use std::path::PathBuf;
|
||||
|
||||
fn make_file_meta(id: i64, vpath: &str) -> FileMeta {
|
||||
FileMeta {
|
||||
id: FileId(id),
|
||||
virtual_path: VirtualPath::new(vpath),
|
||||
real_path: RealPath {
|
||||
origin_id: OriginId::from("test"),
|
||||
path: PathBuf::from("/test"),
|
||||
},
|
||||
size: 1000,
|
||||
mtime: SystemTime::now(),
|
||||
content_hash: None,
|
||||
audio: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_tree_creation() {
|
||||
let tree = VirtualTree::new();
|
||||
assert!(tree.get(ROOT_INODE).is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_insert_file() {
|
||||
let mut tree = VirtualTree::new();
|
||||
let meta = make_file_meta(1, "/Artist/Album/Track.flac");
|
||||
tree.insert_file(&meta);
|
||||
|
||||
assert!(tree.get_by_path(&VirtualPath::new("/Artist")).is_some());
|
||||
assert!(tree
|
||||
.get_by_path(&VirtualPath::new("/Artist/Album"))
|
||||
.is_some());
|
||||
assert!(tree
|
||||
.get_by_path(&VirtualPath::new("/Artist/Album/Track.flac"))
|
||||
.is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_readdir() {
|
||||
let mut tree = VirtualTree::new();
|
||||
tree.insert_file(&make_file_meta(1, "/Artist/Album/Track1.flac"));
|
||||
tree.insert_file(&make_file_meta(2, "/Artist/Album/Track2.flac"));
|
||||
|
||||
let root_children = tree.readdir(ROOT_INODE).unwrap();
|
||||
assert_eq!(root_children.len(), 1);
|
||||
assert_eq!(root_children[0].0, "Artist");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_lookup() {
|
||||
let mut tree = VirtualTree::new();
|
||||
tree.insert_file(&make_file_meta(1, "/Artist/Album/Track.flac"));
|
||||
|
||||
let artist_inode = tree.lookup(ROOT_INODE, OsStr::new("Artist")).unwrap();
|
||||
assert!(tree.lookup(artist_inode, OsStr::new("Album")).is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_file_and_dir_count() {
|
||||
let mut tree = VirtualTree::new();
|
||||
tree.insert_file(&make_file_meta(1, "/A/B/Track1.flac"));
|
||||
tree.insert_file(&make_file_meta(2, "/A/B/Track2.flac"));
|
||||
tree.insert_file(&make_file_meta(3, "/A/C/Track3.flac"));
|
||||
|
||||
assert_eq!(tree.file_count(), 3);
|
||||
assert_eq!(tree.dir_count(), 4);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_remove_file() {
|
||||
let mut tree = VirtualTree::new();
|
||||
let path = VirtualPath::new("/Artist/Album/Track.flac");
|
||||
tree.insert_file(&make_file_meta(1, path.as_str()));
|
||||
|
||||
assert!(tree.get_by_path(&path).is_some());
|
||||
|
||||
let removed_id = tree.remove_file(&path);
|
||||
assert_eq!(removed_id, Some(FileId(1)));
|
||||
assert!(tree.get_by_path(&path).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_tree_builder() {
|
||||
let mut builder = TreeBuilder::new();
|
||||
builder.add_file(&make_file_meta(1, "/A/Track1.flac"));
|
||||
builder.add_file(&make_file_meta(2, "/A/Track2.flac"));
|
||||
|
||||
let tree = builder.build();
|
||||
assert_eq!(tree.file_count(), 2);
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,9 @@
|
||||
pub mod error;
|
||||
pub mod events;
|
||||
pub mod resolver;
|
||||
pub mod types;
|
||||
|
||||
pub use error::{Error, Result};
|
||||
pub use events::{Event, EventBus};
|
||||
pub use resolver::{PathResolver, PathTemplate};
|
||||
pub use types::*;
|
||||
|
||||
@@ -0,0 +1,174 @@
|
||||
use crate::{AudioMeta, VirtualPath};
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct PathTemplate {
|
||||
pub pattern: String,
|
||||
pub fallback_artist: String,
|
||||
pub fallback_album: String,
|
||||
pub fallback_title: String,
|
||||
pub fallback_year: String,
|
||||
}
|
||||
|
||||
impl Default for PathTemplate {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
pattern: "$artist/$album ($year) [$format_upper]/$track - $title.$format".to_string(),
|
||||
fallback_artist: "Unknown Artist".to_string(),
|
||||
fallback_album: "Unknown Album".to_string(),
|
||||
fallback_title: "Unknown Track".to_string(),
|
||||
fallback_year: "Unknown".to_string(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub struct PathResolver {
|
||||
template: PathTemplate,
|
||||
}
|
||||
|
||||
impl PathResolver {
|
||||
pub fn new(template: PathTemplate) -> Self {
|
||||
Self { template }
|
||||
}
|
||||
|
||||
pub fn resolve(&self, meta: &AudioMeta, extension: &str) -> VirtualPath {
|
||||
let artist = meta
|
||||
.artist
|
||||
.as_deref()
|
||||
.unwrap_or(&self.template.fallback_artist);
|
||||
let album = meta
|
||||
.album
|
||||
.as_deref()
|
||||
.unwrap_or(&self.template.fallback_album);
|
||||
let title = meta
|
||||
.title
|
||||
.as_deref()
|
||||
.unwrap_or(&self.template.fallback_title);
|
||||
let year = meta
|
||||
.year
|
||||
.map(|y| y.to_string())
|
||||
.unwrap_or_else(|| self.template.fallback_year.clone());
|
||||
let track = meta.track.unwrap_or(0);
|
||||
let disc = meta.disc.unwrap_or(1);
|
||||
let genre = meta.genre.as_deref().unwrap_or("Unknown");
|
||||
let format = extension.to_lowercase();
|
||||
let format_upper = extension.to_uppercase();
|
||||
|
||||
let artist = sanitize_path_component(artist);
|
||||
let album = sanitize_path_component(album);
|
||||
let title = sanitize_path_component(title);
|
||||
let genre = sanitize_path_component(genre);
|
||||
|
||||
let path = self
|
||||
.template
|
||||
.pattern
|
||||
.replace("$artist", &artist)
|
||||
.replace("$album", &album)
|
||||
.replace("$title", &title)
|
||||
.replace("$track", &format!("{:02}", track))
|
||||
.replace("$disc", &disc.to_string())
|
||||
.replace("$year", &year)
|
||||
.replace("$genre", &genre)
|
||||
.replace("$format_upper", &format_upper)
|
||||
.replace("$format", &format);
|
||||
|
||||
VirtualPath::new(path)
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for PathResolver {
|
||||
fn default() -> Self {
|
||||
Self::new(PathTemplate::default())
|
||||
}
|
||||
}
|
||||
|
||||
fn sanitize_path_component(s: &str) -> String {
|
||||
s.chars()
|
||||
.map(|c| match c {
|
||||
'/' | '\\' | ':' | '*' | '?' | '"' | '<' | '>' | '|' | '\0' => '_',
|
||||
c => c,
|
||||
})
|
||||
.collect::<String>()
|
||||
.trim()
|
||||
.to_string()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::AudioFormat;
|
||||
|
||||
#[test]
|
||||
fn test_resolve_complete_metadata() {
|
||||
let resolver = PathResolver::default();
|
||||
let meta = AudioMeta {
|
||||
artist: Some("Metallica".to_string()),
|
||||
album: Some("Master of Puppets".to_string()),
|
||||
title: Some("Battery".to_string()),
|
||||
track: Some(1),
|
||||
year: Some(1986),
|
||||
format: AudioFormat::Flac,
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let path = resolver.resolve(&meta, "flac");
|
||||
assert_eq!(
|
||||
path.as_str(),
|
||||
"Metallica/Master of Puppets (1986) [FLAC]/01 - Battery.flac"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_resolve_missing_album() {
|
||||
let resolver = PathResolver::default();
|
||||
let meta = AudioMeta {
|
||||
artist: Some("Artist".to_string()),
|
||||
title: Some("Track".to_string()),
|
||||
track: Some(5),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let path = resolver.resolve(&meta, "mp3");
|
||||
assert_eq!(
|
||||
path.as_str(),
|
||||
"Artist/Unknown Album (Unknown) [MP3]/05 - Track.mp3"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_sanitize_special_chars() {
|
||||
let resolver = PathResolver::default();
|
||||
let meta = AudioMeta {
|
||||
artist: Some("AC/DC".to_string()),
|
||||
album: Some("Who Made Who?".to_string()),
|
||||
title: Some("Test:Track".to_string()),
|
||||
track: Some(1),
|
||||
year: Some(1986),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let path = resolver.resolve(&meta, "flac");
|
||||
assert!(!path.as_str().contains(':'));
|
||||
assert!(!path.as_str().contains('?'));
|
||||
assert!(path.as_str().contains("AC_DC"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_custom_template() {
|
||||
let template = PathTemplate {
|
||||
pattern: "$genre/$artist - $album/$track $title.$format".to_string(),
|
||||
..Default::default()
|
||||
};
|
||||
let resolver = PathResolver::new(template);
|
||||
let meta = AudioMeta {
|
||||
artist: Some("Artist".to_string()),
|
||||
album: Some("Album".to_string()),
|
||||
title: Some("Song".to_string()),
|
||||
genre: Some("Rock".to_string()),
|
||||
track: Some(3),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let path = resolver.resolve(&meta, "flac");
|
||||
assert_eq!(path.as_str(), "Rock/Artist - Album/03 Song.flac");
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ edition.workspace = true
|
||||
|
||||
[dependencies]
|
||||
musicfs-core = { path = "../musicfs-core" }
|
||||
musicfs-cache = { path = "../musicfs-cache" }
|
||||
fuser.workspace = true
|
||||
tokio.workspace = true
|
||||
tracing.workspace = true
|
||||
|
||||
@@ -1,23 +1,28 @@
|
||||
use fuser::{
|
||||
FileAttr, FileType, Filesystem, ReplyAttr, ReplyData, ReplyDirectory, ReplyEntry, ReplyOpen,
|
||||
Request, FUSE_ROOT_ID,
|
||||
Request,
|
||||
};
|
||||
use musicfs_core::{Error, Result};
|
||||
use musicfs_cache::{VirtualNode, VirtualTree, ROOT_INODE};
|
||||
use musicfs_core::Result;
|
||||
use std::ffi::OsStr;
|
||||
use std::path::Path;
|
||||
use std::time::{Duration, SystemTime, UNIX_EPOCH};
|
||||
use std::sync::{Arc, RwLock};
|
||||
use std::time::{Duration, SystemTime};
|
||||
use tracing::{debug, info};
|
||||
|
||||
const TTL: Duration = Duration::from_secs(1);
|
||||
const BLOCK_SIZE: u32 = 512;
|
||||
|
||||
pub struct MusicFs {
|
||||
tree: Arc<RwLock<VirtualTree>>,
|
||||
uid: u32,
|
||||
gid: u32,
|
||||
}
|
||||
|
||||
impl MusicFs {
|
||||
pub fn new() -> Self {
|
||||
pub fn new(tree: Arc<RwLock<VirtualTree>>) -> Self {
|
||||
Self {
|
||||
tree,
|
||||
uid: unsafe { libc::getuid() },
|
||||
gid: unsafe { libc::getgid() },
|
||||
}
|
||||
@@ -33,38 +38,51 @@ impl MusicFs {
|
||||
fuser::MountOption::AllowOther,
|
||||
];
|
||||
|
||||
fuser::mount2(self, mountpoint, &options).map_err(Error::Io)?;
|
||||
fuser::mount2(self, mountpoint, &options).map_err(musicfs_core::Error::Io)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn root_attr(&self) -> FileAttr {
|
||||
FileAttr {
|
||||
ino: FUSE_ROOT_ID,
|
||||
size: 0,
|
||||
blocks: 0,
|
||||
atime: UNIX_EPOCH,
|
||||
mtime: UNIX_EPOCH,
|
||||
ctime: UNIX_EPOCH,
|
||||
crtime: UNIX_EPOCH,
|
||||
kind: FileType::Directory,
|
||||
perm: 0o755,
|
||||
nlink: 2,
|
||||
uid: self.uid,
|
||||
gid: self.gid,
|
||||
rdev: 0,
|
||||
blksize: 512,
|
||||
flags: 0,
|
||||
fn node_to_attr(&self, node: &VirtualNode) -> FileAttr {
|
||||
match node {
|
||||
VirtualNode::Directory(dir) => FileAttr {
|
||||
ino: dir.inode,
|
||||
size: 0,
|
||||
blocks: 0,
|
||||
atime: dir.mtime,
|
||||
mtime: dir.mtime,
|
||||
ctime: dir.mtime,
|
||||
crtime: dir.mtime,
|
||||
kind: FileType::Directory,
|
||||
perm: 0o755,
|
||||
nlink: 2,
|
||||
uid: self.uid,
|
||||
gid: self.gid,
|
||||
rdev: 0,
|
||||
blksize: BLOCK_SIZE,
|
||||
flags: 0,
|
||||
},
|
||||
VirtualNode::File(file) => FileAttr {
|
||||
ino: file.inode,
|
||||
size: file.size,
|
||||
blocks: (file.size + BLOCK_SIZE as u64 - 1) / BLOCK_SIZE as u64,
|
||||
atime: file.mtime,
|
||||
mtime: file.mtime,
|
||||
ctime: file.mtime,
|
||||
crtime: file.mtime,
|
||||
kind: FileType::RegularFile,
|
||||
perm: 0o644,
|
||||
nlink: 1,
|
||||
uid: self.uid,
|
||||
gid: self.gid,
|
||||
rdev: 0,
|
||||
blksize: BLOCK_SIZE,
|
||||
flags: 0,
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for MusicFs {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl Filesystem for MusicFs {
|
||||
fn init(
|
||||
&mut self,
|
||||
@@ -81,14 +99,28 @@ impl Filesystem for MusicFs {
|
||||
|
||||
fn lookup(&mut self, _req: &Request, parent: u64, name: &OsStr, reply: ReplyEntry) {
|
||||
debug!("lookup(parent={}, name={:?})", parent, name);
|
||||
|
||||
let tree = self.tree.read().unwrap();
|
||||
|
||||
if let Some(inode) = tree.lookup(parent, name) {
|
||||
if let Some(node) = tree.get(inode) {
|
||||
let attr = self.node_to_attr(node);
|
||||
reply.entry(&TTL, &attr, 0);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
reply.error(libc::ENOENT);
|
||||
}
|
||||
|
||||
fn getattr(&mut self, _req: &Request, ino: u64, reply: ReplyAttr) {
|
||||
debug!("getattr(ino={})", ino);
|
||||
|
||||
if ino == FUSE_ROOT_ID {
|
||||
reply.attr(&TTL, &self.root_attr());
|
||||
let tree = self.tree.read().unwrap();
|
||||
|
||||
if let Some(node) = tree.get(ino) {
|
||||
let attr = self.node_to_attr(node);
|
||||
reply.attr(&TTL, &attr);
|
||||
} else {
|
||||
reply.error(libc::ENOENT);
|
||||
}
|
||||
@@ -104,13 +136,46 @@ impl Filesystem for MusicFs {
|
||||
) {
|
||||
debug!("readdir(ino={}, offset={})", ino, offset);
|
||||
|
||||
if ino == FUSE_ROOT_ID {
|
||||
if offset == 0 {
|
||||
let _ = reply.add(FUSE_ROOT_ID, 1, FileType::Directory, ".");
|
||||
let tree = self.tree.read().unwrap();
|
||||
|
||||
if let Some(children) = tree.readdir(ino) {
|
||||
let parent_ino = tree.get_parent(ino).unwrap_or(ROOT_INODE);
|
||||
|
||||
let entries: Vec<(u64, FileType, &str)> = vec![
|
||||
(ino, FileType::Directory, "."),
|
||||
(parent_ino, FileType::Directory, ".."),
|
||||
];
|
||||
|
||||
let child_entries: Vec<(u64, FileType, String)> = children
|
||||
.iter()
|
||||
.map(|(name, child_ino, is_dir)| {
|
||||
let kind = if *is_dir {
|
||||
FileType::Directory
|
||||
} else {
|
||||
FileType::RegularFile
|
||||
};
|
||||
(*child_ino, kind, name.to_string_lossy().to_string())
|
||||
})
|
||||
.collect();
|
||||
|
||||
for (i, (inode, kind, name)) in entries.iter().enumerate().skip(offset as usize) {
|
||||
if reply.add(*inode, (i + 1) as i64, *kind, name) {
|
||||
reply.ok();
|
||||
return;
|
||||
}
|
||||
}
|
||||
if offset <= 1 {
|
||||
let _ = reply.add(FUSE_ROOT_ID, 2, FileType::Directory, "..");
|
||||
|
||||
let base_offset = entries.len();
|
||||
for (i, (inode, kind, name)) in child_entries.iter().enumerate() {
|
||||
let entry_offset = base_offset + i;
|
||||
if entry_offset < offset as usize {
|
||||
continue;
|
||||
}
|
||||
if reply.add(*inode, (entry_offset + 1) as i64, *kind, name) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
reply.ok();
|
||||
} else {
|
||||
reply.error(libc::ENOENT);
|
||||
@@ -126,7 +191,13 @@ impl Filesystem for MusicFs {
|
||||
return;
|
||||
}
|
||||
|
||||
reply.error(libc::ENOENT);
|
||||
let tree = self.tree.read().unwrap();
|
||||
|
||||
if tree.get(ino).is_some() {
|
||||
reply.opened(0, 0);
|
||||
} else {
|
||||
reply.error(libc::ENOENT);
|
||||
}
|
||||
}
|
||||
|
||||
fn read(
|
||||
@@ -141,7 +212,14 @@ impl Filesystem for MusicFs {
|
||||
reply: ReplyData,
|
||||
) {
|
||||
debug!("read(ino={}, offset={}, size={})", ino, offset, size);
|
||||
reply.error(libc::ENOENT);
|
||||
|
||||
let tree = self.tree.read().unwrap();
|
||||
|
||||
if let Some(VirtualNode::File(_file)) = tree.get(ino) {
|
||||
reply.data(&[]);
|
||||
} else {
|
||||
reply.error(libc::ENOENT);
|
||||
}
|
||||
}
|
||||
|
||||
fn release(
|
||||
@@ -275,3 +353,39 @@ impl Filesystem for MusicFs {
|
||||
reply.error(libc::EROFS);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use musicfs_cache::TreeBuilder;
|
||||
use musicfs_core::{FileId, FileMeta, OriginId, RealPath, VirtualPath};
|
||||
use std::path::PathBuf;
|
||||
|
||||
fn make_file_meta(id: i64, vpath: &str, size: u64) -> FileMeta {
|
||||
FileMeta {
|
||||
id: FileId(id),
|
||||
virtual_path: VirtualPath::new(vpath),
|
||||
real_path: RealPath {
|
||||
origin_id: OriginId::from("test"),
|
||||
path: PathBuf::from("/test"),
|
||||
},
|
||||
size,
|
||||
mtime: SystemTime::now(),
|
||||
content_hash: None,
|
||||
audio: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_tree_integration() {
|
||||
let mut builder = TreeBuilder::new();
|
||||
builder.add_file(&make_file_meta(1, "/Artist/Album/Track.flac", 30_000_000));
|
||||
let tree = Arc::new(RwLock::new(builder.build()));
|
||||
|
||||
let _fs = MusicFs::new(tree.clone());
|
||||
|
||||
let tree_read = tree.read().unwrap();
|
||||
assert!(tree_read.get(ROOT_INODE).is_some());
|
||||
assert!(tree_read.get_by_path(&VirtualPath::new("/Artist")).is_some());
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user