feat(fuse): implement mkdir and mv with persistence
Add mkdir and mv (rename) FUSE operations to the virtual filesystem: - mkdir: Create directories that persist across remounts via SQLite - mv: Move/rename files and directories with database persistence Changes: - Add directories table to schema for user-created empty dirs - Add tree operations: mkdir, rename_file, rename_directory - Add DB methods for path updates and directory CRUD - Remove MountOption::RO to allow write syscalls - Load stored virtual_path from DB instead of regenerating - Restore user directories on mount from directories table - Upsert files to DB during origin scan POSIX compliant: mv fails with ENOENT if parent doesn't exist (use mkdir first, shell handles -p flag and brace expansion)
This commit is contained in:
@@ -47,3 +47,4 @@ rustc-ice-*.txt
|
|||||||
# and can be added to the global gitignore or merged into this file. For a more nuclear
|
# 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.
|
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
|
||||||
#.idea/
|
#.idea/
|
||||||
|
dev/
|
||||||
|
|||||||
@@ -284,6 +284,146 @@ impl Database {
|
|||||||
.optional()
|
.optional()
|
||||||
.map_err(|e| Error::Database(format!("query mtime failed: {}", e)))
|
.map_err(|e| Error::Database(format!("query mtime failed: {}", e)))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn path_exists(&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",
|
||||||
|
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<u64> {
|
||||||
|
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<Vec<(FileId, VirtualPath)>> {
|
||||||
|
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<u64> {
|
||||||
|
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<Vec<VirtualPath>> {
|
||||||
|
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<VirtualPath> = 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<Option<VirtualPath>> {
|
||||||
|
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 {
|
fn parse_audio_format(s: &str) -> AudioFormat {
|
||||||
@@ -501,4 +641,145 @@ mod tests {
|
|||||||
.unwrap();
|
.unwrap();
|
||||||
assert!(retrieved.content_hash.is_some());
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,5 +13,6 @@ pub use metadata::MetadataCache;
|
|||||||
pub use patterns::{AccessContext, AccessPattern, PatternError, PatternStore};
|
pub use patterns::{AccessContext, AccessPattern, PatternError, PatternStore};
|
||||||
pub use prefetch::{PrefetchConfig, PrefetchEngine, PrefetchHandle};
|
pub use prefetch::{PrefetchConfig, PrefetchEngine, PrefetchHandle};
|
||||||
pub use tree::{
|
pub use tree::{
|
||||||
DirNode, FileNode, Inode, RefreshPolicy, TreeBuilder, VirtualNode, VirtualTree, ROOT_INODE,
|
DirNode, FileNode, Inode, RefreshPolicy, RenameError, TreeBuilder, VirtualNode, VirtualTree,
|
||||||
|
ROOT_INODE,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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_origin ON files(origin_id);
|
||||||
CREATE INDEX IF NOT EXISTS idx_files_last_sync ON files(last_sync);
|
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 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);
|
||||||
|
|||||||
@@ -310,6 +310,276 @@ impl VirtualTree {
|
|||||||
pub fn refresh_policy(&self) -> &RefreshPolicy {
|
pub fn refresh_policy(&self) -> &RefreshPolicy {
|
||||||
&self.refresh_policy
|
&self.refresh_policy
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn path_to_inode_iter(&self) -> impl Iterator<Item = (&VirtualPath, &Inode)> {
|
||||||
|
self.path_to_inode.iter()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn mkdir(&mut self, path: &VirtualPath) -> std::result::Result<Inode, RenameError> {
|
||||||
|
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<u64, RenameError> {
|
||||||
|
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 {
|
impl Default for VirtualTree {
|
||||||
@@ -445,4 +715,178 @@ mod tests {
|
|||||||
let tree = builder.build();
|
let tree = builder.build();
|
||||||
assert_eq!(tree.file_count(), 2);
|
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));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
use anyhow::{Context, Result};
|
use anyhow::{Context, Result};
|
||||||
use clap::{Parser, Subcommand};
|
use clap::{Parser, Subcommand};
|
||||||
use musicfs_cache::TreeBuilder;
|
use musicfs_cache::{Database, TreeBuilder};
|
||||||
use musicfs_cas::{CasConfig, CasStore, ContentFetcher, FileReader};
|
use musicfs_cas::{CasConfig, CasStore, ContentFetcher, FileReader};
|
||||||
use musicfs_core::{FileId, FileMeta, LoggingConfig, OriginId, RealPath, VirtualPath};
|
use musicfs_core::{FileId, FileMeta, LoggingConfig, OriginId, RealPath, VirtualPath};
|
||||||
use musicfs_fuse::MusicFs;
|
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 runtime = tokio::runtime::Runtime::new().context("Failed to create Tokio runtime")?;
|
||||||
let handle = runtime.handle().clone();
|
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!(mountpoint = ?config.mount_point, "Mount configuration");
|
||||||
info!("Cache directory: {:?}", config.cache_dir);
|
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.cache_dir).context("Failed to create cache directory")?;
|
||||||
std::fs::create_dir_all(&config.mount_point).context("Failed to create mountpoint")?;
|
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 {
|
let cas_config = CasConfig {
|
||||||
chunks_dir: config.cache_dir.join("chunks"),
|
chunks_dir: config.cache_dir.join("chunks"),
|
||||||
..Default::default()
|
..Default::default()
|
||||||
@@ -258,9 +262,9 @@ fn run_mount(config: musicfs_core::Config) -> Result<()> {
|
|||||||
.unwrap();
|
.unwrap();
|
||||||
let origin_path = PathBuf::from(path_str);
|
let origin_path = PathBuf::from(path_str);
|
||||||
info!("Scanning music files for origin {}...", origin_cfg.id);
|
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!(
|
info!(
|
||||||
"Fount {} music files for origin {}",
|
"Found {} music files for origin {}",
|
||||||
origin_files.len(),
|
origin_files.len(),
|
||||||
origin_cfg.id
|
origin_cfg.id
|
||||||
);
|
);
|
||||||
@@ -273,12 +277,27 @@ fn run_mount(config: musicfs_core::Config) -> Result<()> {
|
|||||||
builder.add_file(file);
|
builder.add_file(file);
|
||||||
fetcher.register_file(file.clone());
|
fetcher.register_file(file.clone());
|
||||||
}
|
}
|
||||||
let tree = Arc::new(RwLock::new(builder.build()));
|
let mut tree = builder.build();
|
||||||
info!("Virtual tree built");
|
|
||||||
|
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));
|
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)?;
|
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?")?;
|
.context("Failed to acquire lock — is another instance running?")?;
|
||||||
info!(lock_path = ?lock_path, "Lock acquired");
|
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);
|
info!("Mounting filesystem at {:?}", config.mount_point);
|
||||||
|
|
||||||
@@ -459,7 +478,11 @@ fn init_basic_logging(level: &str) {
|
|||||||
.init();
|
.init();
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn scan_music_files(dir: &Path, origin_id: &OriginId) -> Result<Vec<FileMeta>> {
|
async fn scan_music_files(
|
||||||
|
dir: &Path,
|
||||||
|
origin_id: &OriginId,
|
||||||
|
db: &Database,
|
||||||
|
) -> Result<Vec<FileMeta>> {
|
||||||
let parser = MetadataParser::new();
|
let parser = MetadataParser::new();
|
||||||
let mut files = Vec::new();
|
let mut files = Vec::new();
|
||||||
let mut file_id_counter = 1i64;
|
let mut file_id_counter = 1i64;
|
||||||
@@ -469,6 +492,7 @@ async fn scan_music_files(dir: &Path, origin_id: &OriginId) -> Result<Vec<FileMe
|
|||||||
dir,
|
dir,
|
||||||
origin_id,
|
origin_id,
|
||||||
&parser,
|
&parser,
|
||||||
|
db,
|
||||||
&mut files,
|
&mut files,
|
||||||
&mut file_id_counter,
|
&mut file_id_counter,
|
||||||
)
|
)
|
||||||
@@ -482,6 +506,7 @@ async fn scan_dir_recursive(
|
|||||||
dir: &Path,
|
dir: &Path,
|
||||||
origin_id: &OriginId,
|
origin_id: &OriginId,
|
||||||
parser: &MetadataParser,
|
parser: &MetadataParser,
|
||||||
|
db: &Database,
|
||||||
files: &mut Vec<FileMeta>,
|
files: &mut Vec<FileMeta>,
|
||||||
id_counter: &mut i64,
|
id_counter: &mut i64,
|
||||||
) -> Result<()> {
|
) -> Result<()> {
|
||||||
@@ -493,11 +518,12 @@ async fn scan_dir_recursive(
|
|||||||
|
|
||||||
if metadata.is_dir() {
|
if metadata.is_dir() {
|
||||||
Box::pin(scan_dir_recursive(
|
Box::pin(scan_dir_recursive(
|
||||||
base, &path, origin_id, parser, files, id_counter,
|
base, &path, origin_id, parser, db, files, id_counter,
|
||||||
))
|
))
|
||||||
.await?;
|
.await?;
|
||||||
} else if is_audio_file(&path) {
|
} else if is_audio_file(&path) {
|
||||||
let relative_path = path.strip_prefix(base).unwrap_or(&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) {
|
let audio_meta = match parser.parse_file(&path) {
|
||||||
Ok(meta) => Some(meta),
|
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 {
|
let file_meta = FileMeta {
|
||||||
id: FileId(*id_counter),
|
id: file_id,
|
||||||
virtual_path,
|
virtual_path,
|
||||||
real_path: RealPath {
|
real_path,
|
||||||
origin_id: origin_id.clone(),
|
|
||||||
path: PathBuf::from("/").join(relative_path),
|
|
||||||
},
|
|
||||||
size: metadata.len(),
|
size: metadata.len(),
|
||||||
mtime: metadata.modified().unwrap_or(SystemTime::UNIX_EPOCH),
|
mtime: metadata.modified().unwrap_or(SystemTime::UNIX_EPOCH),
|
||||||
content_hash: None,
|
content_hash: None,
|
||||||
|
|||||||
@@ -3,9 +3,9 @@ use fuser::{
|
|||||||
FileAttr, FileType, Filesystem, ReplyAttr, ReplyData, ReplyDirectory, ReplyEntry, ReplyOpen,
|
FileAttr, FileType, Filesystem, ReplyAttr, ReplyData, ReplyDirectory, ReplyEntry, ReplyOpen,
|
||||||
Request,
|
Request,
|
||||||
};
|
};
|
||||||
use musicfs_cache::{VirtualNode, VirtualTree, ROOT_INODE};
|
use musicfs_cache::{Database, RenameError, VirtualNode, VirtualTree, ROOT_INODE};
|
||||||
use musicfs_cas::FileReader;
|
use musicfs_cas::FileReader;
|
||||||
use musicfs_core::Result;
|
use musicfs_core::{Result, VirtualPath};
|
||||||
use parking_lot::RwLock;
|
use parking_lot::RwLock;
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use std::ffi::OsStr;
|
use std::ffi::OsStr;
|
||||||
@@ -22,6 +22,7 @@ const SEARCH_QUERY_INODE_BASE: u64 = 0xFFFF_FFFF_0000_0100;
|
|||||||
pub struct MusicFs {
|
pub struct MusicFs {
|
||||||
tree: Arc<RwLock<VirtualTree>>,
|
tree: Arc<RwLock<VirtualTree>>,
|
||||||
reader: Option<Arc<FileReader>>,
|
reader: Option<Arc<FileReader>>,
|
||||||
|
db: Option<Arc<Database>>,
|
||||||
runtime_handle: Handle,
|
runtime_handle: Handle,
|
||||||
search_ops: Option<SearchOps>,
|
search_ops: Option<SearchOps>,
|
||||||
query_inodes: RwLock<HashMap<String, u64>>,
|
query_inodes: RwLock<HashMap<String, u64>>,
|
||||||
@@ -36,6 +37,7 @@ impl MusicFs {
|
|||||||
Self {
|
Self {
|
||||||
tree,
|
tree,
|
||||||
reader: None,
|
reader: None,
|
||||||
|
db: None,
|
||||||
runtime_handle,
|
runtime_handle,
|
||||||
search_ops: None,
|
search_ops: None,
|
||||||
query_inodes: RwLock::new(HashMap::new()),
|
query_inodes: RwLock::new(HashMap::new()),
|
||||||
@@ -54,6 +56,7 @@ impl MusicFs {
|
|||||||
Self {
|
Self {
|
||||||
tree,
|
tree,
|
||||||
reader: Some(reader),
|
reader: Some(reader),
|
||||||
|
db: None,
|
||||||
runtime_handle,
|
runtime_handle,
|
||||||
search_ops: None,
|
search_ops: None,
|
||||||
query_inodes: RwLock::new(HashMap::new()),
|
query_inodes: RwLock::new(HashMap::new()),
|
||||||
@@ -64,11 +67,37 @@ impl MusicFs {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn with_db(mut self, db: Arc<Database>) -> Self {
|
||||||
|
self.db = Some(db);
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
pub fn with_search(mut self, search_ops: SearchOps) -> Self {
|
pub fn with_search(mut self, search_ops: SearchOps) -> Self {
|
||||||
self.search_ops = Some(search_ops);
|
self.search_ops = Some(search_ops);
|
||||||
self
|
self
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn resolve_path(&self, parent_inode: u64, name: &OsStr) -> Option<VirtualPath> {
|
||||||
|
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<String> {
|
||||||
|
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 {
|
fn get_or_create_query_inode(&self, query: &str) -> u64 {
|
||||||
let query_inodes = self.query_inodes.read();
|
let query_inodes = self.query_inodes.read();
|
||||||
if let Some(&inode) = query_inodes.get(query) {
|
if let Some(&inode) = query_inodes.get(query) {
|
||||||
@@ -99,7 +128,6 @@ impl MusicFs {
|
|||||||
info!("Mounting MusicFS at {:?}", mountpoint);
|
info!("Mounting MusicFS at {:?}", mountpoint);
|
||||||
|
|
||||||
let options = vec![
|
let options = vec![
|
||||||
fuser::MountOption::RO,
|
|
||||||
fuser::MountOption::FSName("musicfs".to_string()),
|
fuser::MountOption::FSName("musicfs".to_string()),
|
||||||
fuser::MountOption::AutoUnmount,
|
fuser::MountOption::AutoUnmount,
|
||||||
fuser::MountOption::AllowOther,
|
fuser::MountOption::AllowOther,
|
||||||
@@ -114,7 +142,6 @@ impl MusicFs {
|
|||||||
info!("Mounting MusicFS at {:?}", mountpoint);
|
info!("Mounting MusicFS at {:?}", mountpoint);
|
||||||
|
|
||||||
let options = vec![
|
let options = vec![
|
||||||
fuser::MountOption::RO,
|
|
||||||
fuser::MountOption::FSName("musicfs".to_string()),
|
fuser::MountOption::FSName("musicfs".to_string()),
|
||||||
fuser::MountOption::AutoUnmount,
|
fuser::MountOption::AutoUnmount,
|
||||||
fuser::MountOption::AllowOther,
|
fuser::MountOption::AllowOther,
|
||||||
@@ -471,13 +498,52 @@ impl Filesystem for MusicFs {
|
|||||||
fn mkdir(
|
fn mkdir(
|
||||||
&mut self,
|
&mut self,
|
||||||
_req: &Request,
|
_req: &Request,
|
||||||
_parent: u64,
|
parent: u64,
|
||||||
_name: &OsStr,
|
name: &OsStr,
|
||||||
_mode: u32,
|
_mode: u32,
|
||||||
_umask: u32,
|
_umask: u32,
|
||||||
reply: ReplyEntry,
|
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) {
|
fn unlink(&mut self, _req: &Request, _parent: u64, _name: &OsStr, reply: fuser::ReplyEmpty) {
|
||||||
@@ -491,14 +557,100 @@ impl Filesystem for MusicFs {
|
|||||||
fn rename(
|
fn rename(
|
||||||
&mut self,
|
&mut self,
|
||||||
_req: &Request,
|
_req: &Request,
|
||||||
_parent: u64,
|
parent: u64,
|
||||||
_name: &OsStr,
|
name: &OsStr,
|
||||||
_newparent: u64,
|
newparent: u64,
|
||||||
_newname: &OsStr,
|
newname: &OsStr,
|
||||||
_flags: u32,
|
_flags: u32,
|
||||||
reply: fuser::ReplyEmpty,
|
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(
|
fn create(
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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
|
||||||
Reference in New Issue
Block a user