Move the files around
This commit is contained in:
@@ -0,0 +1,19 @@
|
||||
[package]
|
||||
name = "musicfs-fuse"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
|
||||
[dependencies]
|
||||
musicfs-core = { path = "../musicfs-core" }
|
||||
musicfs-cache = { path = "../musicfs-cache" }
|
||||
musicfs-cas = { path = "../musicfs-cas" }
|
||||
musicfs-search = { path = "../musicfs-search" }
|
||||
fuser.workspace = true
|
||||
tokio.workspace = true
|
||||
tracing.workspace = true
|
||||
moka.workspace = true
|
||||
parking_lot.workspace = true
|
||||
libc = "0.2"
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile.workspace = true
|
||||
@@ -0,0 +1,613 @@
|
||||
use crate::ops::SearchOps;
|
||||
use fuser::{
|
||||
FileAttr, FileType, Filesystem, ReplyAttr, ReplyData, ReplyDirectory, ReplyEntry, ReplyOpen,
|
||||
Request,
|
||||
};
|
||||
use musicfs_cache::{VirtualNode, VirtualTree, ROOT_INODE};
|
||||
use musicfs_cas::FileReader;
|
||||
use musicfs_core::Result;
|
||||
use parking_lot::RwLock;
|
||||
use std::collections::HashMap;
|
||||
use std::ffi::OsStr;
|
||||
use std::path::Path;
|
||||
use std::sync::Arc;
|
||||
use std::time::{Duration, SystemTime};
|
||||
use tokio::runtime::Handle;
|
||||
use tracing::{debug, info, instrument, trace, warn};
|
||||
|
||||
const TTL: Duration = Duration::from_secs(1);
|
||||
const BLOCK_SIZE: u32 = 512;
|
||||
const SEARCH_QUERY_INODE_BASE: u64 = 0xFFFF_FFFF_0000_0100;
|
||||
|
||||
pub struct MusicFs {
|
||||
tree: Arc<RwLock<VirtualTree>>,
|
||||
reader: Option<Arc<FileReader>>,
|
||||
runtime_handle: Handle,
|
||||
search_ops: Option<SearchOps>,
|
||||
query_inodes: RwLock<HashMap<String, u64>>,
|
||||
inode_queries: RwLock<HashMap<u64, String>>,
|
||||
next_query_inode: RwLock<u64>,
|
||||
uid: u32,
|
||||
gid: u32,
|
||||
}
|
||||
|
||||
impl MusicFs {
|
||||
pub fn new(tree: Arc<RwLock<VirtualTree>>, runtime_handle: Handle) -> Self {
|
||||
Self {
|
||||
tree,
|
||||
reader: None,
|
||||
runtime_handle,
|
||||
search_ops: None,
|
||||
query_inodes: RwLock::new(HashMap::new()),
|
||||
inode_queries: RwLock::new(HashMap::new()),
|
||||
next_query_inode: RwLock::new(SEARCH_QUERY_INODE_BASE),
|
||||
uid: unsafe { libc::getuid() },
|
||||
gid: unsafe { libc::getgid() },
|
||||
}
|
||||
}
|
||||
|
||||
pub fn with_reader(
|
||||
tree: Arc<RwLock<VirtualTree>>,
|
||||
reader: Arc<FileReader>,
|
||||
runtime_handle: Handle,
|
||||
) -> Self {
|
||||
Self {
|
||||
tree,
|
||||
reader: Some(reader),
|
||||
runtime_handle,
|
||||
search_ops: None,
|
||||
query_inodes: RwLock::new(HashMap::new()),
|
||||
inode_queries: RwLock::new(HashMap::new()),
|
||||
next_query_inode: RwLock::new(SEARCH_QUERY_INODE_BASE),
|
||||
uid: unsafe { libc::getuid() },
|
||||
gid: unsafe { libc::getgid() },
|
||||
}
|
||||
}
|
||||
|
||||
pub fn with_search(mut self, search_ops: SearchOps) -> Self {
|
||||
self.search_ops = Some(search_ops);
|
||||
self
|
||||
}
|
||||
|
||||
fn get_or_create_query_inode(&self, query: &str) -> u64 {
|
||||
let query_inodes = self.query_inodes.read();
|
||||
if let Some(&inode) = query_inodes.get(query) {
|
||||
return inode;
|
||||
}
|
||||
drop(query_inodes);
|
||||
|
||||
let mut query_inodes = self.query_inodes.write();
|
||||
let mut inode_queries = self.inode_queries.write();
|
||||
let mut next_inode = self.next_query_inode.write();
|
||||
|
||||
if let Some(&inode) = query_inodes.get(query) {
|
||||
return inode;
|
||||
}
|
||||
|
||||
let inode = *next_inode;
|
||||
*next_inode += 1;
|
||||
query_inodes.insert(query.to_string(), inode);
|
||||
inode_queries.insert(inode, query.to_string());
|
||||
inode
|
||||
}
|
||||
|
||||
fn get_query_for_inode(&self, inode: u64) -> Option<String> {
|
||||
self.inode_queries.read().get(&inode).cloned()
|
||||
}
|
||||
|
||||
pub fn mount(self, mountpoint: &Path) -> Result<()> {
|
||||
info!("Mounting MusicFS at {:?}", mountpoint);
|
||||
|
||||
let options = vec![
|
||||
fuser::MountOption::RO,
|
||||
fuser::MountOption::FSName("musicfs".to_string()),
|
||||
fuser::MountOption::AutoUnmount,
|
||||
fuser::MountOption::AllowOther,
|
||||
];
|
||||
|
||||
fuser::mount2(self, mountpoint, &options).map_err(musicfs_core::Error::Io)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn spawn_mount(self, mountpoint: &Path) -> Result<fuser::BackgroundSession> {
|
||||
info!("Mounting MusicFS at {:?}", mountpoint);
|
||||
|
||||
let options = vec![
|
||||
fuser::MountOption::RO,
|
||||
fuser::MountOption::FSName("musicfs".to_string()),
|
||||
fuser::MountOption::AutoUnmount,
|
||||
fuser::MountOption::AllowOther,
|
||||
];
|
||||
|
||||
let session =
|
||||
fuser::spawn_mount2(self, mountpoint, &options).map_err(musicfs_core::Error::Io)?;
|
||||
|
||||
Ok(session)
|
||||
}
|
||||
|
||||
fn node_to_attr(&self, node: &VirtualNode) -> FileAttr {
|
||||
match node {
|
||||
VirtualNode::Directory(dir) => FileAttr {
|
||||
ino: dir.inode,
|
||||
size: 0,
|
||||
blocks: 0,
|
||||
atime: dir.mtime,
|
||||
mtime: dir.mtime,
|
||||
ctime: dir.mtime,
|
||||
crtime: dir.mtime,
|
||||
kind: FileType::Directory,
|
||||
perm: 0o755,
|
||||
nlink: 2,
|
||||
uid: self.uid,
|
||||
gid: self.gid,
|
||||
rdev: 0,
|
||||
blksize: BLOCK_SIZE,
|
||||
flags: 0,
|
||||
},
|
||||
VirtualNode::File(file) => FileAttr {
|
||||
ino: file.inode,
|
||||
size: file.size,
|
||||
blocks: (file.size + BLOCK_SIZE as u64 - 1) / BLOCK_SIZE as u64,
|
||||
atime: file.mtime,
|
||||
mtime: file.mtime,
|
||||
ctime: file.mtime,
|
||||
crtime: file.mtime,
|
||||
kind: FileType::RegularFile,
|
||||
perm: 0o644,
|
||||
nlink: 1,
|
||||
uid: self.uid,
|
||||
gid: self.gid,
|
||||
rdev: 0,
|
||||
blksize: BLOCK_SIZE,
|
||||
flags: 0,
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Filesystem for MusicFs {
|
||||
fn init(
|
||||
&mut self,
|
||||
_req: &Request<'_>,
|
||||
_config: &mut fuser::KernelConfig,
|
||||
) -> std::result::Result<(), libc::c_int> {
|
||||
info!("MusicFS initialized");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn destroy(&mut self) {
|
||||
info!("MusicFS destroyed");
|
||||
}
|
||||
|
||||
#[instrument(level = "debug", skip(self, reply))]
|
||||
fn lookup(&mut self, _req: &Request, parent: u64, name: &OsStr, reply: ReplyEntry) {
|
||||
let name_str = name.to_string_lossy();
|
||||
|
||||
if parent == ROOT_INODE && SearchOps::is_search_dir_name(&name_str) {
|
||||
trace!(parent, name = %name_str, "search_dir_name matched");
|
||||
if let Some(ref search_ops) = self.search_ops {
|
||||
search_ops.lookup_search_dir(reply);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if parent == SearchOps::search_dir_inode() {
|
||||
trace!(parent, name = %name_str, "search_dir_inode matched");
|
||||
if let Some(ref search_ops) = self.search_ops {
|
||||
let inode = self.get_or_create_query_inode(&name_str);
|
||||
search_ops.lookup_query_dir(&name_str, inode, reply);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(query) = self.get_query_for_inode(parent) {
|
||||
trace!(parent, name = %name_str, query = %query, "query_inode matched");
|
||||
if let Some(ref search_ops) = self.search_ops {
|
||||
let inode = self.get_or_create_query_inode(&format!("{}:{}", query, name_str));
|
||||
search_ops.lookup_result(inode, reply);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
let tree = self.tree.read();
|
||||
|
||||
if let Some(inode) = tree.lookup(parent, name) {
|
||||
trace!(parent, name = %name_str, ino = inode, "file found in tree");
|
||||
if let Some(node) = tree.get(inode) {
|
||||
let attr = self.node_to_attr(node);
|
||||
reply.entry(&TTL, &attr, 0);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
trace!(parent, name = %name_str, "file not found");
|
||||
reply.error(libc::ENOENT);
|
||||
}
|
||||
|
||||
#[instrument(level = "debug", skip(self, reply))]
|
||||
fn getattr(&mut self, _req: &Request, ino: u64, reply: ReplyAttr) {
|
||||
if ino == SearchOps::search_dir_inode() {
|
||||
trace!(ino, "search_dir_inode matched");
|
||||
if let Some(ref search_ops) = self.search_ops {
|
||||
search_ops.getattr_search_dir(reply);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if SearchOps::is_search_inode(ino) {
|
||||
trace!(ino, "search_inode matched");
|
||||
if let Some(ref search_ops) = self.search_ops {
|
||||
search_ops.getattr_result(ino, reply);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if self.get_query_for_inode(ino).is_some() {
|
||||
trace!(ino, "query_inode matched");
|
||||
if let Some(ref search_ops) = self.search_ops {
|
||||
search_ops.getattr_search_dir(reply);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
let tree = self.tree.read();
|
||||
|
||||
if let Some(node) = tree.get(ino) {
|
||||
trace!(ino, "inode found in tree");
|
||||
let attr = self.node_to_attr(node);
|
||||
reply.attr(&TTL, &attr);
|
||||
} else {
|
||||
trace!(ino, "inode not found");
|
||||
reply.error(libc::ENOENT);
|
||||
}
|
||||
}
|
||||
|
||||
#[instrument(level = "debug", skip(self, reply))]
|
||||
fn readdir(
|
||||
&mut self,
|
||||
_req: &Request,
|
||||
ino: u64,
|
||||
_fh: u64,
|
||||
offset: i64,
|
||||
mut reply: ReplyDirectory,
|
||||
) {
|
||||
if ino == SearchOps::search_dir_inode() {
|
||||
trace!(ino, offset, "search_dir_inode matched");
|
||||
if let Some(ref search_ops) = self.search_ops {
|
||||
search_ops.readdir_search_root(offset, reply);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(query) = self.get_query_for_inode(ino) {
|
||||
trace!(ino, offset, query = %query, "query_inode matched");
|
||||
if let Some(ref search_ops) = self.search_ops {
|
||||
search_ops.readdir_query(&query, offset, reply);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
let tree = self.tree.read();
|
||||
|
||||
if let Some(children) = tree.readdir(ino) {
|
||||
trace!(
|
||||
ino,
|
||||
offset,
|
||||
children_count = children.len(),
|
||||
"directory found"
|
||||
);
|
||||
let parent_ino = tree.get_parent(ino).unwrap_or(ROOT_INODE);
|
||||
|
||||
let entries: Vec<(u64, FileType, &str)> = vec![
|
||||
(ino, FileType::Directory, "."),
|
||||
(parent_ino, FileType::Directory, ".."),
|
||||
];
|
||||
|
||||
let child_entries: Vec<(u64, FileType, String)> = children
|
||||
.iter()
|
||||
.map(|(name, child_ino, is_dir)| {
|
||||
let kind = if *is_dir {
|
||||
FileType::Directory
|
||||
} else {
|
||||
FileType::RegularFile
|
||||
};
|
||||
(*child_ino, kind, name.to_string_lossy().to_string())
|
||||
})
|
||||
.collect();
|
||||
|
||||
for (i, (inode, kind, name)) in entries.iter().enumerate().skip(offset as usize) {
|
||||
if reply.add(*inode, (i + 1) as i64, *kind, name) {
|
||||
reply.ok();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
let base_offset = entries.len();
|
||||
for (i, (inode, kind, name)) in child_entries.iter().enumerate() {
|
||||
let entry_offset = base_offset + i;
|
||||
if entry_offset < offset as usize {
|
||||
continue;
|
||||
}
|
||||
if reply.add(*inode, (entry_offset + 1) as i64, *kind, name) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
reply.ok();
|
||||
} else {
|
||||
trace!(ino, offset, "directory not found");
|
||||
reply.error(libc::ENOENT);
|
||||
}
|
||||
}
|
||||
|
||||
#[instrument(level = "debug", skip(self, reply))]
|
||||
fn open(&mut self, _req: &Request, ino: u64, flags: i32, reply: ReplyOpen) {
|
||||
let write_flags = libc::O_WRONLY | libc::O_RDWR | libc::O_APPEND | libc::O_TRUNC;
|
||||
if flags & write_flags != 0 {
|
||||
trace!(ino, flags, "write flags detected");
|
||||
reply.error(libc::EROFS);
|
||||
return;
|
||||
}
|
||||
|
||||
let tree = self.tree.read();
|
||||
|
||||
if tree.get(ino).is_some() {
|
||||
trace!(ino, "inode found");
|
||||
reply.opened(0, 0);
|
||||
} else {
|
||||
trace!(ino, "inode not found");
|
||||
reply.error(libc::ENOENT);
|
||||
}
|
||||
}
|
||||
|
||||
#[instrument(level = "debug", skip(self, reply))]
|
||||
fn read(
|
||||
&mut self,
|
||||
_req: &Request,
|
||||
ino: u64,
|
||||
_fh: u64,
|
||||
offset: i64,
|
||||
size: u32,
|
||||
_flags: i32,
|
||||
_lock_owner: Option<u64>,
|
||||
reply: ReplyData,
|
||||
) {
|
||||
let file_id = {
|
||||
let tree = self.tree.read();
|
||||
if let Some(VirtualNode::File(file)) = tree.get(ino) {
|
||||
trace!(ino, "file found in tree");
|
||||
file.file_id
|
||||
} else {
|
||||
trace!(ino, "file not found");
|
||||
reply.error(libc::ENOENT);
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
let Some(reader) = &self.reader else {
|
||||
trace!(ino, "no reader available");
|
||||
reply.data(&[]);
|
||||
return;
|
||||
};
|
||||
|
||||
let reader = reader.clone();
|
||||
let handle = self.runtime_handle.clone();
|
||||
let result = std::thread::scope(|_| {
|
||||
handle.block_on(async {
|
||||
tokio::time::timeout(
|
||||
Duration::from_secs(30),
|
||||
reader.read(file_id, offset as u64, size),
|
||||
)
|
||||
.await
|
||||
})
|
||||
});
|
||||
|
||||
match result {
|
||||
Ok(Ok(data)) => {
|
||||
trace!(
|
||||
ino,
|
||||
offset,
|
||||
size_bytes = size,
|
||||
bytes_read = data.len(),
|
||||
"read successful"
|
||||
);
|
||||
reply.data(&data);
|
||||
}
|
||||
Ok(Err(e)) => {
|
||||
warn!(ino, offset, size_bytes = size, error = %e, "read failed");
|
||||
reply.error(libc::EIO);
|
||||
}
|
||||
Err(_timeout) => {
|
||||
warn!(ino, offset, size_bytes = size, "read timed out after 30s");
|
||||
reply.error(libc::EIO);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[instrument(level = "debug", skip(self, reply))]
|
||||
fn release(
|
||||
&mut self,
|
||||
_req: &Request,
|
||||
ino: u64,
|
||||
_fh: u64,
|
||||
_flags: i32,
|
||||
_lock_owner: Option<u64>,
|
||||
_flush: bool,
|
||||
reply: fuser::ReplyEmpty,
|
||||
) {
|
||||
trace!(ino, "releasing file handle");
|
||||
reply.ok();
|
||||
}
|
||||
|
||||
fn readlink(&mut self, _req: &Request, ino: u64, reply: ReplyData) {
|
||||
debug!("readlink(ino={})", ino);
|
||||
|
||||
if SearchOps::is_search_inode(ino) {
|
||||
if let Some(ref search_ops) = self.search_ops {
|
||||
search_ops.readlink(ino, reply);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
reply.error(libc::EINVAL);
|
||||
}
|
||||
|
||||
fn write(
|
||||
&mut self,
|
||||
_req: &Request,
|
||||
_ino: u64,
|
||||
_fh: u64,
|
||||
_offset: i64,
|
||||
_data: &[u8],
|
||||
_write_flags: u32,
|
||||
_flags: i32,
|
||||
_lock_owner: Option<u64>,
|
||||
reply: fuser::ReplyWrite,
|
||||
) {
|
||||
reply.error(libc::EROFS);
|
||||
}
|
||||
|
||||
fn mkdir(
|
||||
&mut self,
|
||||
_req: &Request,
|
||||
_parent: u64,
|
||||
_name: &OsStr,
|
||||
_mode: u32,
|
||||
_umask: u32,
|
||||
reply: ReplyEntry,
|
||||
) {
|
||||
reply.error(libc::EROFS);
|
||||
}
|
||||
|
||||
fn unlink(&mut self, _req: &Request, _parent: u64, _name: &OsStr, reply: fuser::ReplyEmpty) {
|
||||
reply.error(libc::EROFS);
|
||||
}
|
||||
|
||||
fn rmdir(&mut self, _req: &Request, _parent: u64, _name: &OsStr, reply: fuser::ReplyEmpty) {
|
||||
reply.error(libc::EROFS);
|
||||
}
|
||||
|
||||
fn rename(
|
||||
&mut self,
|
||||
_req: &Request,
|
||||
_parent: u64,
|
||||
_name: &OsStr,
|
||||
_newparent: u64,
|
||||
_newname: &OsStr,
|
||||
_flags: u32,
|
||||
reply: fuser::ReplyEmpty,
|
||||
) {
|
||||
reply.error(libc::EROFS);
|
||||
}
|
||||
|
||||
fn create(
|
||||
&mut self,
|
||||
_req: &Request,
|
||||
_parent: u64,
|
||||
_name: &OsStr,
|
||||
_mode: u32,
|
||||
_umask: u32,
|
||||
_flags: i32,
|
||||
reply: fuser::ReplyCreate,
|
||||
) {
|
||||
reply.error(libc::EROFS);
|
||||
}
|
||||
|
||||
fn setattr(
|
||||
&mut self,
|
||||
_req: &Request,
|
||||
_ino: u64,
|
||||
_mode: Option<u32>,
|
||||
_uid: Option<u32>,
|
||||
_gid: Option<u32>,
|
||||
_size: Option<u64>,
|
||||
_atime: Option<fuser::TimeOrNow>,
|
||||
_mtime: Option<fuser::TimeOrNow>,
|
||||
_ctime: Option<SystemTime>,
|
||||
_fh: Option<u64>,
|
||||
_crtime: Option<SystemTime>,
|
||||
_chgtime: Option<SystemTime>,
|
||||
_bkuptime: Option<SystemTime>,
|
||||
_flags: Option<u32>,
|
||||
reply: ReplyAttr,
|
||||
) {
|
||||
reply.error(libc::EROFS);
|
||||
}
|
||||
|
||||
fn symlink(
|
||||
&mut self,
|
||||
_req: &Request,
|
||||
_parent: u64,
|
||||
_name: &OsStr,
|
||||
_link: &Path,
|
||||
reply: ReplyEntry,
|
||||
) {
|
||||
reply.error(libc::EROFS);
|
||||
}
|
||||
|
||||
fn link(
|
||||
&mut self,
|
||||
_req: &Request,
|
||||
_ino: u64,
|
||||
_newparent: u64,
|
||||
_newname: &OsStr,
|
||||
reply: ReplyEntry,
|
||||
) {
|
||||
reply.error(libc::EROFS);
|
||||
}
|
||||
|
||||
fn mknod(
|
||||
&mut self,
|
||||
_req: &Request,
|
||||
_parent: u64,
|
||||
_name: &OsStr,
|
||||
_mode: u32,
|
||||
_umask: u32,
|
||||
_rdev: u32,
|
||||
reply: ReplyEntry,
|
||||
) {
|
||||
reply.error(libc::EROFS);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use musicfs_cache::TreeBuilder;
|
||||
use musicfs_core::{FileId, FileMeta, OriginId, RealPath, VirtualPath};
|
||||
use std::path::PathBuf;
|
||||
|
||||
fn make_file_meta(id: i64, vpath: &str, size: u64) -> FileMeta {
|
||||
FileMeta {
|
||||
id: FileId(id),
|
||||
virtual_path: VirtualPath::new(vpath),
|
||||
real_path: RealPath {
|
||||
origin_id: OriginId::from("test"),
|
||||
path: PathBuf::from("/test"),
|
||||
},
|
||||
size,
|
||||
mtime: SystemTime::now(),
|
||||
content_hash: None,
|
||||
audio: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_tree_integration() {
|
||||
let runtime = tokio::runtime::Runtime::new().unwrap();
|
||||
let handle = runtime.handle().clone();
|
||||
|
||||
let mut builder = TreeBuilder::new();
|
||||
builder.add_file(&make_file_meta(1, "/Artist/Album/Track.flac", 30_000_000));
|
||||
let tree = Arc::new(RwLock::new(builder.build()));
|
||||
|
||||
let _fs = MusicFs::new(tree.clone(), handle);
|
||||
|
||||
let tree_read = tree.read();
|
||||
assert!(tree_read.get(ROOT_INODE).is_some());
|
||||
assert!(tree_read
|
||||
.get_by_path(&VirtualPath::new("/Artist"))
|
||||
.is_some());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
mod filesystem;
|
||||
pub mod ops;
|
||||
|
||||
pub use filesystem::MusicFs;
|
||||
pub use ops::SearchOps;
|
||||
@@ -0,0 +1,5 @@
|
||||
mod prefetch;
|
||||
mod search;
|
||||
|
||||
pub use prefetch::PrefetchOps;
|
||||
pub use search::SearchOps;
|
||||
@@ -0,0 +1,298 @@
|
||||
use fuser::{FileAttr, FileType, ReplyAttr, ReplyData, ReplyDirectory, ReplyEntry};
|
||||
use musicfs_cache::{PatternStore, PrefetchConfig, PrefetchEngine};
|
||||
use musicfs_cas::ContentFetcher;
|
||||
use musicfs_core::{EventBus, FileId};
|
||||
use std::sync::Arc;
|
||||
use std::time::{Duration, SystemTime};
|
||||
|
||||
const PREFETCH_DIR_INODE: u64 = 0xFFFF_FFFF_0000_0002;
|
||||
const PREFETCH_STATUS_INODE: u64 = 0xFFFF_FFFF_0000_0003;
|
||||
const PREFETCH_HINTS_BASE: u64 = 0xFFFF_FFFF_2000_0000;
|
||||
|
||||
pub struct PrefetchOps {
|
||||
pattern_store: Arc<PatternStore>,
|
||||
engine: Option<Arc<PrefetchEngine>>,
|
||||
uid: u32,
|
||||
gid: u32,
|
||||
}
|
||||
|
||||
impl PrefetchOps {
|
||||
pub fn new(pattern_store: Arc<PatternStore>, uid: u32, gid: u32) -> Self {
|
||||
Self {
|
||||
pattern_store,
|
||||
engine: None,
|
||||
uid,
|
||||
gid,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn with_engine(
|
||||
pattern_store: Arc<PatternStore>,
|
||||
fetcher: Arc<ContentFetcher>,
|
||||
config: PrefetchConfig,
|
||||
uid: u32,
|
||||
gid: u32,
|
||||
) -> Self {
|
||||
let engine = Arc::new(PrefetchEngine::new(config, pattern_store.clone(), fetcher));
|
||||
|
||||
Self {
|
||||
pattern_store,
|
||||
engine: Some(engine),
|
||||
uid,
|
||||
gid,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn start_engine(&self, event_bus: Arc<EventBus>) -> Option<musicfs_cache::PrefetchHandle> {
|
||||
self.engine
|
||||
.as_ref()
|
||||
.map(|e| e.clone().start(event_bus, self.pattern_store.clone()))
|
||||
}
|
||||
|
||||
pub fn is_prefetch_dir_name(name: &str) -> bool {
|
||||
name == ".prefetch"
|
||||
}
|
||||
|
||||
pub fn is_prefetch_inode(inode: u64) -> bool {
|
||||
inode == PREFETCH_DIR_INODE
|
||||
|| inode == PREFETCH_STATUS_INODE
|
||||
|| inode >= PREFETCH_HINTS_BASE
|
||||
}
|
||||
|
||||
pub fn prefetch_dir_inode() -> u64 {
|
||||
PREFETCH_DIR_INODE
|
||||
}
|
||||
|
||||
pub fn lookup_prefetch_dir(&self, reply: ReplyEntry) {
|
||||
let attr = self.dir_attr(PREFETCH_DIR_INODE);
|
||||
reply.entry(&Duration::from_secs(60), &attr, 0);
|
||||
}
|
||||
|
||||
pub fn lookup_status(&self, reply: ReplyEntry) {
|
||||
let status = self.generate_status();
|
||||
let attr = self.file_attr(PREFETCH_STATUS_INODE, status.len() as u64);
|
||||
reply.entry(&Duration::from_secs(1), &attr, 0);
|
||||
}
|
||||
|
||||
pub fn lookup_hint(&self, name: &str, reply: ReplyEntry) {
|
||||
if let Some(inode) = self.hint_name_to_inode(name) {
|
||||
let attr = self.file_attr(inode, 256);
|
||||
reply.entry(&Duration::from_secs(1), &attr, 0);
|
||||
} else {
|
||||
reply.error(libc::ENOENT);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn getattr_prefetch_dir(&self, reply: ReplyAttr) {
|
||||
let attr = self.dir_attr(PREFETCH_DIR_INODE);
|
||||
reply.attr(&Duration::from_secs(60), &attr);
|
||||
}
|
||||
|
||||
pub fn getattr_status(&self, reply: ReplyAttr) {
|
||||
let status = self.generate_status();
|
||||
let attr = self.file_attr(PREFETCH_STATUS_INODE, status.len() as u64);
|
||||
reply.attr(&Duration::from_secs(1), &attr);
|
||||
}
|
||||
|
||||
pub fn getattr_hint(&self, inode: u64, reply: ReplyAttr) {
|
||||
let attr = self.file_attr(inode, 256);
|
||||
reply.attr(&Duration::from_secs(1), &attr);
|
||||
}
|
||||
|
||||
pub fn readdir_prefetch_root(&self, offset: i64, mut reply: ReplyDirectory) {
|
||||
let entries: Vec<(u64, FileType, &str)> = vec![
|
||||
(PREFETCH_DIR_INODE, FileType::Directory, "."),
|
||||
(1, FileType::Directory, ".."),
|
||||
(PREFETCH_STATUS_INODE, FileType::RegularFile, "status"),
|
||||
];
|
||||
|
||||
let recently_played = self.pattern_store.recently_played(7).unwrap_or_default();
|
||||
let predictions: Vec<(u64, FileType, String)> = recently_played
|
||||
.iter()
|
||||
.take(10)
|
||||
.enumerate()
|
||||
.map(|(i, file_id)| {
|
||||
let inode = PREFETCH_HINTS_BASE + i as u64;
|
||||
let name = format!("hint_{:04}", file_id.0);
|
||||
(inode, FileType::RegularFile, name)
|
||||
})
|
||||
.collect();
|
||||
|
||||
for (i, (inode, kind, name)) in entries.iter().enumerate().skip(offset as usize) {
|
||||
if reply.add(*inode, (i + 1) as i64, *kind, *name) {
|
||||
reply.ok();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
let base_offset = entries.len();
|
||||
for (i, (inode, kind, name)) in predictions.iter().enumerate() {
|
||||
let entry_offset = base_offset + i;
|
||||
if entry_offset < offset as usize {
|
||||
continue;
|
||||
}
|
||||
if reply.add(*inode, (entry_offset + 1) as i64, *kind, name) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
reply.ok();
|
||||
}
|
||||
|
||||
pub fn read_status(&self, offset: i64, size: u32, reply: ReplyData) {
|
||||
let status = self.generate_status();
|
||||
let start = offset as usize;
|
||||
let end = std::cmp::min(start + size as usize, status.len());
|
||||
|
||||
if start >= status.len() {
|
||||
reply.data(&[]);
|
||||
} else {
|
||||
reply.data(&status.as_bytes()[start..end]);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn read_hint(&self, inode: u64, offset: i64, size: u32, reply: ReplyData) {
|
||||
let file_id = self.inode_to_file_id(inode);
|
||||
let predictions = self.pattern_store.predict_next(file_id, 5);
|
||||
|
||||
let content = predictions
|
||||
.iter()
|
||||
.map(|id| format!("{}", id.0))
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n");
|
||||
|
||||
let start = offset as usize;
|
||||
let end = std::cmp::min(start + size as usize, content.len());
|
||||
|
||||
if start >= content.len() {
|
||||
reply.data(&[]);
|
||||
} else {
|
||||
reply.data(&content.as_bytes()[start..end]);
|
||||
}
|
||||
}
|
||||
|
||||
fn generate_status(&self) -> String {
|
||||
let engine_status = if let Some(engine) = &self.engine {
|
||||
format!(
|
||||
"running: {}\nin_flight: {}",
|
||||
engine.is_running(),
|
||||
engine.in_flight_count()
|
||||
)
|
||||
} else {
|
||||
"engine: disabled".to_string()
|
||||
};
|
||||
|
||||
let most_played = self
|
||||
.pattern_store
|
||||
.most_played(5)
|
||||
.unwrap_or_default()
|
||||
.iter()
|
||||
.map(|id| format!("{}", id.0))
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ");
|
||||
|
||||
format!(
|
||||
"MusicFS Prefetch Status\n\
|
||||
=======================\n\
|
||||
{}\n\
|
||||
most_played: [{}]\n",
|
||||
engine_status, most_played
|
||||
)
|
||||
}
|
||||
|
||||
fn hint_name_to_inode(&self, name: &str) -> Option<u64> {
|
||||
if name.starts_with("hint_") {
|
||||
let id_str = name.strip_prefix("hint_")?;
|
||||
let id: i64 = id_str.parse().ok()?;
|
||||
Some(PREFETCH_HINTS_BASE + id as u64)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
fn inode_to_file_id(&self, inode: u64) -> FileId {
|
||||
FileId((inode - PREFETCH_HINTS_BASE) as i64)
|
||||
}
|
||||
|
||||
fn dir_attr(&self, inode: u64) -> FileAttr {
|
||||
FileAttr {
|
||||
ino: inode,
|
||||
size: 0,
|
||||
blocks: 0,
|
||||
atime: SystemTime::UNIX_EPOCH,
|
||||
mtime: SystemTime::UNIX_EPOCH,
|
||||
ctime: SystemTime::UNIX_EPOCH,
|
||||
crtime: SystemTime::UNIX_EPOCH,
|
||||
kind: FileType::Directory,
|
||||
perm: 0o555,
|
||||
nlink: 2,
|
||||
uid: self.uid,
|
||||
gid: self.gid,
|
||||
rdev: 0,
|
||||
blksize: 512,
|
||||
flags: 0,
|
||||
}
|
||||
}
|
||||
|
||||
fn file_attr(&self, inode: u64, size: u64) -> FileAttr {
|
||||
FileAttr {
|
||||
ino: inode,
|
||||
size,
|
||||
blocks: (size + 511) / 512,
|
||||
atime: SystemTime::UNIX_EPOCH,
|
||||
mtime: SystemTime::UNIX_EPOCH,
|
||||
ctime: SystemTime::UNIX_EPOCH,
|
||||
crtime: SystemTime::UNIX_EPOCH,
|
||||
kind: FileType::RegularFile,
|
||||
perm: 0o444,
|
||||
nlink: 1,
|
||||
uid: self.uid,
|
||||
gid: self.gid,
|
||||
rdev: 0,
|
||||
blksize: 512,
|
||||
flags: 0,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use tempfile::TempDir;
|
||||
|
||||
#[test]
|
||||
fn test_prefetch_ops_new() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let pattern_store =
|
||||
Arc::new(PatternStore::new(&dir.path().join("patterns.db"), 30).unwrap());
|
||||
let _ops = PrefetchOps::new(pattern_store, 1000, 1000);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_is_prefetch_inode() {
|
||||
assert!(PrefetchOps::is_prefetch_inode(PREFETCH_DIR_INODE));
|
||||
assert!(PrefetchOps::is_prefetch_inode(PREFETCH_STATUS_INODE));
|
||||
assert!(PrefetchOps::is_prefetch_inode(PREFETCH_HINTS_BASE));
|
||||
assert!(PrefetchOps::is_prefetch_inode(PREFETCH_HINTS_BASE + 100));
|
||||
assert!(!PrefetchOps::is_prefetch_inode(1));
|
||||
assert!(!PrefetchOps::is_prefetch_inode(1000));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_hint_name_to_inode() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let pattern_store =
|
||||
Arc::new(PatternStore::new(&dir.path().join("patterns.db"), 30).unwrap());
|
||||
let ops = PrefetchOps::new(pattern_store, 1000, 1000);
|
||||
|
||||
assert_eq!(
|
||||
ops.hint_name_to_inode("hint_0001"),
|
||||
Some(PREFETCH_HINTS_BASE + 1)
|
||||
);
|
||||
assert_eq!(
|
||||
ops.hint_name_to_inode("hint_9999"),
|
||||
Some(PREFETCH_HINTS_BASE + 9999)
|
||||
);
|
||||
assert_eq!(ops.hint_name_to_inode("invalid"), None);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,273 @@
|
||||
use fuser::{FileAttr, FileType, ReplyAttr, ReplyData, ReplyDirectory, ReplyEntry};
|
||||
use moka::sync::Cache;
|
||||
use musicfs_search::{SearchHit, SearchIndex};
|
||||
use std::path::Path;
|
||||
use std::sync::Arc;
|
||||
use std::time::{Duration, SystemTime};
|
||||
|
||||
const SEARCH_DIR_INODE: u64 = 0xFFFF_FFFF_0000_0001;
|
||||
const SEARCH_RESULT_BASE: u64 = 0xFFFF_FFFF_1000_0000;
|
||||
const RESULT_CACHE_MAX_ENTRIES: u64 = 1000;
|
||||
const RESULT_CACHE_TTL_SECS: u64 = 300;
|
||||
const INODE_CACHE_MAX_ENTRIES: u64 = 10000;
|
||||
const MAX_QUERY_LENGTH: usize = 256;
|
||||
|
||||
pub struct SearchOps {
|
||||
index: Arc<SearchIndex>,
|
||||
result_cache: Cache<String, Vec<SearchHit>>,
|
||||
inode_to_result: Cache<u64, (String, usize)>,
|
||||
mount_point: String,
|
||||
uid: u32,
|
||||
gid: u32,
|
||||
}
|
||||
|
||||
impl SearchOps {
|
||||
pub fn new(index: Arc<SearchIndex>, mount_point: &str, uid: u32, gid: u32) -> Self {
|
||||
let result_cache = Cache::builder()
|
||||
.max_capacity(RESULT_CACHE_MAX_ENTRIES)
|
||||
.time_to_live(Duration::from_secs(RESULT_CACHE_TTL_SECS))
|
||||
.build();
|
||||
|
||||
let inode_to_result = Cache::builder()
|
||||
.max_capacity(INODE_CACHE_MAX_ENTRIES)
|
||||
.time_to_live(Duration::from_secs(RESULT_CACHE_TTL_SECS))
|
||||
.build();
|
||||
|
||||
Self {
|
||||
index,
|
||||
result_cache,
|
||||
inode_to_result,
|
||||
mount_point: mount_point.to_string(),
|
||||
uid,
|
||||
gid,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn is_search_dir_name(name: &str) -> bool {
|
||||
name == ".search"
|
||||
}
|
||||
|
||||
pub fn is_search_inode(inode: u64) -> bool {
|
||||
inode == SEARCH_DIR_INODE || inode >= SEARCH_RESULT_BASE
|
||||
}
|
||||
|
||||
pub fn search_dir_inode() -> u64 {
|
||||
SEARCH_DIR_INODE
|
||||
}
|
||||
|
||||
pub fn lookup_search_dir(&self, reply: ReplyEntry) {
|
||||
let attr = self.dir_attr(SEARCH_DIR_INODE);
|
||||
reply.entry(&Duration::from_secs(60), &attr, 0);
|
||||
}
|
||||
|
||||
pub fn lookup_query_dir(&self, query: &str, inode: u64, reply: ReplyEntry) {
|
||||
let results = self.execute_query(query);
|
||||
if results.is_empty() {
|
||||
reply.error(libc::ENOENT);
|
||||
return;
|
||||
}
|
||||
|
||||
let attr = self.dir_attr(inode);
|
||||
reply.entry(&Duration::from_secs(1), &attr, 0);
|
||||
}
|
||||
|
||||
pub fn lookup_result(&self, inode: u64, reply: ReplyEntry) {
|
||||
if self.inode_to_result.contains_key(&inode) {
|
||||
let attr = self.symlink_attr(inode, 256);
|
||||
reply.entry(&Duration::from_secs(1), &attr, 0);
|
||||
} else {
|
||||
reply.error(libc::ENOENT);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn getattr_search_dir(&self, reply: ReplyAttr) {
|
||||
let attr = self.dir_attr(SEARCH_DIR_INODE);
|
||||
reply.attr(&Duration::from_secs(60), &attr);
|
||||
}
|
||||
|
||||
pub fn getattr_result(&self, inode: u64, reply: ReplyAttr) {
|
||||
let attr = self.symlink_attr(inode, 256);
|
||||
reply.attr(&Duration::from_secs(1), &attr);
|
||||
}
|
||||
|
||||
pub fn readdir_search_root(&self, offset: i64, mut reply: ReplyDirectory) {
|
||||
let entries = vec![
|
||||
(SEARCH_DIR_INODE, FileType::Directory, "."),
|
||||
(1, FileType::Directory, ".."),
|
||||
];
|
||||
|
||||
for (i, (inode, kind, name)) in entries.iter().enumerate().skip(offset as usize) {
|
||||
if reply.add(*inode, (i + 1) as i64, *kind, name) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
reply.ok();
|
||||
}
|
||||
|
||||
pub fn readdir_query(&self, query: &str, offset: i64, mut reply: ReplyDirectory) {
|
||||
let results = self.execute_query(query);
|
||||
|
||||
let entries = vec![
|
||||
(SEARCH_DIR_INODE + 1, FileType::Directory, ".".to_string()),
|
||||
(SEARCH_DIR_INODE, FileType::Directory, "..".to_string()),
|
||||
];
|
||||
|
||||
for (i, (inode, kind, name)) in entries.iter().enumerate().skip(offset as usize) {
|
||||
if reply.add(*inode, (i + 1) as i64, *kind, name) {
|
||||
reply.ok();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
let base_offset = entries.len();
|
||||
for (i, hit) in results.iter().enumerate() {
|
||||
let entry_offset = base_offset + i;
|
||||
if entry_offset < offset as usize {
|
||||
continue;
|
||||
}
|
||||
|
||||
let inode = SEARCH_RESULT_BASE + i as u64;
|
||||
let name = self.result_filename(hit, i);
|
||||
|
||||
self.inode_to_result.insert(inode, (query.to_string(), i));
|
||||
|
||||
if reply.add(inode, (entry_offset + 1) as i64, FileType::Symlink, &name) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
reply.ok();
|
||||
}
|
||||
|
||||
pub fn readlink(&self, inode: u64, reply: ReplyData) {
|
||||
let (query, index) = match self.inode_to_result.get(&inode) {
|
||||
Some((q, i)) => (q, i),
|
||||
None => {
|
||||
reply.error(libc::ENOENT);
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
let results = self.execute_query(&query);
|
||||
if let Some(hit) = results.get(index) {
|
||||
if let Some(target) = self.safe_symlink_target(hit.virtual_path.as_str()) {
|
||||
reply.data(target.as_bytes());
|
||||
} else {
|
||||
reply.error(libc::EINVAL);
|
||||
}
|
||||
} else {
|
||||
reply.error(libc::ENOENT);
|
||||
}
|
||||
}
|
||||
|
||||
fn safe_symlink_target(&self, virtual_path: &str) -> Option<String> {
|
||||
let normalized = Path::new(virtual_path).components().fold(
|
||||
std::path::PathBuf::new(),
|
||||
|mut acc, comp| {
|
||||
match comp {
|
||||
std::path::Component::Normal(s) => acc.push(s),
|
||||
std::path::Component::RootDir => acc.push("/"),
|
||||
_ => {}
|
||||
}
|
||||
acc
|
||||
},
|
||||
);
|
||||
|
||||
let path_str = normalized.to_string_lossy();
|
||||
if path_str.contains("..") {
|
||||
return None;
|
||||
}
|
||||
|
||||
Some(format!("{}{}", self.mount_point, path_str))
|
||||
}
|
||||
|
||||
fn execute_query(&self, query: &str) -> Vec<SearchHit> {
|
||||
let query = if query.len() > MAX_QUERY_LENGTH {
|
||||
&query[..MAX_QUERY_LENGTH]
|
||||
} else {
|
||||
query
|
||||
};
|
||||
|
||||
if let Some(results) = self.result_cache.get(query) {
|
||||
return results;
|
||||
}
|
||||
|
||||
let results = self.index.search(query, 1000).unwrap_or_default();
|
||||
self.result_cache.insert(query.to_string(), results.clone());
|
||||
results
|
||||
}
|
||||
|
||||
fn result_filename(&self, hit: &SearchHit, index: usize) -> String {
|
||||
let artist = hit.artist.as_deref().unwrap_or("Unknown");
|
||||
let title = hit.title.as_deref().unwrap_or("Unknown");
|
||||
let ext = hit
|
||||
.virtual_path
|
||||
.as_str()
|
||||
.rsplit('.')
|
||||
.next()
|
||||
.unwrap_or("flac");
|
||||
format!("{:03}. {} - {}.{}", index + 1, artist, title, ext)
|
||||
}
|
||||
|
||||
fn dir_attr(&self, inode: u64) -> FileAttr {
|
||||
FileAttr {
|
||||
ino: inode,
|
||||
size: 0,
|
||||
blocks: 0,
|
||||
atime: SystemTime::UNIX_EPOCH,
|
||||
mtime: SystemTime::UNIX_EPOCH,
|
||||
ctime: SystemTime::UNIX_EPOCH,
|
||||
crtime: SystemTime::UNIX_EPOCH,
|
||||
kind: FileType::Directory,
|
||||
perm: 0o555,
|
||||
nlink: 2,
|
||||
uid: self.uid,
|
||||
gid: self.gid,
|
||||
rdev: 0,
|
||||
blksize: 512,
|
||||
flags: 0,
|
||||
}
|
||||
}
|
||||
|
||||
fn symlink_attr(&self, inode: u64, target_len: u64) -> FileAttr {
|
||||
FileAttr {
|
||||
ino: inode,
|
||||
size: target_len,
|
||||
blocks: 0,
|
||||
atime: SystemTime::UNIX_EPOCH,
|
||||
mtime: SystemTime::UNIX_EPOCH,
|
||||
ctime: SystemTime::UNIX_EPOCH,
|
||||
crtime: SystemTime::UNIX_EPOCH,
|
||||
kind: FileType::Symlink,
|
||||
perm: 0o777,
|
||||
nlink: 1,
|
||||
uid: self.uid,
|
||||
gid: self.gid,
|
||||
rdev: 0,
|
||||
blksize: 512,
|
||||
flags: 0,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use musicfs_search::SearchIndex;
|
||||
use tempfile::TempDir;
|
||||
|
||||
#[test]
|
||||
fn test_search_ops_new() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let index = Arc::new(SearchIndex::open(dir.path()).unwrap());
|
||||
let _ops = SearchOps::new(index, "/mnt/music", 1000, 1000);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_is_search_inode() {
|
||||
assert!(SearchOps::is_search_inode(SEARCH_DIR_INODE));
|
||||
assert!(SearchOps::is_search_inode(SEARCH_RESULT_BASE));
|
||||
assert!(SearchOps::is_search_inode(SEARCH_RESULT_BASE + 100));
|
||||
assert!(!SearchOps::is_search_inode(1));
|
||||
assert!(!SearchOps::is_search_inode(1000));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user