diff --git a/.gitignore b/.gitignore index 50c1343..50c0c1a 100644 --- a/.gitignore +++ b/.gitignore @@ -47,3 +47,4 @@ rustc-ice-*.txt # and can be added to the global gitignore or merged into this file. For a more nuclear # option (not recommended) you can uncomment the following to ignore the entire idea folder. #.idea/ +dev/ diff --git a/crates/musicfs-cache/src/db.rs b/crates/musicfs-cache/src/db.rs index b2ccb9e..b9b34e4 100644 --- a/crates/musicfs-cache/src/db.rs +++ b/crates/musicfs-cache/src/db.rs @@ -284,6 +284,146 @@ impl Database { .optional() .map_err(|e| Error::Database(format!("query mtime failed: {}", e))) } + + pub fn path_exists(&self, path: &VirtualPath) -> Result { + let conn = self.conn.lock().unwrap(); + let count: i64 = conn + .query_row( + "SELECT COUNT(*) FROM files WHERE virtual_path = ?1", + params![path.as_str()], + |row| row.get(0), + ) + .map_err(|e| Error::Database(format!("path_exists query failed: {}", e)))?; + Ok(count > 0) + } + + pub fn update_virtual_path(&self, id: FileId, new_path: &VirtualPath) -> Result<()> { + let conn = self.conn.lock().unwrap(); + let rows = conn + .execute( + "UPDATE files SET virtual_path = ?1 WHERE id = ?2", + params![new_path.as_str(), id.0], + ) + .map_err(|e| Error::Database(format!("update_virtual_path failed: {}", e)))?; + + if rows == 0 { + return Err(Error::FileNotFound(format!("file id {} not found", id.0))); + } + debug!( + id = id.0, + new_path = new_path.as_str(), + "updated virtual path" + ); + Ok(()) + } + + pub fn rename_directory(&self, old_prefix: &str, new_prefix: &str) -> Result { + let conn = self.conn.lock().unwrap(); + + let pattern = format!("{}%", old_prefix); + let old_len = old_prefix.len(); + + let rows = conn + .execute( + "UPDATE files SET virtual_path = ?1 || substr(virtual_path, ?2) WHERE virtual_path LIKE ?3", + params![new_prefix, old_len as i64 + 1, pattern], + ) + .map_err(|e| Error::Database(format!("rename_directory failed: {}", e)))?; + + debug!(old_prefix, new_prefix, rows, "renamed directory paths"); + Ok(rows as u64) + } + + pub fn get_files_by_prefix(&self, prefix: &str) -> Result> { + let conn = self.conn.lock().unwrap(); + let pattern = format!("{}%", prefix); + + let mut stmt = conn + .prepare("SELECT id, virtual_path FROM files WHERE virtual_path LIKE ?1") + .map_err(|e| Error::Database(format!("prepare failed: {}", e)))?; + + let files: Vec<(FileId, VirtualPath)> = stmt + .query_map(params![pattern], |row| { + Ok(( + FileId(row.get(0)?), + VirtualPath::new(row.get::<_, String>(1)?), + )) + }) + .map_err(|e| Error::Database(format!("query failed: {}", e)))? + .filter_map(|r| r.ok()) + .collect(); + + Ok(files) + } + + pub fn insert_directory(&self, path: &VirtualPath) -> Result<()> { + let conn = self.conn.lock().unwrap(); + conn.execute( + "INSERT OR IGNORE INTO directories (path) VALUES (?1)", + params![path.as_str()], + ) + .map_err(|e| Error::Database(format!("insert_directory failed: {}", e)))?; + debug!(path = path.as_str(), "inserted directory"); + Ok(()) + } + + pub fn delete_directory(&self, path: &VirtualPath) -> Result<()> { + let conn = self.conn.lock().unwrap(); + conn.execute( + "DELETE FROM directories WHERE path = ?1", + params![path.as_str()], + ) + .map_err(|e| Error::Database(format!("delete_directory failed: {}", e)))?; + Ok(()) + } + + pub fn rename_directories(&self, old_prefix: &str, new_prefix: &str) -> Result { + let conn = self.conn.lock().unwrap(); + let pattern = format!("{}%", old_prefix); + let old_len = old_prefix.len(); + + let rows = conn + .execute( + "UPDATE directories SET path = ?1 || substr(path, ?2) WHERE path LIKE ?3", + params![new_prefix, old_len as i64 + 1, pattern], + ) + .map_err(|e| Error::Database(format!("rename_directories failed: {}", e)))?; + + debug!(old_prefix, new_prefix, rows, "renamed directory paths"); + Ok(rows as u64) + } + + pub fn list_directories(&self) -> Result> { + let conn = self.conn.lock().unwrap(); + + let mut stmt = conn + .prepare("SELECT path FROM directories ORDER BY path") + .map_err(|e| Error::Database(format!("prepare failed: {}", e)))?; + + let dirs: Vec = stmt + .query_map([], |row| Ok(VirtualPath::new(row.get::<_, String>(0)?))) + .map_err(|e| Error::Database(format!("query failed: {}", e)))? + .filter_map(|r| r.ok()) + .collect(); + + Ok(dirs) + } + + pub fn get_file_by_real_path( + &self, + origin_id: &OriginId, + real_path: &Path, + ) -> Result> { + let conn = self.conn.lock().unwrap(); + + conn.query_row( + "SELECT virtual_path FROM files WHERE origin_id = ?1 AND real_path = ?2", + params![&origin_id.0, real_path.to_string_lossy()], + |row| Ok(VirtualPath::new(row.get::<_, String>(0)?)), + ) + .optional() + .map_err(|e| Error::Database(format!("query failed: {}", e))) + } } fn parse_audio_format(s: &str) -> AudioFormat { @@ -501,4 +641,145 @@ mod tests { .unwrap(); assert!(retrieved.content_hash.is_some()); } + + #[test] + fn test_path_exists() { + let db = Database::open_memory().unwrap(); + + let path = VirtualPath::new("/Artist/Album/Track.flac"); + assert!(!db.path_exists(&path).unwrap()); + + db.upsert_file( + &OriginId::from("local"), + Path::new("/test.flac"), + &path, + &AudioMeta::default(), + UNIX_EPOCH, + 100, + ) + .unwrap(); + + assert!(db.path_exists(&path).unwrap()); + assert!(!db + .path_exists(&VirtualPath::new("/Other/Path.flac")) + .unwrap()); + } + + #[test] + fn test_update_virtual_path() { + let db = Database::open_memory().unwrap(); + + let old_path = VirtualPath::new("/Old/Path/Track.flac"); + let new_path = VirtualPath::new("/New/Path/Track.flac"); + + let id = db + .upsert_file( + &OriginId::from("local"), + Path::new("/test.flac"), + &old_path, + &AudioMeta::default(), + UNIX_EPOCH, + 100, + ) + .unwrap(); + + db.update_virtual_path(id, &new_path).unwrap(); + + assert!(db.get_file_by_virtual_path(&old_path).unwrap().is_none()); + assert!(db.get_file_by_virtual_path(&new_path).unwrap().is_some()); + } + + #[test] + fn test_rename_directory() { + let db = Database::open_memory().unwrap(); + let origin = OriginId::from("local"); + + db.upsert_file( + &origin, + Path::new("/a.flac"), + &VirtualPath::new("/Artist/Album/Track1.flac"), + &AudioMeta::default(), + UNIX_EPOCH, + 100, + ) + .unwrap(); + + db.upsert_file( + &origin, + Path::new("/b.flac"), + &VirtualPath::new("/Artist/Album/Track2.flac"), + &AudioMeta::default(), + UNIX_EPOCH, + 100, + ) + .unwrap(); + + db.upsert_file( + &origin, + Path::new("/c.flac"), + &VirtualPath::new("/Other/Track.flac"), + &AudioMeta::default(), + UNIX_EPOCH, + 100, + ) + .unwrap(); + + let count = db.rename_directory("/Artist/", "/Renamed Artist/").unwrap(); + assert_eq!(count, 2); + + assert!(db + .path_exists(&VirtualPath::new("/Renamed Artist/Album/Track1.flac")) + .unwrap()); + assert!(db + .path_exists(&VirtualPath::new("/Renamed Artist/Album/Track2.flac")) + .unwrap()); + assert!(db + .path_exists(&VirtualPath::new("/Other/Track.flac")) + .unwrap()); + assert!(!db + .path_exists(&VirtualPath::new("/Artist/Album/Track1.flac")) + .unwrap()); + } + + #[test] + fn test_get_files_by_prefix() { + let db = Database::open_memory().unwrap(); + let origin = OriginId::from("local"); + + db.upsert_file( + &origin, + Path::new("/a.flac"), + &VirtualPath::new("/Artist/Album/Track1.flac"), + &AudioMeta::default(), + UNIX_EPOCH, + 100, + ) + .unwrap(); + + db.upsert_file( + &origin, + Path::new("/b.flac"), + &VirtualPath::new("/Artist/Album/Track2.flac"), + &AudioMeta::default(), + UNIX_EPOCH, + 100, + ) + .unwrap(); + + db.upsert_file( + &origin, + Path::new("/c.flac"), + &VirtualPath::new("/Other/Track.flac"), + &AudioMeta::default(), + UNIX_EPOCH, + 100, + ) + .unwrap(); + + let files = db.get_files_by_prefix("/Artist/").unwrap(); + assert_eq!(files.len(), 2); + + let files = db.get_files_by_prefix("/Other/").unwrap(); + assert_eq!(files.len(), 1); + } } diff --git a/crates/musicfs-cache/src/lib.rs b/crates/musicfs-cache/src/lib.rs index b3f7724..6b0fa5e 100644 --- a/crates/musicfs-cache/src/lib.rs +++ b/crates/musicfs-cache/src/lib.rs @@ -13,5 +13,6 @@ pub use metadata::MetadataCache; pub use patterns::{AccessContext, AccessPattern, PatternError, PatternStore}; pub use prefetch::{PrefetchConfig, PrefetchEngine, PrefetchHandle}; pub use tree::{ - DirNode, FileNode, Inode, RefreshPolicy, TreeBuilder, VirtualNode, VirtualTree, ROOT_INODE, + DirNode, FileNode, Inode, RefreshPolicy, RenameError, TreeBuilder, VirtualNode, VirtualTree, + ROOT_INODE, }; diff --git a/crates/musicfs-cache/src/schema.sql b/crates/musicfs-cache/src/schema.sql index 510b32e..2a2a3a2 100644 --- a/crates/musicfs-cache/src/schema.sql +++ b/crates/musicfs-cache/src/schema.sql @@ -56,3 +56,11 @@ CREATE INDEX IF NOT EXISTS idx_files_real ON files(origin_id, real_path); CREATE INDEX IF NOT EXISTS idx_files_origin ON files(origin_id); CREATE INDEX IF NOT EXISTS idx_files_last_sync ON files(last_sync); CREATE INDEX IF NOT EXISTS idx_artwork_file ON artwork(file_id); + +CREATE TABLE IF NOT EXISTS directories ( + id INTEGER PRIMARY KEY, + path TEXT NOT NULL UNIQUE, + created_at INTEGER NOT NULL DEFAULT (strftime('%s', 'now')) +); + +CREATE INDEX IF NOT EXISTS idx_directories_path ON directories(path); diff --git a/crates/musicfs-cache/src/tree.rs b/crates/musicfs-cache/src/tree.rs index 6d22cc9..affef2f 100644 --- a/crates/musicfs-cache/src/tree.rs +++ b/crates/musicfs-cache/src/tree.rs @@ -310,6 +310,276 @@ impl VirtualTree { pub fn refresh_policy(&self) -> &RefreshPolicy { &self.refresh_policy } + + pub fn path_to_inode_iter(&self) -> impl Iterator { + self.path_to_inode.iter() + } + + pub fn mkdir(&mut self, path: &VirtualPath) -> std::result::Result { + if self.path_to_inode.contains_key(path) { + return Err(RenameError::TargetExists); + } + + let parent_path = std::path::Path::new(path.as_str()) + .parent() + .map(|p| { + let s = p.to_string_lossy(); + if s.is_empty() { + VirtualPath::new("/") + } else { + VirtualPath::new(s.into_owned()) + } + }) + .unwrap_or_else(|| VirtualPath::new("/")); + + let parent_inode = self + .path_to_inode + .get(&parent_path) + .copied() + .ok_or(RenameError::ParentNotFound)?; + + if !self + .nodes + .get(&parent_inode) + .map(|n| n.is_dir()) + .unwrap_or(false) + { + return Err(RenameError::ParentNotFound); + } + + let inode = self.alloc_inode(); + let name = std::path::Path::new(path.as_str()) + .file_name() + .map(|n| n.to_os_string()) + .unwrap_or_default(); + + let dir_node = DirNode { + inode, + parent: parent_inode, + name: name.clone(), + children: BTreeMap::new(), + mtime: SystemTime::now(), + }; + + self.nodes.insert(inode, VirtualNode::Directory(dir_node)); + self.path_to_inode.insert(path.clone(), inode); + + if let Some(VirtualNode::Directory(parent)) = self.nodes.get_mut(&parent_inode) { + parent.children.insert(name, inode); + } + + debug!(path = path.as_str(), inode, "created directory"); + Ok(inode) + } + + pub fn rename_file( + &mut self, + old_path: &VirtualPath, + new_path: &VirtualPath, + ) -> std::result::Result<(), RenameError> { + let old_inode = self + .path_to_inode + .get(old_path) + .copied() + .ok_or(RenameError::SourceNotFound)?; + + if self.path_to_inode.contains_key(new_path) { + return Err(RenameError::TargetExists); + } + + let node = self + .nodes + .get(&old_inode) + .ok_or(RenameError::SourceNotFound)?; + + if node.is_dir() { + return Err(RenameError::IsDirectory); + } + + let new_parent_path = std::path::Path::new(new_path.as_str()) + .parent() + .map(|p| { + let s = p.to_string_lossy(); + if s.is_empty() { + VirtualPath::new("/") + } else { + VirtualPath::new(s.into_owned()) + } + }) + .unwrap_or_else(|| VirtualPath::new("/")); + + let new_parent_inode = self + .path_to_inode + .get(&new_parent_path) + .copied() + .ok_or(RenameError::ParentNotFound)?; + + if !self + .nodes + .get(&new_parent_inode) + .map(|n| n.is_dir()) + .unwrap_or(false) + { + return Err(RenameError::ParentNotFound); + } + + self.path_to_inode.remove(old_path); + + let old_parent_path = std::path::Path::new(old_path.as_str()) + .parent() + .map(|p| VirtualPath::new(p.to_string_lossy().into_owned())) + .unwrap_or_else(|| VirtualPath::new("/")); + + if let Some(&old_parent_inode) = self.path_to_inode.get(&old_parent_path) { + if let Some(VirtualNode::Directory(dir)) = self.nodes.get_mut(&old_parent_inode) { + let old_name = std::path::Path::new(old_path.as_str()) + .file_name() + .map(|n| n.to_os_string()) + .unwrap_or_default(); + dir.children.remove(&old_name); + } + } + + let new_name = std::path::Path::new(new_path.as_str()) + .file_name() + .map(|n| n.to_os_string()) + .unwrap_or_default(); + + if let Some(VirtualNode::File(file)) = self.nodes.get_mut(&old_inode) { + file.name = new_name.clone(); + } + + if let Some(VirtualNode::Directory(dir)) = self.nodes.get_mut(&new_parent_inode) { + dir.children.insert(new_name, old_inode); + } + + self.path_to_inode.insert(new_path.clone(), old_inode); + + debug!( + old = old_path.as_str(), + new = new_path.as_str(), + inode = old_inode, + "renamed file" + ); + Ok(()) + } + + pub fn rename_directory( + &mut self, + old_path: &VirtualPath, + new_path: &VirtualPath, + ) -> std::result::Result { + let old_inode = self + .path_to_inode + .get(old_path) + .copied() + .ok_or(RenameError::SourceNotFound)?; + + if !self + .nodes + .get(&old_inode) + .map(|n| n.is_dir()) + .unwrap_or(false) + { + return Err(RenameError::NotDirectory); + } + + if self.path_to_inode.contains_key(new_path) { + return Err(RenameError::TargetExists); + } + + let new_parent_path = std::path::Path::new(new_path.as_str()) + .parent() + .map(|p| { + let s = p.to_string_lossy(); + if s.is_empty() { + VirtualPath::new("/") + } else { + VirtualPath::new(s.into_owned()) + } + }) + .unwrap_or_else(|| VirtualPath::new("/")); + + let new_parent_inode = self + .path_to_inode + .get(&new_parent_path) + .copied() + .ok_or(RenameError::ParentNotFound)?; + + if !self + .nodes + .get(&new_parent_inode) + .map(|n| n.is_dir()) + .unwrap_or(false) + { + return Err(RenameError::ParentNotFound); + } + + let old_prefix = old_path.as_str(); + let new_prefix = new_path.as_str(); + + let paths_to_update: Vec<(VirtualPath, Inode)> = self + .path_to_inode + .iter() + .filter(|(p, _)| p.as_str().starts_with(old_prefix)) + .map(|(p, &i)| (p.clone(), i)) + .collect(); + + let count = paths_to_update.len() as u64; + + for (old_p, inode) in paths_to_update { + self.path_to_inode.remove(&old_p); + let new_p_str = format!("{}{}", new_prefix, &old_p.as_str()[old_prefix.len()..]); + let new_p = VirtualPath::new(&new_p_str); + self.path_to_inode.insert(new_p, inode); + } + + let old_parent_path = std::path::Path::new(old_path.as_str()) + .parent() + .map(|p| VirtualPath::new(p.to_string_lossy().into_owned())) + .unwrap_or_else(|| VirtualPath::new("/")); + + if let Some(&old_parent_inode) = self.path_to_inode.get(&old_parent_path) { + if let Some(VirtualNode::Directory(dir)) = self.nodes.get_mut(&old_parent_inode) { + let old_name = std::path::Path::new(old_path.as_str()) + .file_name() + .map(|n| n.to_os_string()) + .unwrap_or_default(); + dir.children.remove(&old_name); + } + } + + let new_name = std::path::Path::new(new_path.as_str()) + .file_name() + .map(|n| n.to_os_string()) + .unwrap_or_default(); + + if let Some(VirtualNode::Directory(dir)) = self.nodes.get_mut(&old_inode) { + dir.name = new_name.clone(); + dir.parent = new_parent_inode; + } + + if let Some(VirtualNode::Directory(dir)) = self.nodes.get_mut(&new_parent_inode) { + dir.children.insert(new_name, old_inode); + } + + debug!( + old = old_path.as_str(), + new = new_path.as_str(), + count, + "renamed directory" + ); + Ok(count) + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum RenameError { + SourceNotFound, + TargetExists, + ParentNotFound, + IsDirectory, + NotDirectory, } impl Default for VirtualTree { @@ -445,4 +715,178 @@ mod tests { let tree = builder.build(); assert_eq!(tree.file_count(), 2); } + + #[test] + fn test_rename_file() { + let mut tree = VirtualTree::new(); + let old_path = VirtualPath::new("/Artist/Album/Track.flac"); + let new_path = VirtualPath::new("/Artist/Album/Renamed.flac"); + + tree.insert_file(&make_file_meta(1, old_path.as_str())); + + assert!(tree.get_by_path(&old_path).is_some()); + + tree.rename_file(&old_path, &new_path).unwrap(); + + assert!(tree.get_by_path(&old_path).is_none()); + assert!(tree.get_by_path(&new_path).is_some()); + } + + #[test] + fn test_rename_file_to_new_dir() { + let mut tree = VirtualTree::new(); + tree.insert_file(&make_file_meta(1, "/Artist/Album/Track.flac")); + + tree.mkdir(&VirtualPath::new("/New Artist")).unwrap(); + tree.mkdir(&VirtualPath::new("/New Artist/New Album")) + .unwrap(); + + let result = tree.rename_file( + &VirtualPath::new("/Artist/Album/Track.flac"), + &VirtualPath::new("/New Artist/New Album/Track.flac"), + ); + + assert!(result.is_ok()); + assert!(tree + .get_by_path(&VirtualPath::new("/New Artist/New Album/Track.flac")) + .is_some()); + } + + #[test] + fn test_rename_file_parent_not_found() { + let mut tree = VirtualTree::new(); + tree.insert_file(&make_file_meta(1, "/Artist/Album/Track.flac")); + + let result = tree.rename_file( + &VirtualPath::new("/Artist/Album/Track.flac"), + &VirtualPath::new("/NonExistent/Album/Track.flac"), + ); + + assert_eq!(result, Err(RenameError::ParentNotFound)); + } + + #[test] + fn test_rename_file_target_exists() { + let mut tree = VirtualTree::new(); + tree.insert_file(&make_file_meta(1, "/A/Track1.flac")); + tree.insert_file(&make_file_meta(2, "/A/Track2.flac")); + + let result = tree.rename_file( + &VirtualPath::new("/A/Track1.flac"), + &VirtualPath::new("/A/Track2.flac"), + ); + + assert_eq!(result, Err(RenameError::TargetExists)); + } + + #[test] + fn test_rename_file_source_not_found() { + let mut tree = VirtualTree::new(); + + let result = tree.rename_file( + &VirtualPath::new("/Nonexistent.flac"), + &VirtualPath::new("/New.flac"), + ); + + assert_eq!(result, Err(RenameError::SourceNotFound)); + } + + #[test] + fn test_rename_directory() { + 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")); + tree.insert_file(&make_file_meta(3, "/Artist/Other/Track3.flac")); + + let count = tree + .rename_directory( + &VirtualPath::new("/Artist"), + &VirtualPath::new("/Renamed Artist"), + ) + .unwrap(); + + assert_eq!(count, 6); + + assert!(tree.get_by_path(&VirtualPath::new("/Artist")).is_none()); + assert!(tree + .get_by_path(&VirtualPath::new("/Renamed Artist")) + .is_some()); + assert!(tree + .get_by_path(&VirtualPath::new("/Renamed Artist/Album/Track1.flac")) + .is_some()); + assert!(tree + .get_by_path(&VirtualPath::new("/Renamed Artist/Album/Track2.flac")) + .is_some()); + assert!(tree + .get_by_path(&VirtualPath::new("/Renamed Artist/Other/Track3.flac")) + .is_some()); + } + + #[test] + fn test_rename_directory_parent_not_found() { + let mut tree = VirtualTree::new(); + tree.insert_file(&make_file_meta(1, "/Artist/Album/Track.flac")); + + let result = tree.rename_directory( + &VirtualPath::new("/Artist"), + &VirtualPath::new("/NonExistent/Renamed"), + ); + + assert_eq!(result, Err(RenameError::ParentNotFound)); + } + + #[test] + fn test_rename_directory_not_directory() { + let mut tree = VirtualTree::new(); + tree.insert_file(&make_file_meta(1, "/Artist/Track.flac")); + + let result = tree.rename_directory( + &VirtualPath::new("/Artist/Track.flac"), + &VirtualPath::new("/New"), + ); + + assert_eq!(result, Err(RenameError::NotDirectory)); + } + + #[test] + fn test_mkdir() { + let mut tree = VirtualTree::new(); + + let inode = tree.mkdir(&VirtualPath::new("/NewDir")).unwrap(); + assert!(inode > ROOT_INODE); + assert!(tree.get_by_path(&VirtualPath::new("/NewDir")).is_some()); + assert!(tree + .get_by_path(&VirtualPath::new("/NewDir")) + .unwrap() + .is_dir()); + } + + #[test] + fn test_mkdir_nested() { + let mut tree = VirtualTree::new(); + + tree.mkdir(&VirtualPath::new("/A")).unwrap(); + tree.mkdir(&VirtualPath::new("/A/B")).unwrap(); + tree.mkdir(&VirtualPath::new("/A/B/C")).unwrap(); + + assert!(tree.get_by_path(&VirtualPath::new("/A/B/C")).is_some()); + } + + #[test] + fn test_mkdir_parent_not_found() { + let mut tree = VirtualTree::new(); + + let result = tree.mkdir(&VirtualPath::new("/A/B/C")); + assert_eq!(result, Err(RenameError::ParentNotFound)); + } + + #[test] + fn test_mkdir_already_exists() { + let mut tree = VirtualTree::new(); + + tree.mkdir(&VirtualPath::new("/Existing")).unwrap(); + let result = tree.mkdir(&VirtualPath::new("/Existing")); + + assert_eq!(result, Err(RenameError::TargetExists)); + } } diff --git a/crates/musicfs-cli/src/main.rs b/crates/musicfs-cli/src/main.rs index 2109f03..88c028d 100644 --- a/crates/musicfs-cli/src/main.rs +++ b/crates/musicfs-cli/src/main.rs @@ -1,6 +1,6 @@ use anyhow::{Context, Result}; use clap::{Parser, Subcommand}; -use musicfs_cache::TreeBuilder; +use musicfs_cache::{Database, TreeBuilder}; use musicfs_cas::{CasConfig, CasStore, ContentFetcher, FileReader}; use musicfs_core::{FileId, FileMeta, LoggingConfig, OriginId, RealPath, VirtualPath}; use musicfs_fuse::MusicFs; @@ -202,13 +202,17 @@ fn run_mount(config: musicfs_core::Config) -> Result<()> { let runtime = tokio::runtime::Runtime::new().context("Failed to create Tokio runtime")?; let handle = runtime.handle().clone(); - let (tree, reader) = runtime.block_on(async { + let (tree, reader, db) = runtime.block_on(async { info!(mountpoint = ?config.mount_point, "Mount configuration"); info!("Cache directory: {:?}", config.cache_dir); std::fs::create_dir_all(&config.cache_dir).context("Failed to create cache directory")?; std::fs::create_dir_all(&config.mount_point).context("Failed to create mountpoint")?; + let db_path = config.cache_dir.join("musicfs.db"); + let db = Arc::new(Database::open(&db_path).context("Failed to open metadata database")?); + info!("Metadata database opened at {:?}", db_path); + let cas_config = CasConfig { chunks_dir: config.cache_dir.join("chunks"), ..Default::default() @@ -258,9 +262,9 @@ fn run_mount(config: musicfs_core::Config) -> Result<()> { .unwrap(); let origin_path = PathBuf::from(path_str); info!("Scanning music files for origin {}...", origin_cfg.id); - let origin_files = scan_music_files(&origin_path, &origin_id).await?; + let origin_files = scan_music_files(&origin_path, &origin_id, db.as_ref()).await?; info!( - "Fount {} music files for origin {}", + "Found {} music files for origin {}", origin_files.len(), origin_cfg.id ); @@ -273,12 +277,27 @@ fn run_mount(config: musicfs_core::Config) -> Result<()> { builder.add_file(file); fetcher.register_file(file.clone()); } - let tree = Arc::new(RwLock::new(builder.build())); - info!("Virtual tree built"); + let mut tree = builder.build(); + + let dirs = db.list_directories().unwrap_or_default(); + for dir_path in &dirs { + if tree.get_by_path(dir_path).is_none() { + if let Err(e) = tree.mkdir(dir_path) { + debug!("Could not restore directory {:?}: {:?}", dir_path, e); + } + } + } + info!( + "Virtual tree built ({} files, {} user directories)", + tree.file_count(), + dirs.len() + ); + + let tree = Arc::new(RwLock::new(tree)); let reader = Arc::new(FileReader::with_fetcher(store, fetcher)); - Ok::<_, anyhow::Error>((tree, reader)) + Ok::<_, anyhow::Error>((tree, reader, db)) })?; check_stale_mount(&config.mount_point)?; @@ -288,7 +307,7 @@ fn run_mount(config: musicfs_core::Config) -> Result<()> { .context("Failed to acquire lock — is another instance running?")?; info!(lock_path = ?lock_path, "Lock acquired"); - let fs = MusicFs::with_reader(tree, reader, handle.clone()); + let fs = MusicFs::with_reader(tree, reader, handle.clone()).with_db(db); info!("Mounting filesystem at {:?}", config.mount_point); @@ -459,7 +478,11 @@ fn init_basic_logging(level: &str) { .init(); } -async fn scan_music_files(dir: &Path, origin_id: &OriginId) -> Result> { +async fn scan_music_files( + dir: &Path, + origin_id: &OriginId, + db: &Database, +) -> Result> { let parser = MetadataParser::new(); let mut files = Vec::new(); let mut file_id_counter = 1i64; @@ -469,6 +492,7 @@ async fn scan_music_files(dir: &Path, origin_id: &OriginId) -> Result, id_counter: &mut i64, ) -> Result<()> { @@ -493,11 +518,12 @@ async fn scan_dir_recursive( if metadata.is_dir() { Box::pin(scan_dir_recursive( - base, &path, origin_id, parser, files, id_counter, + base, &path, origin_id, parser, db, files, id_counter, )) .await?; } else if is_audio_file(&path) { let relative_path = path.strip_prefix(base).unwrap_or(&path); + let real_path_for_db = PathBuf::from("/").join(relative_path); let audio_meta = match parser.parse_file(&path) { Ok(meta) => Some(meta), @@ -507,15 +533,37 @@ async fn scan_dir_recursive( } }; - let virtual_path = build_virtual_path(&path, audio_meta.as_ref()); + let virtual_path = if let Ok(Some(stored_path)) = + db.get_file_by_real_path(origin_id, &real_path_for_db) + { + stored_path + } else { + build_virtual_path(&path, audio_meta.as_ref()) + }; + + let real_path = RealPath { + origin_id: origin_id.clone(), + path: real_path_for_db.clone(), + }; + + let file_id = db + .upsert_file( + origin_id, + &real_path.path, + &virtual_path, + audio_meta.as_ref().unwrap_or(&Default::default()), + metadata.modified().unwrap_or(SystemTime::UNIX_EPOCH), + metadata.len(), + ) + .unwrap_or_else(|e| { + debug!("Failed to upsert file to DB: {}", e); + FileId(*id_counter) + }); let file_meta = FileMeta { - id: FileId(*id_counter), + id: file_id, virtual_path, - real_path: RealPath { - origin_id: origin_id.clone(), - path: PathBuf::from("/").join(relative_path), - }, + real_path, size: metadata.len(), mtime: metadata.modified().unwrap_or(SystemTime::UNIX_EPOCH), content_hash: None, diff --git a/crates/musicfs-fuse/src/filesystem.rs b/crates/musicfs-fuse/src/filesystem.rs index 39daf31..cb06a3d 100644 --- a/crates/musicfs-fuse/src/filesystem.rs +++ b/crates/musicfs-fuse/src/filesystem.rs @@ -3,9 +3,9 @@ use fuser::{ FileAttr, FileType, Filesystem, ReplyAttr, ReplyData, ReplyDirectory, ReplyEntry, ReplyOpen, Request, }; -use musicfs_cache::{VirtualNode, VirtualTree, ROOT_INODE}; +use musicfs_cache::{Database, RenameError, VirtualNode, VirtualTree, ROOT_INODE}; use musicfs_cas::FileReader; -use musicfs_core::Result; +use musicfs_core::{Result, VirtualPath}; use parking_lot::RwLock; use std::collections::HashMap; use std::ffi::OsStr; @@ -22,6 +22,7 @@ const SEARCH_QUERY_INODE_BASE: u64 = 0xFFFF_FFFF_0000_0100; pub struct MusicFs { tree: Arc>, reader: Option>, + db: Option>, runtime_handle: Handle, search_ops: Option, query_inodes: RwLock>, @@ -36,6 +37,7 @@ impl MusicFs { Self { tree, reader: None, + db: None, runtime_handle, search_ops: None, query_inodes: RwLock::new(HashMap::new()), @@ -54,6 +56,7 @@ impl MusicFs { Self { tree, reader: Some(reader), + db: None, runtime_handle, search_ops: None, query_inodes: RwLock::new(HashMap::new()), @@ -64,11 +67,37 @@ impl MusicFs { } } + pub fn with_db(mut self, db: Arc) -> Self { + self.db = Some(db); + self + } + pub fn with_search(mut self, search_ops: SearchOps) -> Self { self.search_ops = Some(search_ops); self } + fn resolve_path(&self, parent_inode: u64, name: &OsStr) -> Option { + let tree = self.tree.read(); + let parent_path = self.inode_to_path_inner(&tree, parent_inode)?; + let name_str = name.to_string_lossy(); + let full_path = if parent_path == "/" { + format!("/{}", name_str) + } else { + format!("{}/{}", parent_path, name_str) + }; + Some(VirtualPath::new(full_path)) + } + + fn inode_to_path_inner(&self, tree: &VirtualTree, inode: u64) -> Option { + for (path, &ino) in tree.path_to_inode_iter() { + if ino == inode { + return Some(path.as_str().to_string()); + } + } + None + } + fn get_or_create_query_inode(&self, query: &str) -> u64 { let query_inodes = self.query_inodes.read(); if let Some(&inode) = query_inodes.get(query) { @@ -99,7 +128,6 @@ impl MusicFs { info!("Mounting MusicFS at {:?}", mountpoint); let options = vec![ - fuser::MountOption::RO, fuser::MountOption::FSName("musicfs".to_string()), fuser::MountOption::AutoUnmount, fuser::MountOption::AllowOther, @@ -114,7 +142,6 @@ impl MusicFs { info!("Mounting MusicFS at {:?}", mountpoint); let options = vec![ - fuser::MountOption::RO, fuser::MountOption::FSName("musicfs".to_string()), fuser::MountOption::AutoUnmount, fuser::MountOption::AllowOther, @@ -471,13 +498,52 @@ impl Filesystem for MusicFs { fn mkdir( &mut self, _req: &Request, - _parent: u64, - _name: &OsStr, + parent: u64, + name: &OsStr, _mode: u32, _umask: u32, reply: ReplyEntry, ) { - reply.error(libc::EROFS); + let path = match self.resolve_path(parent, name) { + Some(p) => p, + None => { + reply.error(libc::ENOENT); + return; + } + }; + + let mut tree = self.tree.write(); + match tree.mkdir(&path) { + Ok(inode) => { + if let Some(ref db) = self.db { + if let Err(e) = db.insert_directory(&path) { + warn!(error = %e, "failed to persist directory to database"); + } + } + let attr = FileAttr { + ino: inode, + size: 0, + blocks: 0, + atime: SystemTime::now(), + mtime: SystemTime::now(), + ctime: SystemTime::now(), + crtime: SystemTime::now(), + kind: FileType::Directory, + perm: 0o755, + nlink: 2, + uid: self.uid, + gid: self.gid, + rdev: 0, + blksize: BLOCK_SIZE, + flags: 0, + }; + debug!(path = %path.as_str(), inode, "mkdir successful"); + reply.entry(&TTL, &attr, 0); + } + Err(RenameError::TargetExists) => reply.error(libc::EEXIST), + Err(RenameError::ParentNotFound) => reply.error(libc::ENOENT), + Err(_) => reply.error(libc::EIO), + } } fn unlink(&mut self, _req: &Request, _parent: u64, _name: &OsStr, reply: fuser::ReplyEmpty) { @@ -491,14 +557,100 @@ impl Filesystem for MusicFs { fn rename( &mut self, _req: &Request, - _parent: u64, - _name: &OsStr, - _newparent: u64, - _newname: &OsStr, + parent: u64, + name: &OsStr, + newparent: u64, + newname: &OsStr, _flags: u32, reply: fuser::ReplyEmpty, ) { - reply.error(libc::EROFS); + let old_path = match self.resolve_path(parent, name) { + Some(p) => p, + None => { + reply.error(libc::ENOENT); + return; + } + }; + + let new_path = match self.resolve_path(newparent, newname) { + Some(p) => p, + None => { + reply.error(libc::ENOENT); + return; + } + }; + + if old_path.as_str() == new_path.as_str() { + reply.ok(); + return; + } + + let is_dir = { + let tree = self.tree.read(); + tree.get_by_path(&old_path) + .map(|n| n.is_dir()) + .unwrap_or(false) + }; + + let result = if is_dir { + let mut tree = self.tree.write(); + match tree.rename_directory(&old_path, &new_path) { + Ok(count) => { + if let Some(ref db) = self.db { + let old_prefix = if old_path.as_str().ends_with('/') { + old_path.as_str().to_string() + } else { + format!("{}/", old_path.as_str()) + }; + let new_prefix = if new_path.as_str().ends_with('/') { + new_path.as_str().to_string() + } else { + format!("{}/", new_path.as_str()) + }; + if let Err(e) = db.rename_directory(&old_prefix, &new_prefix) { + warn!(error = %e, "failed to persist file path rename to database"); + } + if let Err(e) = db.rename_directories(&old_prefix, &new_prefix) { + warn!(error = %e, "failed to persist directory rename to database"); + } + } + debug!(old = %old_path.as_str(), new = %new_path.as_str(), count, "directory renamed"); + Ok(()) + } + Err(e) => Err(e), + } + } else { + let file_id = { + let tree = self.tree.read(); + match tree.get_by_path(&old_path) { + Some(VirtualNode::File(f)) => Some(f.file_id), + _ => None, + } + }; + + let mut tree = self.tree.write(); + match tree.rename_file(&old_path, &new_path) { + Ok(()) => { + if let (Some(ref db), Some(id)) = (&self.db, file_id) { + if let Err(e) = db.update_virtual_path(id, &new_path) { + warn!(error = %e, "failed to persist file rename to database"); + } + } + debug!(old = %old_path.as_str(), new = %new_path.as_str(), "file renamed"); + Ok(()) + } + Err(e) => Err(e), + } + }; + + match result { + Ok(()) => reply.ok(), + Err(RenameError::SourceNotFound) => reply.error(libc::ENOENT), + Err(RenameError::TargetExists) => reply.error(libc::EEXIST), + Err(RenameError::ParentNotFound) => reply.error(libc::ENOENT), + Err(RenameError::IsDirectory) => reply.error(libc::EISDIR), + Err(RenameError::NotDirectory) => reply.error(libc::ENOTDIR), + } } fn create( diff --git a/docs/v2/features/mkdir.md b/docs/v2/features/mkdir.md new file mode 100644 index 0000000..39d6a39 --- /dev/null +++ b/docs/v2/features/mkdir.md @@ -0,0 +1,105 @@ +**Date**: 2026-05-17 +**Status**: Shipped + +# Feature: Create Directory (mkdir) + +## Overview + +MusicFS supports creating directories in the virtual filesystem. This enables organizing files into custom folder structures beyond the auto-generated metadata-based layout. + +## Behavior + +### Basic Usage + +```bash +mkdir "/mnt/music/New Artist" +mkdir "/mnt/music/New Artist/New Album" +``` + +- Creates empty directory at specified path +- Parent directory must exist +- Standard POSIX semantics + +### Nested Directories + +```bash +# This works (shell handles -p) +mkdir -p "/mnt/music/A/B/C" + +# Equivalent to: +mkdir "/mnt/music/A" +mkdir "/mnt/music/A/B" +mkdir "/mnt/music/A/B/C" +``` + +The `-p` flag is handled by the shell, which makes multiple `mkdir` syscalls. + +### Brace Expansion + +```bash +# Shell expands this to multiple mkdir calls +mkdir "/mnt/music/Artist/{Album1,Album2,Album3}" + +# Equivalent to: +mkdir "/mnt/music/Artist/Album1" +mkdir "/mnt/music/Artist/Album2" +mkdir "/mnt/music/Artist/Album3" +``` + +Brace expansion is shell functionality, not filesystem. + +## Error Codes + +| Condition | Error | +|-----------|-------| +| Parent doesn't exist | `ENOENT` | +| Path already exists | `EEXIST` | + +## Persistence + +**Empty directories persist across remounts.** + +- User-created directories are stored in the `directories` table +- On mount, directories are restored from database +- Directories survive even when empty + +## Use Cases + +### Organizing Downloads + +```bash +# Create structure +mkdir "/mnt/music/Unsorted" +mkdir "/mnt/music/Unsorted/2026" + +# Move untagged files +mv "/mnt/music/Unknown Artist/Unknown Album/"*.flac "/mnt/music/Unsorted/2026/" +``` + +### Custom Collections + +```bash +# Create playlist-like structure +mkdir "/mnt/music/_Playlists" +mkdir "/mnt/music/_Playlists/Road Trip" + +# Move tracks (they'll still be in original location too - wait, no they won't) +# Note: mv moves, doesn't copy +``` + +## Implementation + +| Component | File | +|-----------|------| +| Tree | `crates/musicfs-cache/src/tree.rs` | +| FUSE | `crates/musicfs-fuse/src/filesystem.rs` | + +### Key Functions + +- `VirtualTree::mkdir()` - Create directory node in tree +- `Filesystem::mkdir()` - FUSE operation handler + +## Limitations + +- **No permissions**: Mode/umask parameters are ignored (always 0755) +- **No ownership**: UID/GID set to mounting user diff --git a/docs/v2/features/mv.md b/docs/v2/features/mv.md new file mode 100644 index 0000000..ddd3671 --- /dev/null +++ b/docs/v2/features/mv.md @@ -0,0 +1,94 @@ +**Date**: 2026-05-17 +**Status**: Shipped + +# Feature: Move/Rename (mv) + +## Overview + +MusicFS supports moving and renaming files and directories within the virtual filesystem. Moves are persisted to the SQLite database and survive remounts. + +## Behavior + +### File Rename + +```bash +mv "/mnt/music/Artist/Album/old.flac" "/mnt/music/Artist/Album/new.flac" +``` + +- Renames file within same directory +- Updates `virtual_path` in database +- Original file on origin is unchanged + +### File Move + +```bash +mv "/mnt/music/Artist/Album/track.flac" "/mnt/music/Other Artist/Other Album/track.flac" +``` + +- Moves file to different directory +- **Requires target directory to exist** (use `mkdir` first) +- Returns `ENOENT` if target parent doesn't exist + +### Directory Rename + +```bash +mv "/mnt/music/Old Artist" "/mnt/music/New Artist" +``` + +- Renames directory and all descendants +- All files under the directory have their `virtual_path` updated in DB +- Single atomic operation + +### Directory Move + +```bash +mv "/mnt/music/Artist/Album" "/mnt/music/Other Artist/Album" +``` + +- Moves directory subtree to new parent +- **Requires target parent to exist** +- Returns `ENOENT` if target parent doesn't exist + +## Error Codes + +| Condition | Error | +|-----------|-------| +| Source doesn't exist | `ENOENT` | +| Target already exists | `EEXIST` | +| Target parent doesn't exist | `ENOENT` | +| Source is file but treated as dir | `EISDIR` | +| Source is dir but treated as file | `ENOTDIR` | + +## Persistence + +- File moves: `virtual_path` column updated in `files` table +- Directory moves: All matching `virtual_path` entries updated with new prefix +- User directories: Tracked in separate `directories` table +- Changes persist across unmount/remount cycles + +On mount, the CLI: +1. Scans origin files +2. For each file, checks DB for stored `virtual_path` (by origin_id + real_path) +3. Uses stored path if found, otherwise generates from metadata +4. Restores user-created directories from `directories` table + +## Limitations + +- **Read-only content**: File contents cannot be modified, only paths +- **No cross-origin moves**: All files remain on their original origin +- **No overwrite**: Moving to existing path fails (no implicit delete) + +## Implementation + +| Component | File | +|-----------|------| +| Database | `crates/musicfs-cache/src/db.rs` | +| Tree | `crates/musicfs-cache/src/tree.rs` | +| FUSE | `crates/musicfs-fuse/src/filesystem.rs` | + +### Key Functions + +- `Database::update_virtual_path()` - Update single file path +- `Database::rename_directory()` - Bulk update paths with prefix +- `VirtualTree::rename_file()` - Move file node in tree +- `VirtualTree::rename_directory()` - Move directory subtree