Implement Week 1 foundation: workspace, core types, FUSE skeleton, LocalOrigin

- musicfs-core: OriginId, FileId, VirtualPath, ContentHash, AudioMeta,
  FileMeta, EventBus with FileAccessed event (5 tests)
- musicfs-fuse: FUSE skeleton with EROFS handlers for write ops
- musicfs-origins: Origin trait with watch(), LocalOrigin impl (6 tests)
- flake.nix: Nix dev shell with rust toolchain, clang, lld, fuse3

All 11 tests pass. Build produces no warnings.
This commit is contained in:
Alexander
2026-05-12 18:01:47 +02:00
parent e08988f7f3
commit 76856b893a
35 changed files with 1933 additions and 0 deletions
+6
View File
@@ -0,0 +1,6 @@
[package]
name = "musicfs-cache"
version.workspace = true
edition.workspace = true
[dependencies]
+1
View File
@@ -0,0 +1 @@
#![allow(dead_code)]
+6
View File
@@ -0,0 +1,6 @@
[package]
name = "musicfs-cas"
version.workspace = true
edition.workspace = true
[dependencies]
+1
View File
@@ -0,0 +1 @@
#![allow(dead_code)]
+10
View File
@@ -0,0 +1,10 @@
[package]
name = "musicfs-cli"
version.workspace = true
edition.workspace = true
[[bin]]
name = "musicfs"
path = "src/main.rs"
[dependencies]
+1
View File
@@ -0,0 +1 @@
#![allow(dead_code)]
+3
View File
@@ -0,0 +1,3 @@
fn main() {
println!("MusicFS CLI - placeholder");
}
+11
View File
@@ -0,0 +1,11 @@
[package]
name = "musicfs-core"
version.workspace = true
edition.workspace = true
[dependencies]
thiserror.workspace = true
serde.workspace = true
tokio = { workspace = true, features = ["sync"] }
xxhash-rust.workspace = true
hex = "0.4"
+30
View File
@@ -0,0 +1,30 @@
use thiserror::Error;
#[derive(Error, Debug)]
pub enum Error {
#[error("I/O error: {0}")]
Io(#[from] std::io::Error),
#[error("Origin not found: {0}")]
OriginNotFound(String),
#[error("File not found: {0}")]
FileNotFound(String),
#[error("Path resolution failed: {0}")]
PathResolution(String),
#[error("Cache error: {0}")]
Cache(String),
#[error("Database error: {0}")]
Database(String),
#[error("NFS stale file handle")]
NfsStaleHandle,
#[error("Operation not permitted (read-only filesystem)")]
ReadOnly,
}
pub type Result<T> = std::result::Result<T, Error>;
+96
View File
@@ -0,0 +1,96 @@
use crate::types::{OriginId, VirtualPath};
use tokio::sync::broadcast;
pub struct EventBus {
sender: broadcast::Sender<Event>,
}
impl EventBus {
pub fn new(capacity: usize) -> Self {
let (sender, _) = broadcast::channel(capacity);
Self { sender }
}
pub fn publish(&self, event: Event) {
let _ = self.sender.send(event);
}
pub fn subscribe(&self) -> broadcast::Receiver<Event> {
self.sender.subscribe()
}
}
impl Default for EventBus {
fn default() -> Self {
Self::new(1024)
}
}
#[derive(Clone, Debug)]
pub enum Event {
FileAdded {
path: VirtualPath,
origin_id: OriginId,
},
FileRemoved {
path: VirtualPath,
},
FileModified {
path: VirtualPath,
},
FileAccessed {
path: VirtualPath,
origin_id: OriginId,
offset: u64,
size: u32,
},
OriginConnected {
origin_id: OriginId,
},
OriginDisconnected {
origin_id: OriginId,
},
SyncStarted {
origin_id: OriginId,
},
SyncCompleted {
origin_id: OriginId,
files_changed: u64,
},
CacheEviction {
bytes_freed: u64,
},
}
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn test_event_bus() {
let bus = EventBus::new(16);
let mut rx = bus.subscribe();
bus.publish(Event::SyncStarted {
origin_id: OriginId::from("test"),
});
let event = rx.recv().await.unwrap();
assert!(matches!(event, Event::SyncStarted { .. }));
}
#[tokio::test]
async fn test_event_bus_multiple_subscribers() {
let bus = EventBus::new(16);
let mut rx1 = bus.subscribe();
let mut rx2 = bus.subscribe();
bus.publish(Event::CacheEviction { bytes_freed: 1024 });
let e1 = rx1.recv().await.unwrap();
let e2 = rx2.recv().await.unwrap();
assert!(matches!(e1, Event::CacheEviction { bytes_freed: 1024 }));
assert!(matches!(e2, Event::CacheEviction { bytes_freed: 1024 }));
}
}
+7
View File
@@ -0,0 +1,7 @@
pub mod error;
pub mod events;
pub mod types;
pub use error::{Error, Result};
pub use events::{Event, EventBus};
pub use types::*;
+179
View File
@@ -0,0 +1,179 @@
use serde::{Deserialize, Serialize};
use std::path::PathBuf;
use std::time::SystemTime;
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub struct OriginId(pub String);
impl From<&str> for OriginId {
fn from(s: &str) -> Self {
Self(s.to_string())
}
}
impl std::fmt::Display for OriginId {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", self.0)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub struct FileId(pub i64);
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub struct VirtualPath(pub PathBuf);
impl VirtualPath {
pub fn new(path: impl Into<PathBuf>) -> Self {
Self(path.into())
}
pub fn as_path(&self) -> &std::path::Path {
&self.0
}
pub fn as_str(&self) -> &str {
self.0.to_str().unwrap_or("")
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct RealPath {
pub origin_id: OriginId,
pub path: PathBuf,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub struct ContentHash(pub [u8; 8]);
impl ContentHash {
pub fn from_bytes(data: &[u8]) -> Self {
use xxhash_rust::xxh64::xxh64;
Self(xxh64(data, 0).to_le_bytes())
}
pub fn to_hex(&self) -> String {
hex::encode(self.0)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub struct ChunkHash(pub [u8; 8]);
impl ChunkHash {
pub fn from_bytes(data: &[u8]) -> Self {
use xxhash_rust::xxh64::xxh64;
Self(xxh64(data, 0).to_le_bytes())
}
pub fn to_hex(&self) -> String {
hex::encode(self.0)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
pub enum AudioFormat {
Flac,
Mp3,
Opus,
Vorbis,
Aac,
Alac,
Wav,
#[default]
Unknown,
}
impl AudioFormat {
pub fn from_extension(ext: &str) -> Self {
match ext.to_lowercase().as_str() {
"flac" => Self::Flac,
"mp3" => Self::Mp3,
"opus" => Self::Opus,
"ogg" => Self::Vorbis,
"m4a" | "aac" => Self::Aac,
"wav" => Self::Wav,
_ => Self::Unknown,
}
}
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct AudioMeta {
pub title: Option<String>,
pub artist: Option<String>,
pub album: Option<String>,
pub album_artist: Option<String>,
pub genre: Option<String>,
pub year: Option<u32>,
pub track: Option<u32>,
pub disc: Option<u32>,
pub duration_ms: Option<u64>,
pub bitrate: Option<u32>,
pub sample_rate: Option<u32>,
pub format: AudioFormat,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct FileMeta {
pub id: FileId,
pub virtual_path: VirtualPath,
pub real_path: RealPath,
pub size: u64,
pub mtime: SystemTime,
pub content_hash: Option<ContentHash>,
pub audio: Option<AudioMeta>,
}
#[derive(Debug, Clone)]
pub struct DirEntry {
pub name: String,
pub is_dir: bool,
pub size: u64,
pub mtime: SystemTime,
}
#[derive(Debug, Clone)]
pub struct FileStat {
pub size: u64,
pub mtime: SystemTime,
pub is_dir: bool,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum HealthStatus {
Healthy,
Degraded,
Unhealthy,
#[default]
Unknown,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_content_hash() {
let data = b"hello world";
let hash1 = ContentHash::from_bytes(data);
let hash2 = ContentHash::from_bytes(data);
assert_eq!(hash1, hash2);
let hash3 = ContentHash::from_bytes(b"different");
assert_ne!(hash1, hash3);
}
#[test]
fn test_audio_format_from_extension() {
assert_eq!(AudioFormat::from_extension("flac"), AudioFormat::Flac);
assert_eq!(AudioFormat::from_extension("MP3"), AudioFormat::Mp3);
assert_eq!(AudioFormat::from_extension("unknown"), AudioFormat::Unknown);
}
#[test]
fn test_virtual_path() {
let path = VirtualPath::new("/Artist/Album/Track.flac");
assert_eq!(path.as_str(), "/Artist/Album/Track.flac");
}
}
+11
View File
@@ -0,0 +1,11 @@
[package]
name = "musicfs-fuse"
version.workspace = true
edition.workspace = true
[dependencies]
musicfs-core = { path = "../musicfs-core" }
fuser.workspace = true
tokio.workspace = true
tracing.workspace = true
libc = "0.2"
@@ -0,0 +1,277 @@
use fuser::{
FileAttr, FileType, Filesystem, ReplyAttr, ReplyData, ReplyDirectory, ReplyEntry, ReplyOpen,
Request, FUSE_ROOT_ID,
};
use musicfs_core::{Error, Result};
use std::ffi::OsStr;
use std::path::Path;
use std::time::{Duration, SystemTime, UNIX_EPOCH};
use tracing::{debug, info};
const TTL: Duration = Duration::from_secs(1);
pub struct MusicFs {
uid: u32,
gid: u32,
}
impl MusicFs {
pub fn new() -> Self {
Self {
uid: unsafe { libc::getuid() },
gid: unsafe { libc::getgid() },
}
}
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(Error::Io)?;
Ok(())
}
fn root_attr(&self) -> FileAttr {
FileAttr {
ino: FUSE_ROOT_ID,
size: 0,
blocks: 0,
atime: UNIX_EPOCH,
mtime: UNIX_EPOCH,
ctime: UNIX_EPOCH,
crtime: UNIX_EPOCH,
kind: FileType::Directory,
perm: 0o755,
nlink: 2,
uid: self.uid,
gid: self.gid,
rdev: 0,
blksize: 512,
flags: 0,
}
}
}
impl Default for MusicFs {
fn default() -> Self {
Self::new()
}
}
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");
}
fn lookup(&mut self, _req: &Request, parent: u64, name: &OsStr, reply: ReplyEntry) {
debug!("lookup(parent={}, name={:?})", parent, name);
reply.error(libc::ENOENT);
}
fn getattr(&mut self, _req: &Request, ino: u64, reply: ReplyAttr) {
debug!("getattr(ino={})", ino);
if ino == FUSE_ROOT_ID {
reply.attr(&TTL, &self.root_attr());
} else {
reply.error(libc::ENOENT);
}
}
fn readdir(
&mut self,
_req: &Request,
ino: u64,
_fh: u64,
offset: i64,
mut reply: ReplyDirectory,
) {
debug!("readdir(ino={}, offset={})", ino, offset);
if ino == FUSE_ROOT_ID {
if offset == 0 {
let _ = reply.add(FUSE_ROOT_ID, 1, FileType::Directory, ".");
}
if offset <= 1 {
let _ = reply.add(FUSE_ROOT_ID, 2, FileType::Directory, "..");
}
reply.ok();
} else {
reply.error(libc::ENOENT);
}
}
fn open(&mut self, _req: &Request, ino: u64, flags: i32, reply: ReplyOpen) {
debug!("open(ino={}, flags={})", ino, flags);
let write_flags = libc::O_WRONLY | libc::O_RDWR | libc::O_APPEND | libc::O_TRUNC;
if flags & write_flags != 0 {
reply.error(libc::EROFS);
return;
}
reply.error(libc::ENOENT);
}
fn read(
&mut self,
_req: &Request,
ino: u64,
_fh: u64,
offset: i64,
size: u32,
_flags: i32,
_lock_owner: Option<u64>,
reply: ReplyData,
) {
debug!("read(ino={}, offset={}, size={})", ino, offset, size);
reply.error(libc::ENOENT);
}
fn release(
&mut self,
_req: &Request,
ino: u64,
_fh: u64,
_flags: i32,
_lock_owner: Option<u64>,
_flush: bool,
reply: fuser::ReplyEmpty,
) {
debug!("release(ino={})", ino);
reply.ok();
}
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);
}
}
+3
View File
@@ -0,0 +1,3 @@
mod filesystem;
pub use filesystem::MusicFs;
+6
View File
@@ -0,0 +1,6 @@
[package]
name = "musicfs-grpc"
version.workspace = true
edition.workspace = true
[dependencies]
+1
View File
@@ -0,0 +1 @@
#![allow(dead_code)]
@@ -0,0 +1,6 @@
[package]
name = "musicfs-metadata"
version.workspace = true
edition.workspace = true
[dependencies]
@@ -0,0 +1 @@
#![allow(dead_code)]
+13
View File
@@ -0,0 +1,13 @@
[package]
name = "musicfs-origins"
version.workspace = true
edition.workspace = true
[dependencies]
musicfs-core = { path = "../musicfs-core" }
async-trait.workspace = true
tokio = { workspace = true, features = ["fs", "sync"] }
tracing.workspace = true
[dev-dependencies]
tempfile.workspace = true
@@ -0,0 +1,5 @@
mod local;
mod traits;
pub use local::LocalOrigin;
pub use traits::{Origin, OriginType, WatchCallback, WatchEvent, WatchHandle};
+200
View File
@@ -0,0 +1,200 @@
use crate::traits::{Origin, OriginType, WatchCallback, WatchHandle};
use async_trait::async_trait;
use musicfs_core::{DirEntry, FileStat, HealthStatus, OriginId, Result};
use std::path::{Path, PathBuf};
use tokio::fs;
use tokio::io::AsyncRead;
use tracing::debug;
pub struct LocalOrigin {
id: OriginId,
root: PathBuf,
display_name: String,
}
impl LocalOrigin {
pub fn new(id: impl Into<OriginId>, root: impl Into<PathBuf>) -> Self {
let root = root.into();
let display_name = format!("Local: {}", root.display());
Self {
id: id.into(),
root,
display_name,
}
}
fn full_path(&self, path: &Path) -> PathBuf {
if path.as_os_str().is_empty() || path == Path::new("/") {
self.root.clone()
} else {
self.root.join(path.strip_prefix("/").unwrap_or(path))
}
}
}
#[async_trait]
impl Origin for LocalOrigin {
fn id(&self) -> &OriginId {
&self.id
}
fn origin_type(&self) -> OriginType {
OriginType::Local
}
fn display_name(&self) -> &str {
&self.display_name
}
async fn readdir(&self, path: &Path) -> Result<Vec<DirEntry>> {
let full_path = self.full_path(path);
debug!("LocalOrigin::readdir({:?})", full_path);
let mut entries = Vec::new();
let mut dir = fs::read_dir(&full_path).await?;
while let Some(entry) = dir.next_entry().await? {
let metadata = entry.metadata().await?;
let name = entry.file_name().to_string_lossy().into_owned();
entries.push(DirEntry {
name,
is_dir: metadata.is_dir(),
size: metadata.len(),
mtime: metadata.modified().unwrap_or(std::time::UNIX_EPOCH),
});
}
Ok(entries)
}
async fn stat(&self, path: &Path) -> Result<FileStat> {
let full_path = self.full_path(path);
debug!("LocalOrigin::stat({:?})", full_path);
let metadata = fs::metadata(&full_path).await?;
Ok(FileStat {
size: metadata.len(),
mtime: metadata.modified().unwrap_or(std::time::UNIX_EPOCH),
is_dir: metadata.is_dir(),
})
}
async fn read(&self, path: &Path, offset: u64, size: u32) -> Result<Vec<u8>> {
use tokio::io::{AsyncReadExt, AsyncSeekExt};
let full_path = self.full_path(path);
debug!(
"LocalOrigin::read({:?}, offset={}, size={})",
full_path, offset, size
);
let mut file = fs::File::open(&full_path).await?;
file.seek(std::io::SeekFrom::Start(offset)).await?;
let mut buffer = vec![0u8; size as usize];
let bytes_read = file.read(&mut buffer).await?;
buffer.truncate(bytes_read);
Ok(buffer)
}
async fn exists(&self, path: &Path) -> Result<bool> {
let full_path = self.full_path(path);
Ok(fs::try_exists(&full_path).await?)
}
async fn health(&self) -> HealthStatus {
match fs::try_exists(&self.root).await {
Ok(true) => HealthStatus::Healthy,
Ok(false) => HealthStatus::Unhealthy,
Err(_) => HealthStatus::Unhealthy,
}
}
async fn open_read(&self, path: &Path) -> Result<Box<dyn AsyncRead + Send + Unpin>> {
let full_path = self.full_path(path);
let file = fs::File::open(&full_path).await?;
Ok(Box::new(file))
}
async fn watch(&self, path: &Path, _callback: WatchCallback) -> Result<WatchHandle> {
debug!("LocalOrigin::watch({:?}) - stub implementation", path);
let (tx, _rx) = tokio::sync::oneshot::channel();
Ok(WatchHandle::new(tx))
}
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
#[tokio::test]
async fn test_local_origin_readdir() {
let dir = TempDir::new().unwrap();
std::fs::write(dir.path().join("test.txt"), "hello").unwrap();
std::fs::create_dir(dir.path().join("subdir")).unwrap();
let origin = LocalOrigin::new("test", dir.path());
let entries = origin.readdir(Path::new("/")).await.unwrap();
assert_eq!(entries.len(), 2);
assert!(entries.iter().any(|e| e.name == "test.txt" && !e.is_dir));
assert!(entries.iter().any(|e| e.name == "subdir" && e.is_dir));
}
#[tokio::test]
async fn test_local_origin_stat() {
let dir = TempDir::new().unwrap();
std::fs::write(dir.path().join("test.txt"), "hello world").unwrap();
let origin = LocalOrigin::new("test", dir.path());
let stat = origin.stat(Path::new("/test.txt")).await.unwrap();
assert_eq!(stat.size, 11);
assert!(!stat.is_dir);
}
#[tokio::test]
async fn test_local_origin_read() {
let dir = TempDir::new().unwrap();
std::fs::write(dir.path().join("test.txt"), "hello world").unwrap();
let origin = LocalOrigin::new("test", dir.path());
let data = origin.read(Path::new("/test.txt"), 0, 5).await.unwrap();
assert_eq!(data, b"hello");
}
#[tokio::test]
async fn test_local_origin_read_offset() {
let dir = TempDir::new().unwrap();
std::fs::write(dir.path().join("test.txt"), "hello world").unwrap();
let origin = LocalOrigin::new("test", dir.path());
let data = origin.read(Path::new("/test.txt"), 6, 5).await.unwrap();
assert_eq!(data, b"world");
}
#[tokio::test]
async fn test_local_origin_exists() {
let dir = TempDir::new().unwrap();
std::fs::write(dir.path().join("test.txt"), "hello").unwrap();
let origin = LocalOrigin::new("test", dir.path());
assert!(origin.exists(Path::new("/test.txt")).await.unwrap());
assert!(!origin.exists(Path::new("/nonexistent.txt")).await.unwrap());
}
#[tokio::test]
async fn test_local_origin_health() {
let dir = TempDir::new().unwrap();
let origin = LocalOrigin::new("test", dir.path());
assert_eq!(origin.health().await, HealthStatus::Healthy);
}
}
@@ -0,0 +1,55 @@
use async_trait::async_trait;
use musicfs_core::{DirEntry, FileStat, HealthStatus, OriginId, Result};
use std::path::{Path, PathBuf};
use tokio::io::AsyncRead;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum OriginType {
Local,
Nfs,
Smb,
S3,
Sftp,
}
#[async_trait]
pub trait Origin: Send + Sync {
fn id(&self) -> &OriginId;
fn origin_type(&self) -> OriginType;
fn display_name(&self) -> &str;
async fn readdir(&self, path: &Path) -> Result<Vec<DirEntry>>;
async fn stat(&self, path: &Path) -> Result<FileStat>;
async fn read(&self, path: &Path, offset: u64, size: u32) -> Result<Vec<u8>>;
async fn exists(&self, path: &Path) -> Result<bool>;
async fn health(&self) -> HealthStatus;
async fn open_read(&self, path: &Path) -> Result<Box<dyn AsyncRead + Send + Unpin>>;
async fn watch(&self, path: &Path, callback: WatchCallback) -> Result<WatchHandle>;
}
pub type WatchCallback = Box<dyn Fn(WatchEvent) + Send + Sync>;
pub struct WatchHandle {
_cancel: tokio::sync::oneshot::Sender<()>,
}
impl WatchHandle {
pub fn new(cancel: tokio::sync::oneshot::Sender<()>) -> Self {
Self { _cancel: cancel }
}
}
#[derive(Debug, Clone)]
pub enum WatchEvent {
Created(PathBuf),
Modified(PathBuf),
Deleted(PathBuf),
}
@@ -0,0 +1,6 @@
[package]
name = "musicfs-plugins"
version.workspace = true
edition.workspace = true
[dependencies]
@@ -0,0 +1 @@
#![allow(dead_code)]
+6
View File
@@ -0,0 +1,6 @@
[package]
name = "musicfs-search"
version.workspace = true
edition.workspace = true
[dependencies]
+1
View File
@@ -0,0 +1 @@
#![allow(dead_code)]
+6
View File
@@ -0,0 +1,6 @@
[package]
name = "musicfs-sync"
version.workspace = true
edition.workspace = true
[dependencies]
+1
View File
@@ -0,0 +1 @@
#![allow(dead_code)]