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:
@@ -0,0 +1,6 @@
|
||||
[package]
|
||||
name = "musicfs-cache"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
|
||||
[dependencies]
|
||||
@@ -0,0 +1 @@
|
||||
#![allow(dead_code)]
|
||||
@@ -0,0 +1,6 @@
|
||||
[package]
|
||||
name = "musicfs-cas"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
|
||||
[dependencies]
|
||||
@@ -0,0 +1 @@
|
||||
#![allow(dead_code)]
|
||||
@@ -0,0 +1,10 @@
|
||||
[package]
|
||||
name = "musicfs-cli"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
|
||||
[[bin]]
|
||||
name = "musicfs"
|
||||
path = "src/main.rs"
|
||||
|
||||
[dependencies]
|
||||
@@ -0,0 +1 @@
|
||||
#![allow(dead_code)]
|
||||
@@ -0,0 +1,3 @@
|
||||
fn main() {
|
||||
println!("MusicFS CLI - placeholder");
|
||||
}
|
||||
@@ -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"
|
||||
@@ -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>;
|
||||
@@ -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 }));
|
||||
}
|
||||
}
|
||||
@@ -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::*;
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
mod filesystem;
|
||||
|
||||
pub use filesystem::MusicFs;
|
||||
@@ -0,0 +1,6 @@
|
||||
[package]
|
||||
name = "musicfs-grpc"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
|
||||
[dependencies]
|
||||
@@ -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)]
|
||||
@@ -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};
|
||||
@@ -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)]
|
||||
@@ -0,0 +1,6 @@
|
||||
[package]
|
||||
name = "musicfs-search"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
|
||||
[dependencies]
|
||||
@@ -0,0 +1 @@
|
||||
#![allow(dead_code)]
|
||||
@@ -0,0 +1,6 @@
|
||||
[package]
|
||||
name = "musicfs-sync"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
|
||||
[dependencies]
|
||||
@@ -0,0 +1 @@
|
||||
#![allow(dead_code)]
|
||||
Reference in New Issue
Block a user