diff --git a/crates/musicfs-cache/src/db.rs b/crates/musicfs-cache/src/db.rs index b9b34e4..5f49b57 100644 --- a/crates/musicfs-cache/src/db.rs +++ b/crates/musicfs-cache/src/db.rs @@ -145,9 +145,19 @@ impl Database { .map_err(|e| Error::Database(format!("upsert failed: {}", e)))?; let id = conn.last_insert_rowid(); - debug!(id, vpath = virtual_path.as_str(), "Upserted file"); + let file_id = if id == 0 { + conn.query_row( + "SELECT id FROM files WHERE origin_id = ?1 AND real_path = ?2", + params![&origin_id.0, real_path.to_string_lossy()], + |row| row.get::<_, i64>(0), + ) + .map_err(|e| Error::Database(format!("failed to get file id after upsert: {}", e)))? + } else { + id + }; + debug!(id = file_id, vpath = virtual_path.as_str(), "Upserted file"); - Ok(FileId(id)) + Ok(FileId(file_id)) } pub fn get_file_by_virtual_path(&self, path: &VirtualPath) -> Result> { @@ -424,6 +434,140 @@ impl Database { .optional() .map_err(|e| Error::Database(format!("query failed: {}", e))) } + + pub fn mark_trashed(&self, id: FileId, original_path: &VirtualPath) -> Result<()> { + let conn = self.conn.lock().unwrap(); + let rows = conn + .execute( + "UPDATE files SET trashed = 1, original_path = ?1, trashed_at = strftime('%s', 'now') WHERE id = ?2", + params![original_path.as_str(), id.0], + ) + .map_err(|e| Error::Database(format!("mark_trashed failed: {}", e)))?; + + if rows == 0 { + return Err(Error::FileNotFound(format!("file id {} not found", id.0))); + } + debug!( + id = id.0, + original_path = original_path.as_str(), + "marked file as trashed" + ); + Ok(()) + } + + pub fn unmark_trashed(&self, id: FileId) -> Result<()> { + let conn = self.conn.lock().unwrap(); + conn.execute( + "UPDATE files SET trashed = 0, original_path = NULL, trashed_at = NULL WHERE id = ?1", + params![id.0], + ) + .map_err(|e| Error::Database(format!("unmark_trashed failed: {}", e)))?; + debug!(id = id.0, "unmarked file as trashed"); + Ok(()) + } + + pub fn list_trashed(&self, filter: &TrashedFilter) -> Result> { + let conn = self.conn.lock().unwrap(); + + let mut sql = String::from( + "SELECT id, virtual_path, original_path, trashed_at, origin_id FROM files WHERE trashed = 1", + ); + let mut params_vec: Vec> = Vec::new(); + + if let Some(ref origin) = filter.origin_id { + sql.push_str(" AND origin_id = ?"); + params_vec.push(Box::new(origin.0.clone())); + } + + if let Some(ref prefix) = filter.path_prefix { + sql.push_str(" AND original_path LIKE ?"); + params_vec.push(Box::new(format!("{}%", prefix))); + } + + if let Some(since) = filter.since { + let cutoff = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_secs() as i64 + - since.as_secs() as i64; + sql.push_str(" AND trashed_at >= ?"); + params_vec.push(Box::new(cutoff)); + } + + sql.push_str(" ORDER BY trashed_at DESC"); + + let mut stmt = conn + .prepare(&sql) + .map_err(|e| Error::Database(format!("prepare failed: {}", e)))?; + + let params_refs: Vec<&dyn rusqlite::ToSql> = + params_vec.iter().map(|p| p.as_ref()).collect(); + + let files: Vec = stmt + .query_map(params_refs.as_slice(), |row| { + Ok(TrashedFile { + file_id: FileId(row.get(0)?), + current_path: VirtualPath::new(row.get::<_, String>(1)?), + original_path: VirtualPath::new(row.get::<_, String>(2)?), + trashed_at: row.get(3)?, + origin_id: OriginId(row.get(4)?), + }) + }) + .map_err(|e| Error::Database(format!("query failed: {}", e)))? + .filter_map(|r| r.ok()) + .collect(); + + Ok(files) + } + + pub fn get_trashed_by_prefix(&self, prefix: &str) -> Result> { + self.list_trashed(&TrashedFilter { + path_prefix: Some(prefix.to_string()), + ..Default::default() + }) + } + + pub fn is_trashed(&self, path: &VirtualPath) -> Result { + let conn = self.conn.lock().unwrap(); + let count: i64 = conn + .query_row( + "SELECT COUNT(*) FROM files WHERE virtual_path = ?1 AND trashed = 1", + params![path.as_str()], + |row| row.get(0), + ) + .map_err(|e| Error::Database(format!("is_trashed query failed: {}", e)))?; + Ok(count > 0) + } + + pub fn purge_trashed(&self, filter: &TrashedFilter) -> Result { + let trashed = self.list_trashed(filter)?; + let count = trashed.len() as u64; + + let conn = self.conn.lock().unwrap(); + for file in trashed { + conn.execute("DELETE FROM files WHERE id = ?1", params![file.file_id.0]) + .map_err(|e| Error::Database(format!("purge delete failed: {}", e)))?; + } + + debug!(count, "purged trashed files"); + Ok(count) + } +} + +#[derive(Debug, Clone)] +pub struct TrashedFile { + pub file_id: FileId, + pub current_path: VirtualPath, + pub original_path: VirtualPath, + pub trashed_at: i64, + pub origin_id: OriginId, +} + +#[derive(Debug, Clone, Default)] +pub struct TrashedFilter { + pub origin_id: Option, + pub path_prefix: Option, + pub since: Option, } fn parse_audio_format(s: &str) -> AudioFormat { @@ -782,4 +926,124 @@ mod tests { let files = db.get_files_by_prefix("/Other/").unwrap(); assert_eq!(files.len(), 1); } + + #[test] + fn test_mark_trashed() { + let db = Database::open_memory().unwrap(); + + let id = db + .upsert_file( + &OriginId::from("local"), + Path::new("/test.flac"), + &VirtualPath::new("/Artist/Track.flac"), + &AudioMeta::default(), + UNIX_EPOCH, + 100, + ) + .unwrap(); + + db.mark_trashed(id, &VirtualPath::new("/Artist/Track.flac")) + .unwrap(); + + let trashed = db.list_trashed(&TrashedFilter::default()).unwrap(); + assert_eq!(trashed.len(), 1); + assert_eq!(trashed[0].original_path.as_str(), "/Artist/Track.flac"); + } + + #[test] + fn test_unmark_trashed() { + let db = Database::open_memory().unwrap(); + + let id = db + .upsert_file( + &OriginId::from("local"), + Path::new("/test.flac"), + &VirtualPath::new("/Artist/Track.flac"), + &AudioMeta::default(), + UNIX_EPOCH, + 100, + ) + .unwrap(); + + db.mark_trashed(id, &VirtualPath::new("/Artist/Track.flac")) + .unwrap(); + assert_eq!(db.list_trashed(&TrashedFilter::default()).unwrap().len(), 1); + + db.unmark_trashed(id).unwrap(); + assert_eq!(db.list_trashed(&TrashedFilter::default()).unwrap().len(), 0); + } + + #[test] + fn test_list_trashed_with_filter() { + let db = Database::open_memory().unwrap(); + let origin1 = OriginId::from("local1"); + let origin2 = OriginId::from("local2"); + + let id1 = db + .upsert_file( + &origin1, + Path::new("/a.flac"), + &VirtualPath::new("/Artist1/Track.flac"), + &AudioMeta::default(), + UNIX_EPOCH, + 100, + ) + .unwrap(); + + let id2 = db + .upsert_file( + &origin2, + Path::new("/b.flac"), + &VirtualPath::new("/Artist2/Track.flac"), + &AudioMeta::default(), + UNIX_EPOCH, + 100, + ) + .unwrap(); + + db.mark_trashed(id1, &VirtualPath::new("/Artist1/Track.flac")) + .unwrap(); + db.mark_trashed(id2, &VirtualPath::new("/Artist2/Track.flac")) + .unwrap(); + + let all = db.list_trashed(&TrashedFilter::default()).unwrap(); + assert_eq!(all.len(), 2); + + let filtered = db + .list_trashed(&TrashedFilter { + origin_id: Some(origin1.clone()), + ..Default::default() + }) + .unwrap(); + assert_eq!(filtered.len(), 1); + assert_eq!(filtered[0].origin_id, origin1); + + let by_path = db.get_trashed_by_prefix("/Artist1").unwrap(); + assert_eq!(by_path.len(), 1); + } + + #[test] + fn test_purge_trashed() { + let db = Database::open_memory().unwrap(); + + let id = db + .upsert_file( + &OriginId::from("local"), + Path::new("/test.flac"), + &VirtualPath::new("/Track.flac"), + &AudioMeta::default(), + UNIX_EPOCH, + 100, + ) + .unwrap(); + + db.mark_trashed(id, &VirtualPath::new("/Track.flac")) + .unwrap(); + assert_eq!(db.list_trashed(&TrashedFilter::default()).unwrap().len(), 1); + + let count = db.purge_trashed(&TrashedFilter::default()).unwrap(); + assert_eq!(count, 1); + assert_eq!(db.list_trashed(&TrashedFilter::default()).unwrap().len(), 0); + assert_eq!(db.file_count().unwrap(), 0); + } } diff --git a/crates/musicfs-cache/src/lib.rs b/crates/musicfs-cache/src/lib.rs index 6b0fa5e..a43d578 100644 --- a/crates/musicfs-cache/src/lib.rs +++ b/crates/musicfs-cache/src/lib.rs @@ -7,12 +7,12 @@ mod prefetch; mod tree; pub use artwork::{ArtworkCache, ArtworkError, CachedArtwork}; -pub use db::Database; +pub use db::{Database, TrashedFile, TrashedFilter}; pub use eviction::{EvictionError, EvictionPolicy, LruEviction}; pub use metadata::MetadataCache; pub use patterns::{AccessContext, AccessPattern, PatternError, PatternStore}; pub use prefetch::{PrefetchConfig, PrefetchEngine, PrefetchHandle}; pub use tree::{ - DirNode, FileNode, Inode, RefreshPolicy, RenameError, TreeBuilder, VirtualNode, VirtualTree, - ROOT_INODE, + DirNode, FileNode, Inode, RefreshPolicy, RemoveError, RenameError, TreeBuilder, VirtualNode, + VirtualTree, ROOT_INODE, }; diff --git a/crates/musicfs-cache/src/schema.sql b/crates/musicfs-cache/src/schema.sql index 2a2a3a2..dc87f0f 100644 --- a/crates/musicfs-cache/src/schema.sql +++ b/crates/musicfs-cache/src/schema.sql @@ -27,6 +27,10 @@ CREATE TABLE IF NOT EXISTS files ( chunk_manifest BLOB, last_sync INTEGER NOT NULL DEFAULT (strftime('%s', 'now')), + trashed INTEGER NOT NULL DEFAULT 0, + original_path TEXT, + trashed_at INTEGER, + UNIQUE(origin_id, real_path) ); @@ -64,3 +68,4 @@ CREATE TABLE IF NOT EXISTS directories ( ); CREATE INDEX IF NOT EXISTS idx_directories_path ON directories(path); +CREATE INDEX IF NOT EXISTS idx_files_trashed ON files(trashed) WHERE trashed = 1; diff --git a/crates/musicfs-cache/src/tree.rs b/crates/musicfs-cache/src/tree.rs index affef2f..13ac851 100644 --- a/crates/musicfs-cache/src/tree.rs +++ b/crates/musicfs-cache/src/tree.rs @@ -571,6 +571,207 @@ impl VirtualTree { ); Ok(count) } + + pub fn is_trash_path(path: &VirtualPath) -> bool { + path.as_str().starts_with("/.trash") || path.as_str() == "/.trash" + } + + pub fn ensure_trash_dir(&mut self) -> Inode { + let trash_path = VirtualPath::new("/.trash"); + if let Some(&inode) = self.path_to_inode.get(&trash_path) { + return inode; + } + + let inode = self.alloc_inode(); + let dir_node = DirNode { + inode, + parent: ROOT_INODE, + name: OsString::from(".trash"), + children: BTreeMap::new(), + mtime: SystemTime::now(), + }; + + self.nodes.insert(inode, VirtualNode::Directory(dir_node)); + self.path_to_inode.insert(trash_path, inode); + + if let Some(VirtualNode::Directory(root)) = self.nodes.get_mut(&ROOT_INODE) { + root.children.insert(OsString::from(".trash"), inode); + } + + debug!(inode, "created .trash directory"); + inode + } + + pub fn mkdir_p(&mut self, path: &VirtualPath) -> std::result::Result { + if let Some(&existing) = self.path_to_inode.get(path) { + if self + .nodes + .get(&existing) + .map(|n| n.is_dir()) + .unwrap_or(false) + { + return Ok(existing); + } + return Err(RenameError::TargetExists); + } + + let components: Vec<&str> = path + .as_str() + .trim_start_matches('/') + .split('/') + .filter(|s| !s.is_empty()) + .collect(); + + let mut current_inode = ROOT_INODE; + let mut current_path = String::from("/"); + + for component in &components { + if !current_path.ends_with('/') { + current_path.push('/'); + } + 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; + } + } + + Ok(current_inode) + } + + pub fn remove_directory(&mut self, path: &VirtualPath) -> std::result::Result<(), RemoveError> { + let inode = self + .path_to_inode + .get(path) + .copied() + .ok_or(RemoveError::NotFound)?; + + let node = self.nodes.get(&inode).ok_or(RemoveError::NotFound)?; + + match node { + VirtualNode::File(_) => return Err(RemoveError::NotDirectory), + VirtualNode::Directory(dir) => { + if !dir.children.is_empty() { + return Err(RemoveError::NotEmpty); + } + } + } + + 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(parent)) = self.nodes.get_mut(&parent_inode) { + let name = std::path::Path::new(path.as_str()) + .file_name() + .map(|n| n.to_os_string()) + .unwrap_or_default(); + parent.children.remove(&name); + } + } + + self.path_to_inode.remove(path); + self.nodes.remove(&inode); + + debug!(path = path.as_str(), inode, "removed directory"); + Ok(()) + } + + pub fn remove_directory_recursive( + &mut self, + path: &VirtualPath, + ) -> std::result::Result, RemoveError> { + let inode = self + .path_to_inode + .get(path) + .copied() + .ok_or(RemoveError::NotFound)?; + + if !self.nodes.get(&inode).map(|n| n.is_dir()).unwrap_or(false) { + return Err(RemoveError::NotDirectory); + } + + let prefix = path.as_str(); + let paths_to_remove: Vec<(VirtualPath, Inode)> = self + .path_to_inode + .iter() + .filter(|(p, _)| p.as_str().starts_with(prefix)) + .map(|(p, &i)| (p.clone(), i)) + .collect(); + + let mut removed_files = Vec::new(); + + for (p, ino) in &paths_to_remove { + if let Some(VirtualNode::File(f)) = self.nodes.get(ino) { + removed_files.push(f.file_id); + } + self.path_to_inode.remove(p); + self.nodes.remove(ino); + } + + 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(parent)) = self.nodes.get_mut(&parent_inode) { + let name = std::path::Path::new(path.as_str()) + .file_name() + .map(|n| n.to_os_string()) + .unwrap_or_default(); + parent.children.remove(&name); + } + } + + debug!( + path = path.as_str(), + file_count = removed_files.len(), + "removed directory recursively" + ); + Ok(removed_files) + } + + pub fn is_directory_empty(&self, path: &VirtualPath) -> Option { + let inode = self.path_to_inode.get(path)?; + if let Some(VirtualNode::Directory(dir)) = self.nodes.get(inode) { + Some(dir.children.is_empty()) + } else { + None + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum RemoveError { + NotFound, + NotEmpty, + NotDirectory, } #[derive(Debug, Clone, PartialEq, Eq)] @@ -889,4 +1090,135 @@ mod tests { assert_eq!(result, Err(RenameError::TargetExists)); } + + #[test] + fn test_is_trash_path() { + assert!(VirtualTree::is_trash_path(&VirtualPath::new("/.trash"))); + assert!(VirtualTree::is_trash_path(&VirtualPath::new( + "/.trash/Artist/Track.flac" + ))); + assert!(!VirtualTree::is_trash_path(&VirtualPath::new( + "/Artist/Track.flac" + ))); + assert!(!VirtualTree::is_trash_path(&VirtualPath::new( + "/trash/Artist/Track.flac" + ))); + } + + #[test] + fn test_ensure_trash_dir() { + let mut tree = VirtualTree::new(); + + assert!(tree.get_by_path(&VirtualPath::new("/.trash")).is_none()); + + let inode = tree.ensure_trash_dir(); + assert!(inode > ROOT_INODE); + + let node = tree.get_by_path(&VirtualPath::new("/.trash")); + assert!(node.is_some()); + assert!(node.unwrap().is_dir()); + + let inode2 = tree.ensure_trash_dir(); + assert_eq!(inode, inode2); + } + + #[test] + fn test_mkdir_p() { + let mut tree = VirtualTree::new(); + + tree.mkdir_p(&VirtualPath::new("/A/B/C/D")).unwrap(); + + assert!(tree.get_by_path(&VirtualPath::new("/A")).is_some()); + assert!(tree.get_by_path(&VirtualPath::new("/A/B")).is_some()); + assert!(tree.get_by_path(&VirtualPath::new("/A/B/C")).is_some()); + assert!(tree.get_by_path(&VirtualPath::new("/A/B/C/D")).is_some()); + } + + #[test] + fn test_mkdir_p_partial_exists() { + let mut tree = VirtualTree::new(); + + tree.mkdir(&VirtualPath::new("/A")).unwrap(); + tree.mkdir(&VirtualPath::new("/A/B")).unwrap(); + + tree.mkdir_p(&VirtualPath::new("/A/B/C/D")).unwrap(); + + assert!(tree.get_by_path(&VirtualPath::new("/A/B/C")).is_some()); + assert!(tree.get_by_path(&VirtualPath::new("/A/B/C/D")).is_some()); + } + + #[test] + fn test_remove_directory_empty() { + let mut tree = VirtualTree::new(); + + tree.mkdir(&VirtualPath::new("/EmptyDir")).unwrap(); + assert!(tree.get_by_path(&VirtualPath::new("/EmptyDir")).is_some()); + + tree.remove_directory(&VirtualPath::new("/EmptyDir")) + .unwrap(); + assert!(tree.get_by_path(&VirtualPath::new("/EmptyDir")).is_none()); + } + + #[test] + fn test_remove_directory_not_empty() { + let mut tree = VirtualTree::new(); + tree.insert_file(&make_file_meta(1, "/Artist/Track.flac")); + + let result = tree.remove_directory(&VirtualPath::new("/Artist")); + assert_eq!(result, Err(RemoveError::NotEmpty)); + } + + #[test] + fn test_remove_directory_not_found() { + let mut tree = VirtualTree::new(); + + let result = tree.remove_directory(&VirtualPath::new("/NonExistent")); + assert_eq!(result, Err(RemoveError::NotFound)); + } + + #[test] + fn test_remove_directory_is_file() { + let mut tree = VirtualTree::new(); + tree.insert_file(&make_file_meta(1, "/Track.flac")); + + let result = tree.remove_directory(&VirtualPath::new("/Track.flac")); + assert_eq!(result, Err(RemoveError::NotDirectory)); + } + + #[test] + fn test_remove_directory_recursive() { + 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 removed = tree + .remove_directory_recursive(&VirtualPath::new("/Artist")) + .unwrap(); + + assert_eq!(removed.len(), 3); + assert!(tree.get_by_path(&VirtualPath::new("/Artist")).is_none()); + } + + #[test] + fn test_is_directory_empty() { + let mut tree = VirtualTree::new(); + + tree.mkdir(&VirtualPath::new("/Empty")).unwrap(); + assert_eq!( + tree.is_directory_empty(&VirtualPath::new("/Empty")), + Some(true) + ); + + tree.insert_file(&make_file_meta(1, "/NonEmpty/Track.flac")); + assert_eq!( + tree.is_directory_empty(&VirtualPath::new("/NonEmpty")), + Some(false) + ); + + assert_eq!( + tree.is_directory_empty(&VirtualPath::new("/NonExistent")), + None + ); + } } diff --git a/crates/musicfs-cli/src/main.rs b/crates/musicfs-cli/src/main.rs index 88c028d..7d44233 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::{Database, TreeBuilder}; +use musicfs_cache::{Database, RenameError, TrashedFilter, TreeBuilder, VirtualTree}; use musicfs_cas::{CasConfig, CasStore, ContentFetcher, FileReader}; use musicfs_core::{FileId, FileMeta, LoggingConfig, OriginId, RealPath, VirtualPath}; use musicfs_fuse::MusicFs; @@ -66,6 +66,14 @@ enum Commands { #[arg(short, long, default_value = "30")] timeout: u32, }, + Trash { + #[arg(short, long, help = "Config file path")] + config: Option, + #[arg(short = 'd', long, help = "Cache directory")] + cache_dir: Option, + #[command(subcommand)] + command: TrashCommands, + }, } #[derive(Subcommand)] @@ -88,6 +96,30 @@ enum OriginCommands { Rescan { origin_id: String }, } +#[derive(Subcommand)] +enum TrashCommands { + List { + #[arg(long, help = "Filter by origin")] + origin: Option, + #[arg(long, help = "Show files deleted within duration (e.g., 7d, 24h)")] + since: Option, + #[arg(long, help = "Filter by path prefix")] + path: Option, + }, + Restore { + #[arg(help = "Path to restore (restores folder recursively)")] + path: Option, + #[arg(long, help = "Restore all deleted files")] + all: bool, + }, + Empty { + #[arg(long, help = "Delete files older than duration (e.g., 30d)")] + older_than: Option, + #[arg(long, help = "Delete files matching pattern")] + pattern: Option, + }, +} + struct LockFile { _file: File, } @@ -195,6 +227,14 @@ fn main() -> Result<()> { init_basic_logging(&cli.log_level); run_shutdown(graceful, timeout) } + Commands::Trash { + config, + cache_dir, + command, + } => { + init_basic_logging(&cli.log_level); + run_trash(config, cache_dir, command) + } } } @@ -307,6 +347,14 @@ 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 pid_path = config.cache_dir.join("musicfs.pid"); + std::fs::write(&pid_path, std::process::id().to_string()) + .context("Failed to write PID file")?; + info!(pid_path = ?pid_path, "PID file written"); + + let tree_for_restore = tree.clone(); + let db_for_restore = db.clone(); + let fs = MusicFs::with_reader(tree, reader, handle.clone()).with_db(db); info!("Mounting filesystem at {:?}", config.mount_point); @@ -329,13 +377,22 @@ fn run_mount(config: musicfs_core::Config) -> Result<()> { let mut sigterm = tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate())?; let mut sigint = tokio::signal::unix::signal(tokio::signal::unix::SignalKind::interrupt())?; + let mut sighup = tokio::signal::unix::signal(tokio::signal::unix::SignalKind::hangup())?; - tokio::select! { - _ = sigterm.recv() => { - info!("Received SIGTERM, shutting down"); - } - _ = sigint.recv() => { - info!("Received SIGINT, shutting down"); + loop { + tokio::select! { + _ = sigterm.recv() => { + info!("Received SIGTERM, shutting down"); + break; + } + _ = sigint.recv() => { + info!("Received SIGINT, shutting down"); + break; + } + _ = sighup.recv() => { + info!("Received SIGHUP, processing pending restores"); + process_pending_restores(&tree_for_restore, &db_for_restore); + } } } @@ -355,6 +412,8 @@ fn run_mount(config: musicfs_core::Config) -> Result<()> { } info!("Unmounting filesystem"); drop(session); + + let _ = std::fs::remove_file(&pid_path); info!("Shutdown complete"); Ok(()) @@ -422,6 +481,254 @@ fn run_shutdown(graceful: bool, timeout: u32) -> Result<()> { Ok(()) } +fn run_trash( + config: Option, + cache_dir: Option, + command: TrashCommands, +) -> Result<()> { + let cache_dir = if let Some(dir) = cache_dir { + dir + } else if let Some(cfg_path) = config { + let content = std::fs::read_to_string(&cfg_path).context("Failed to read config file")?; + let config: Value = toml::from_str(&content).context("Failed to parse config file")?; + PathBuf::from( + config + .get("cache_dir") + .and_then(|v| v.as_str()) + .context("cache_dir not found in config")?, + ) + } else { + return Err(anyhow::anyhow!( + "Either --config or --cache-dir must be provided" + )); + }; + + let db_path = cache_dir.join("musicfs.db"); + let db = Database::open(&db_path).context("Failed to open database")?; + + match command { + TrashCommands::List { + origin, + since, + path, + } => { + let filter = TrashedFilter { + origin_id: origin.map(|s| OriginId::from(s.as_str())), + path_prefix: path, + since: since.and_then(|s| parse_duration(&s)), + }; + + let trashed = db.list_trashed(&filter)?; + + if trashed.is_empty() { + println!("No deleted files found."); + return Ok(()); + } + + println!("{:<6} {:<20} PATH", "IDX", "DELETED"); + println!("{}", "-".repeat(80)); + + for (i, file) in trashed.iter().enumerate() { + let ago = format_time_ago(file.trashed_at); + println!("{:<6} {:<20} {}", i, ago, file.original_path.as_str()); + } + + println!("\nTotal: {} deleted files", trashed.len()); + } + TrashCommands::Restore { path, all } => { + let trashed = if all { + db.list_trashed(&TrashedFilter::default())? + } else if let Some(ref p) = path { + db.get_trashed_by_prefix(p)? + } else { + return Err(anyhow::anyhow!("Either --all or a path must be provided")); + }; + + if trashed.is_empty() { + println!("No files to restore."); + return Ok(()); + } + + let restore_file = cache_dir.join("pending_restore.txt"); + let paths: Vec = trashed + .iter() + .map(|f| f.original_path.as_str().to_string()) + .collect(); + std::fs::write(&restore_file, paths.join("\n"))?; + + let pid_path = cache_dir.join("musicfs.pid"); + if pid_path.exists() { + let pid_str = std::fs::read_to_string(&pid_path)?; + let pid: i32 = pid_str.trim().parse().context("Invalid PID in pid file")?; + + std::env::set_var("MUSICFS_RESTORE_FILE", &restore_file); + + unsafe { + libc::kill(pid, libc::SIGHUP); + } + println!("Restore signal sent for {} files.", trashed.len()); + println!("Files will appear at their original locations."); + } else { + println!( + "Daemon not running. Marked {} files for restore.", + trashed.len() + ); + println!("Start the daemon to complete restore, or restore manually with 'mv'."); + } + } + TrashCommands::Empty { + older_than, + pattern, + } => { + let filter = TrashedFilter { + since: older_than.and_then(|s| parse_duration(&s)), + path_prefix: pattern, + ..Default::default() + }; + + let count = db.purge_trashed(&filter)?; + println!("Permanently deleted {} files from trash.", count); + } + } + + Ok(()) +} + +fn parse_duration(s: &str) -> Option { + let s = s.trim(); + if s.is_empty() { + return None; + } + + let (num_str, unit) = if s.ends_with('d') { + (&s[..s.len() - 1], 'd') + } else if s.ends_with('h') { + (&s[..s.len() - 1], 'h') + } else if s.ends_with('m') { + (&s[..s.len() - 1], 'm') + } else if s.ends_with('s') { + (&s[..s.len() - 1], 's') + } else { + return None; + }; + + let num: u64 = num_str.parse().ok()?; + let secs = match unit { + 'd' => num * 86400, + 'h' => num * 3600, + 'm' => num * 60, + 's' => num, + _ => return None, + }; + + Some(std::time::Duration::from_secs(secs)) +} + +fn format_time_ago(timestamp: i64) -> String { + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_secs() as i64; + + let diff = now - timestamp; + if diff < 60 { + format!("{}s ago", diff) + } else if diff < 3600 { + format!("{}m ago", diff / 60) + } else if diff < 86400 { + format!("{}h ago", diff / 3600) + } else { + format!("{}d ago", diff / 86400) + } +} + +fn process_pending_restores(tree: &Arc>, db: &Arc) { + let restore_file = match std::env::var("MUSICFS_RESTORE_FILE") { + Ok(path) => PathBuf::from(path), + Err(_) => { + debug!("MUSICFS_RESTORE_FILE not set, no restores to process"); + return; + } + }; + + let restore_paths: Vec = match std::fs::read_to_string(&restore_file) { + Ok(content) => content.lines().map(|s| s.to_string()).collect(), + Err(e) => { + warn!(error = %e, path = ?restore_file, "failed to read restore file"); + return; + } + }; + + if restore_paths.is_empty() { + debug!("no paths to restore"); + return; + } + + let trashed = match db.list_trashed(&TrashedFilter::default()) { + Ok(files) => files, + Err(e) => { + warn!(error = %e, "failed to list trashed files"); + return; + } + }; + + let mut restored = 0; + for original_path_str in &restore_paths { + let matching: Vec<_> = trashed + .iter() + .filter(|f| { + f.original_path.as_str() == original_path_str + || f.original_path + .as_str() + .starts_with(&format!("{}/", original_path_str)) + }) + .collect(); + + for file in matching { + let parent_path = std::path::Path::new(file.original_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 mut tree_guard = tree.write(); + + if let Err(e) = tree_guard.mkdir_p(&parent_path) { + if !matches!(e, RenameError::TargetExists) { + warn!(error = ?e, path = %parent_path.as_str(), "failed to create parent for restore"); + continue; + } + } + + if let Err(e) = tree_guard.rename_file(&file.current_path, &file.original_path) { + warn!(error = ?e, from = %file.current_path.as_str(), to = %file.original_path.as_str(), "failed to restore file"); + continue; + } + + drop(tree_guard); + + if let Err(e) = db.update_virtual_path(file.file_id, &file.original_path) { + warn!(error = %e, "failed to update virtual path after restore"); + } + if let Err(e) = db.unmark_trashed(file.file_id) { + warn!(error = %e, "failed to unmark trashed after restore"); + } + + restored += 1; + info!(path = %file.original_path.as_str(), "restored file from trash"); + } + } + + let _ = std::fs::remove_file(&restore_file); + info!(count = restored, "restore complete"); +} + fn init_logging(config: &LoggingConfig) -> Result { std::fs::create_dir_all(&config.log_dir)?; diff --git a/crates/musicfs-fuse/src/filesystem.rs b/crates/musicfs-fuse/src/filesystem.rs index cb06a3d..b048820 100644 --- a/crates/musicfs-fuse/src/filesystem.rs +++ b/crates/musicfs-fuse/src/filesystem.rs @@ -3,7 +3,7 @@ use fuser::{ FileAttr, FileType, Filesystem, ReplyAttr, ReplyData, ReplyDirectory, ReplyEntry, ReplyOpen, Request, }; -use musicfs_cache::{Database, RenameError, VirtualNode, VirtualTree, ROOT_INODE}; +use musicfs_cache::{Database, RemoveError, RenameError, VirtualNode, VirtualTree, ROOT_INODE}; use musicfs_cas::FileReader; use musicfs_core::{Result, VirtualPath}; use parking_lot::RwLock; @@ -546,12 +546,115 @@ impl Filesystem for MusicFs { } } - fn unlink(&mut self, _req: &Request, _parent: u64, _name: &OsStr, reply: fuser::ReplyEmpty) { - reply.error(libc::EROFS); + fn unlink(&mut self, _req: &Request, parent: u64, name: &OsStr, reply: fuser::ReplyEmpty) { + let path = match self.resolve_path(parent, name) { + Some(p) => p, + None => { + reply.error(libc::ENOENT); + return; + } + }; + + let (file_id, is_dir) = { + let tree = self.tree.read(); + match tree.get_by_path(&path) { + Some(VirtualNode::File(f)) => (Some(f.file_id), false), + Some(VirtualNode::Directory(_)) => (None, true), + None => { + reply.error(libc::ENOENT); + return; + } + } + }; + + if is_dir { + reply.error(libc::EISDIR); + return; + } + + let trash_path = VirtualPath::new(format!("/.trash{}", path.as_str())); + + { + let mut tree = self.tree.write(); + tree.ensure_trash_dir(); + + let trash_parent = std::path::Path::new(trash_path.as_str()) + .parent() + .map(|p| VirtualPath::new(p.to_string_lossy().into_owned())) + .unwrap_or_else(|| VirtualPath::new("/.trash")); + + if let Err(e) = tree.mkdir_p(&trash_parent) { + if !matches!(e, RenameError::TargetExists) { + warn!(error = ?e, "failed to create trash parent directories"); + reply.error(libc::EIO); + return; + } + } + + if let Err(e) = tree.rename_file(&path, &trash_path) { + match e { + RenameError::SourceNotFound => reply.error(libc::ENOENT), + RenameError::TargetExists => reply.error(libc::EEXIST), + _ => reply.error(libc::EIO), + } + return; + } + } + + if let (Some(ref db), Some(id)) = (&self.db, file_id) { + if let Err(e) = db.update_virtual_path(id, &trash_path) { + warn!(error = %e, "failed to update virtual path in database"); + } + if let Err(e) = db.mark_trashed(id, &path) { + warn!(error = %e, "failed to mark file as trashed in database"); + } + } + + debug!(path = %path.as_str(), trash = %trash_path.as_str(), "file moved to trash"); + reply.ok(); } - fn rmdir(&mut self, _req: &Request, _parent: u64, _name: &OsStr, reply: fuser::ReplyEmpty) { - reply.error(libc::EROFS); + fn rmdir(&mut self, _req: &Request, parent: u64, name: &OsStr, reply: fuser::ReplyEmpty) { + let path = match self.resolve_path(parent, name) { + Some(p) => p, + None => { + reply.error(libc::ENOENT); + return; + } + }; + + if VirtualTree::is_trash_path(&path) { + reply.error(libc::EPERM); + return; + } + + { + let mut tree = self.tree.write(); + match tree.remove_directory(&path) { + Ok(()) => {} + Err(RemoveError::NotFound) => { + reply.error(libc::ENOENT); + return; + } + Err(RemoveError::NotEmpty) => { + reply.error(libc::ENOTEMPTY); + return; + } + Err(RemoveError::NotDirectory) => { + reply.error(libc::ENOTDIR); + return; + } + } + } + + if let Some(ref db) = self.db { + if let Err(e) = db.delete_directory(&path) { + warn!(error = %e, "failed to delete directory from database"); + } + } + + debug!(path = %path.as_str(), "directory removed"); + reply.ok(); } fn rename( @@ -635,6 +738,14 @@ impl Filesystem for MusicFs { if let Err(e) = db.update_virtual_path(id, &new_path) { warn!(error = %e, "failed to persist file rename to database"); } + let was_in_trash = VirtualTree::is_trash_path(&old_path); + let now_in_trash = VirtualTree::is_trash_path(&new_path); + if was_in_trash && !now_in_trash { + if let Err(e) = db.unmark_trashed(id) { + warn!(error = %e, "failed to unmark trashed after restore"); + } + debug!(path = %new_path.as_str(), "file restored from trash"); + } } debug!(old = %old_path.as_str(), new = %new_path.as_str(), "file renamed"); Ok(()) diff --git a/docs/v2/features/rm.md b/docs/v2/features/rm.md new file mode 100644 index 0000000..6357d14 --- /dev/null +++ b/docs/v2/features/rm.md @@ -0,0 +1,166 @@ +**Date**: 2026-05-17 +**Status**: Shipped + +# Feature: Remove (rm) + +## Overview + +MusicFS supports removing files and directories. Deleted files are moved to a virtual `/.trash/` directory and can be restored. The trash is browsable — users can manually move files out. + +## Behavior + +### Remove File + +```bash +rm "/mnt/music/Artist/Album/track.flac" +``` + +- File moves to `/.trash/Artist/Album/track.flac` +- Original directory structure preserved in trash +- File still accessible via `/.trash/` path +- Database marks file as `trashed=1` with original path stored + +### Remove Empty Directory + +```bash +rmdir "/mnt/music/Empty Folder" +``` + +- Removes empty directory from tree +- Removes from `directories` table if user-created +- Fails with `ENOTEMPTY` if directory has children + +### Remove Directory Recursively + +```bash +rm -rf "/mnt/music/Artist" +``` + +- Shell handles recursion (depth-first unlink + rmdir) +- All files moved to `/.trash/Artist/...` +- Empty directories removed after files are trashed + +## The `.trash/` Directory + +Deleted files live in `/.trash/` with their original path structure: + +``` +/.trash/ +├── Artist/ +│ └── Album/ +│ ├── track1.flac +│ └── track2.flac +└── Other Artist/ + └── song.flac +``` + +### Browse Trash + +```bash +ls "/.trash/" +ls "/.trash/Artist/Album/" +``` + +### Manual Restore + +```bash +# Move file back manually - trashed flag is automatically cleared +mv "/.trash/Artist/Album/track.flac" "/Artist/Album/" +``` + +When moving a file out of `/.trash/`, the database `trashed` flag is automatically cleared. + +## CLI Commands + +All trash commands require either `--config` or `--cache-dir`: + +```bash +musicfs trash -c config.toml +musicfs trash --cache-dir ./dev/cache/musicfs +``` + +### List Deleted Files + +```bash +musicfs trash -c config.toml list +musicfs trash -c config.toml list --origin local-storage +musicfs trash -c config.toml list --since 7d +musicfs trash -c config.toml list --path "/Artist" +``` + +Output shows index, deletion time, and original path. + +### Restore Files + +```bash +# Restore single file or folder +musicfs trash -c config.toml restore "/Artist/Album/track.flac" + +# Restore entire folder recursively +musicfs trash -c config.toml restore "/Artist" + +# Restore everything +musicfs trash -c config.toml restore --all +``` + +CLI restore writes paths to a pending restore file and sends SIGHUP to the daemon. +The daemon processes pending restores and moves files back from `/.trash/`. + +### Empty Trash + +```bash +# Permanently delete all trashed files +musicfs trash -c config.toml empty + +# Delete old items only +musicfs trash -c config.toml empty --older-than 30d + +# Delete by path pattern +musicfs trash -c config.toml empty --pattern "/Artist" +``` + +**Warning:** Empty permanently removes files from MusicFS database. Origin files are unaffected. + +## Error Codes + +| Condition | Error | +|-----------|-------| +| Path doesn't exist | `ENOENT` | +| `rm` on directory (without `-r`) | `EISDIR` | +| `rmdir` on file | `ENOTDIR` | +| `rmdir` on non-empty directory | `ENOTEMPTY` | +| `rmdir` on `/.trash/` | `EPERM` | + +## Database Schema + +Files table extended with trash columns: + +```sql +trashed INTEGER NOT NULL DEFAULT 0, +original_path TEXT, +trashed_at INTEGER +``` + +Partial index for efficient trash queries: +```sql +CREATE INDEX idx_files_trashed ON files(trashed) WHERE trashed = 1; +``` + +## How It Works + +1. **Delete (`rm`)**: FUSE `unlink` moves file to `/.trash/`, marks `trashed=1` in DB +2. **Manual restore (`mv`)**: Moving out of `/.trash/` automatically clears `trashed` flag +3. **CLI restore**: Writes pending paths, sends SIGHUP to daemon, daemon processes restores +4. **Empty**: Deletes matching records from database + +## Persistence + +- Trashed files persist across remounts (stored in `/.trash/` subtree) +- Files marked with `trashed=1`, `original_path`, `trashed_at` in database +- PID file at `{cache_dir}/musicfs.pid` for CLI→daemon communication + +## Limitations + +- **No hard delete of remote files**: Origin content is never modified +- **Trash uses virtual space**: Files still in tree under `/.trash/` until emptied +- **CLI restore requires running daemon**: Manual `mv` works without daemon