feat(fuse): implement rm with virtual .trash/ directory
- Add trashed/original_path/trashed_at columns to files table - Implement FUSE unlink: moves files to /.trash/ preserving path structure - Implement FUSE rmdir: removes empty directories - Add trash CLI commands: list, restore, empty - Add SIGHUP handler for CLI-triggered restore - Fix upsert_file returning 0 on UPDATE (query actual ID) - Auto-clear trashed flag when moving files out of /.trash/
This commit is contained in:
@@ -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<Option<FileMeta>> {
|
||||
@@ -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<Vec<TrashedFile>> {
|
||||
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<Box<dyn rusqlite::ToSql>> = 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<TrashedFile> = 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<Vec<TrashedFile>> {
|
||||
self.list_trashed(&TrashedFilter {
|
||||
path_prefix: Some(prefix.to_string()),
|
||||
..Default::default()
|
||||
})
|
||||
}
|
||||
|
||||
pub fn is_trashed(&self, path: &VirtualPath) -> Result<bool> {
|
||||
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<u64> {
|
||||
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<OriginId>,
|
||||
pub path_prefix: Option<String>,
|
||||
pub since: Option<Duration>,
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<Inode, RenameError> {
|
||||
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<Vec<FileId>, 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<bool> {
|
||||
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
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<PathBuf>,
|
||||
#[arg(short = 'd', long, help = "Cache directory")]
|
||||
cache_dir: Option<PathBuf>,
|
||||
#[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<String>,
|
||||
#[arg(long, help = "Show files deleted within duration (e.g., 7d, 24h)")]
|
||||
since: Option<String>,
|
||||
#[arg(long, help = "Filter by path prefix")]
|
||||
path: Option<String>,
|
||||
},
|
||||
Restore {
|
||||
#[arg(help = "Path to restore (restores folder recursively)")]
|
||||
path: Option<String>,
|
||||
#[arg(long, help = "Restore all deleted files")]
|
||||
all: bool,
|
||||
},
|
||||
Empty {
|
||||
#[arg(long, help = "Delete files older than duration (e.g., 30d)")]
|
||||
older_than: Option<String>,
|
||||
#[arg(long, help = "Delete files matching pattern")]
|
||||
pattern: Option<String>,
|
||||
},
|
||||
}
|
||||
|
||||
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())?;
|
||||
|
||||
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<PathBuf>,
|
||||
cache_dir: Option<PathBuf>,
|
||||
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<String> = 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<std::time::Duration> {
|
||||
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<RwLock<VirtualTree>>, db: &Arc<Database>) {
|
||||
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<String> = 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<WorkerGuard> {
|
||||
std::fs::create_dir_all(&config.log_dir)?;
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
fn rmdir(&mut self, _req: &Request, _parent: u64, _name: &OsStr, reply: fuser::ReplyEmpty) {
|
||||
reply.error(libc::EROFS);
|
||||
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) {
|
||||
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(())
|
||||
|
||||
@@ -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 <command>
|
||||
musicfs trash --cache-dir ./dev/cache/musicfs <command>
|
||||
```
|
||||
|
||||
### 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
|
||||
Reference in New Issue
Block a user