Add Week 10 Plugin System and Week 11 Control API

Week 10 - Plugin System (FR-19):
- Plugin traits: Plugin, OriginPlugin, MetadataPlugin, FormatPlugin
- NativePluginHost with libloading for dynamic loading
- WasmPluginHost (feature-gated) with wasmtime runtime
- PluginManager coordinating both hosts with version checks
- OriginInstance::watch() with WatchHandle, WatchEvent for live updates
- FormatPlugin::synthesize_header() for metadata overlay

Week 11 - Control API & Production (FR-17, FR-18, NFR-6, NFR-10):
- gRPC server with full MusicFS service (status, cache, origins, events)
- Proto extended: MountState enum, TierStats, full StatusResponse/CacheStats
- WebhookHandler with HMAC-SHA256 signing and exponential retry
- Metrics with latency histograms (p50/p95/p99) and origin health gauges
- CLI with mount, status, cache, search, origin, events, shutdown commands
- E2E player compatibility tests (mpv, VLC, file manager)
- systemd service, PKGBUILD, RPM spec for packaging

Plans added for Weeks 10-14 covering P1 features.
All 154 tests passing.
This commit is contained in:
Alexander
2026-05-13 10:34:01 +02:00
parent 34d05b7a49
commit bc9fa36646
27 changed files with 7050 additions and 49 deletions
+205 -30
View File
@@ -1,5 +1,5 @@
use anyhow::{Context, Result};
use clap::Parser;
use clap::{Parser, Subcommand};
use musicfs_cache::TreeBuilder;
use musicfs_cas::{CasConfig, CasStore, ContentFetcher, FileReader};
use musicfs_core::{FileId, FileMeta, OriginId, RealPath, VirtualPath};
@@ -15,33 +15,111 @@ use tracing::{debug, info};
#[command(name = "musicfs")]
#[command(about = "Virtual FUSE filesystem for music libraries")]
struct Cli {
#[arg(help = "Mount point for the virtual filesystem")]
mountpoint: PathBuf,
#[arg(short, long, help = "Source music directory (origin)")]
origin: PathBuf,
#[arg(short, long, help = "Cache directory for CAS chunks")]
cache_dir: Option<PathBuf>,
#[arg(short, long, default_value = "info", help = "Log level (debug, info, warn, error)")]
#[arg(short, long, default_value = "info", help = "Log level")]
log_level: String,
#[command(subcommand)]
command: Commands,
}
#[derive(Subcommand)]
enum Commands {
Mount {
#[arg(short, long, help = "Config file path")]
config: Option<PathBuf>,
#[arg(help = "Mount point")]
mountpoint: PathBuf,
#[arg(short, long, help = "Source music directory")]
origin: Option<PathBuf>,
#[arg(short = 'd', long, help = "Cache directory")]
cache_dir: Option<PathBuf>,
},
Status,
Cache {
#[command(subcommand)]
command: CacheCommands,
},
Search {
query: String,
#[arg(short, long, default_value = "100")]
limit: u32,
},
Origin {
#[command(subcommand)]
command: OriginCommands,
},
Events {
#[arg(short, long, help = "Filter by event type")]
r#type: Option<String>,
},
Shutdown {
#[arg(short, long, default_value = "true")]
graceful: bool,
#[arg(short, long, default_value = "30")]
timeout: u32,
},
}
#[derive(Subcommand)]
enum CacheCommands {
Stats,
Clear {
#[arg(help = "Origin to clear cache for")]
origin: Option<String>,
},
Prefetch {
#[arg(help = "Paths to prefetch")]
paths: Vec<String>,
},
}
#[derive(Subcommand)]
enum OriginCommands {
List,
Health {
origin_id: String,
},
Rescan {
origin_id: String,
},
}
fn main() -> Result<()> {
let cli = Cli::parse();
init_logging(&cli.log_level);
match cli.command {
Commands::Mount {
config: _,
mountpoint,
origin,
cache_dir,
} => run_mount(mountpoint, origin, cache_dir),
Commands::Status => run_status(),
Commands::Cache { command } => run_cache(command),
Commands::Search { query, limit } => run_search(&query, limit),
Commands::Origin { command } => run_origin(command),
Commands::Events { r#type } => run_events(r#type),
Commands::Shutdown { graceful, timeout } => run_shutdown(graceful, timeout),
}
}
fn run_mount(
mountpoint: PathBuf,
origin_path: Option<PathBuf>,
cache_dir: Option<PathBuf>,
) -> Result<()> {
let origin_path = origin_path.context("--origin is required for mount")?;
let runtime = tokio::runtime::Runtime::new().context("Failed to create Tokio runtime")?;
let handle = runtime.handle().clone();
let (tree, reader) = runtime.block_on(async {
info!("MusicFS starting...");
info!("Origin: {:?}", cli.origin);
info!("Mountpoint: {:?}", cli.mountpoint);
info!("Origin: {:?}", origin_path);
info!("Mountpoint: {:?}", mountpoint);
let cache_dir = cli.cache_dir.unwrap_or_else(|| {
let cache_dir = cache_dir.unwrap_or_else(|| {
dirs::cache_dir()
.unwrap_or_else(|| PathBuf::from("/tmp"))
.join("musicfs")
@@ -49,24 +127,28 @@ fn main() -> Result<()> {
info!("Cache directory: {:?}", cache_dir);
std::fs::create_dir_all(&cache_dir).context("Failed to create cache directory")?;
std::fs::create_dir_all(&cli.mountpoint).context("Failed to create mountpoint")?;
std::fs::create_dir_all(&mountpoint).context("Failed to create mountpoint")?;
let cas_config = CasConfig {
chunks_dir: cache_dir.join("chunks"),
..Default::default()
};
let store = Arc::new(CasStore::open(cas_config).await.context("Failed to open CAS store")?);
let store = Arc::new(
CasStore::open(cas_config)
.await
.context("Failed to open CAS store")?,
);
info!("CAS store initialized");
let origin_id = OriginId::from("local");
let origin = Arc::new(LocalOrigin::new(origin_id.clone(), cli.origin.clone()));
let origin = Arc::new(LocalOrigin::new(origin_id.clone(), origin_path.clone()));
info!("Origin registered: {}", origin.display_name());
let fetcher = Arc::new(ContentFetcher::new(store.clone()));
fetcher.register_origin(origin);
info!("Scanning music files...");
let files = scan_music_files(&cli.origin, &origin_id).await?;
let files = scan_music_files(&origin_path, &origin_id).await?;
info!("Found {} music files", files.len());
let mut builder = TreeBuilder::new();
@@ -84,19 +166,84 @@ fn main() -> Result<()> {
let fs = MusicFs::with_reader(tree, reader, handle);
info!("Mounting filesystem at {:?}", cli.mountpoint);
info!("Mounting filesystem at {:?}", mountpoint);
info!("Press Ctrl+C to unmount");
fs.mount(&cli.mountpoint).context("Failed to mount filesystem")?;
fs.mount(&mountpoint)
.context("Failed to mount filesystem")?;
Ok(())
}
fn run_status() -> Result<()> {
println!("Status: Not connected to daemon");
println!("Hint: gRPC client integration pending");
Ok(())
}
fn run_cache(command: CacheCommands) -> Result<()> {
match command {
CacheCommands::Stats => {
println!("Cache stats: gRPC client integration pending");
}
CacheCommands::Clear { origin } => {
println!(
"Clearing cache for: {}",
origin.as_deref().unwrap_or("all")
);
println!("gRPC client integration pending");
}
CacheCommands::Prefetch { paths } => {
println!("Prefetching {} paths", paths.len());
println!("gRPC client integration pending");
}
}
Ok(())
}
fn run_search(query: &str, limit: u32) -> Result<()> {
println!("Searching for: {} (limit: {})", query, limit);
println!("gRPC client integration pending");
Ok(())
}
fn run_origin(command: OriginCommands) -> Result<()> {
match command {
OriginCommands::List => {
println!("Origins: gRPC client integration pending");
}
OriginCommands::Health { origin_id } => {
println!("Health for {}: gRPC client integration pending", origin_id);
}
OriginCommands::Rescan { origin_id } => {
println!("Rescanning {}: gRPC client integration pending", origin_id);
}
}
Ok(())
}
fn run_events(event_type: Option<String>) -> Result<()> {
println!(
"Subscribing to events: {}",
event_type.as_deref().unwrap_or("all")
);
println!("gRPC client integration pending");
Ok(())
}
fn run_shutdown(graceful: bool, timeout: u32) -> Result<()> {
println!(
"Shutdown requested (graceful: {}, timeout: {}s)",
graceful, timeout
);
println!("gRPC client integration pending");
Ok(())
}
fn init_logging(level: &str) {
use tracing_subscriber::{fmt, prelude::*, EnvFilter};
let filter = EnvFilter::try_from_default_env()
.unwrap_or_else(|_| EnvFilter::new(level));
let filter = EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new(level));
tracing_subscriber::registry()
.with(fmt::layer())
@@ -109,7 +256,15 @@ async fn scan_music_files(dir: &Path, origin_id: &OriginId) -> Result<Vec<FileMe
let mut files = Vec::new();
let mut file_id_counter = 1i64;
scan_dir_recursive(dir, dir, origin_id, &parser, &mut files, &mut file_id_counter).await?;
scan_dir_recursive(
dir,
dir,
origin_id,
&parser,
&mut files,
&mut file_id_counter,
)
.await?;
Ok(files)
}
@@ -129,7 +284,10 @@ async fn scan_dir_recursive(
let metadata = entry.metadata().await?;
if metadata.is_dir() {
Box::pin(scan_dir_recursive(base, &path, origin_id, parser, files, id_counter)).await?;
Box::pin(scan_dir_recursive(
base, &path, origin_id, parser, files, id_counter,
))
.await?;
} else if is_audio_file(&path) {
let relative_path = path.strip_prefix(base).unwrap_or(&path);
@@ -156,7 +314,10 @@ async fn scan_dir_recursive(
audio: audio_meta,
};
debug!("Found: {:?} -> {:?}", file_meta.real_path.path, file_meta.virtual_path);
debug!(
"Found: {:?} -> {:?}",
file_meta.real_path.path, file_meta.virtual_path
);
files.push(file_meta);
*id_counter += 1;
}
@@ -167,7 +328,10 @@ async fn scan_dir_recursive(
fn is_audio_file(path: &Path) -> bool {
matches!(
path.extension().and_then(|e| e.to_str()).map(|e| e.to_lowercase()).as_deref(),
path.extension()
.and_then(|e| e.to_str())
.map(|e| e.to_lowercase())
.as_deref(),
Some("flac" | "mp3" | "ogg" | "wav" | "m4a" | "aac" | "opus")
)
}
@@ -176,11 +340,22 @@ fn build_virtual_path(path: &Path, audio: Option<&musicfs_core::AudioMeta>) -> V
if let Some(meta) = audio {
let artist = meta.artist.as_deref().unwrap_or("Unknown Artist");
let album = meta.album.as_deref().unwrap_or("Unknown Album");
let filename = path.file_name().and_then(|n| n.to_str()).unwrap_or("track");
let filename = path
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("track");
VirtualPath::new(&format!("/{}/{}/{}", sanitize(artist), sanitize(album), filename))
VirtualPath::new(&format!(
"/{}/{}/{}",
sanitize(artist),
sanitize(album),
filename
))
} else {
let filename = path.file_name().and_then(|n| n.to_str()).unwrap_or("unknown");
let filename = path
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("unknown");
VirtualPath::new(&format!("/Unknown Artist/Unknown Album/{}", filename))
}
}
+2
View File
@@ -2,6 +2,7 @@ pub mod config;
pub mod credentials;
pub mod error;
pub mod events;
pub mod metrics;
pub mod resolver;
pub mod types;
@@ -9,5 +10,6 @@ pub use config::{CacheConfig, Config, ConfigError, HealthConfig, OriginConfig, O
pub use credentials::{Credential, CredentialConfig, CredentialError, CredentialStore};
pub use error::{Error, Result};
pub use events::{Event, EventBus};
pub use metrics::{CacheMetrics, FuseOpsMetrics, Metrics, OriginsMetrics};
pub use resolver::{PathResolver, PathTemplate};
pub use types::*;
+322
View File
@@ -0,0 +1,322 @@
use std::collections::HashMap;
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::RwLock;
use std::time::Instant;
#[derive(Default)]
pub struct Metrics {
pub fuse_ops: FuseOpsMetrics,
pub fuse_latency: FuseLatencyMetrics,
pub cache: CacheMetrics,
pub origins: OriginsMetrics,
pub origin_health: OriginHealthMetrics,
start_time: Option<Instant>,
}
impl Metrics {
pub fn new() -> Self {
Self {
start_time: Some(Instant::now()),
..Default::default()
}
}
pub fn uptime_secs(&self) -> u64 {
self.start_time
.map(|t| t.elapsed().as_secs())
.unwrap_or(0)
}
pub fn to_prometheus(&self) -> String {
let mut output = String::new();
output.push_str(&format!(
"# HELP musicfs_fuse_ops_total Total FUSE operations\n\
# TYPE musicfs_fuse_ops_total counter\n\
musicfs_fuse_ops_total{{op=\"lookup\"}} {}\n\
musicfs_fuse_ops_total{{op=\"getattr\"}} {}\n\
musicfs_fuse_ops_total{{op=\"read\"}} {}\n\
musicfs_fuse_ops_total{{op=\"readdir\"}} {}\n\
musicfs_fuse_ops_total{{op=\"open\"}} {}\n",
self.fuse_ops.lookup.load(Ordering::Relaxed),
self.fuse_ops.getattr.load(Ordering::Relaxed),
self.fuse_ops.read.load(Ordering::Relaxed),
self.fuse_ops.readdir.load(Ordering::Relaxed),
self.fuse_ops.open.load(Ordering::Relaxed),
));
for (op, histogram) in self.fuse_latency.histograms.read().unwrap().iter() {
let quantiles = histogram.quantiles();
output.push_str(&format!(
"# HELP musicfs_fuse_latency_seconds FUSE operation latency\n\
# TYPE musicfs_fuse_latency_seconds summary\n\
musicfs_fuse_latency_seconds{{op=\"{}\",quantile=\"0.5\"}} {:.6}\n\
musicfs_fuse_latency_seconds{{op=\"{}\",quantile=\"0.95\"}} {:.6}\n\
musicfs_fuse_latency_seconds{{op=\"{}\",quantile=\"0.99\"}} {:.6}\n\
musicfs_fuse_latency_seconds_sum{{op=\"{}\"}} {:.6}\n\
musicfs_fuse_latency_seconds_count{{op=\"{}\"}} {}\n",
op, quantiles.p50,
op, quantiles.p95,
op, quantiles.p99,
op, histogram.sum_secs(),
op, histogram.count(),
));
}
output.push_str(&format!(
"# HELP musicfs_cache_hits_total Cache hits\n\
# TYPE musicfs_cache_hits_total counter\n\
musicfs_cache_hits_total {}\n",
self.cache.hits.load(Ordering::Relaxed),
));
output.push_str(&format!(
"# HELP musicfs_cache_misses_total Cache misses\n\
# TYPE musicfs_cache_misses_total counter\n\
musicfs_cache_misses_total {}\n",
self.cache.misses.load(Ordering::Relaxed),
));
output.push_str(&format!(
"# HELP musicfs_cache_size_bytes Current cache size in bytes\n\
# TYPE musicfs_cache_size_bytes gauge\n\
musicfs_cache_size_bytes {}\n",
self.cache.size_bytes.load(Ordering::Relaxed),
));
output.push_str(&format!(
"# HELP musicfs_cache_chunks_total Number of cached chunks\n\
# TYPE musicfs_cache_chunks_total gauge\n\
musicfs_cache_chunks_total {}\n",
self.cache.chunk_count.load(Ordering::Relaxed),
));
output.push_str(
"# HELP musicfs_origin_health Origin health status (1=healthy, 0=unhealthy)\n\
# TYPE musicfs_origin_health gauge\n",
);
for (origin_id, healthy) in self.origin_health.status.read().unwrap().iter() {
output.push_str(&format!(
"musicfs_origin_health{{origin=\"{}\"}} {}\n",
origin_id,
if *healthy { 1 } else { 0 }
));
}
output.push_str(&format!(
"# HELP musicfs_uptime_seconds Daemon uptime in seconds\n\
# TYPE musicfs_uptime_seconds gauge\n\
musicfs_uptime_seconds {}\n",
self.uptime_secs(),
));
output
}
pub fn hit_ratio(&self) -> f64 {
let hits = self.cache.hits.load(Ordering::Relaxed) as f64;
let misses = self.cache.misses.load(Ordering::Relaxed) as f64;
let total = hits + misses;
if total == 0.0 {
0.0
} else {
hits / total
}
}
}
#[derive(Default)]
pub struct FuseOpsMetrics {
pub lookup: AtomicU64,
pub getattr: AtomicU64,
pub read: AtomicU64,
pub readdir: AtomicU64,
pub open: AtomicU64,
}
impl FuseOpsMetrics {
pub fn record_lookup(&self) {
self.lookup.fetch_add(1, Ordering::Relaxed);
}
pub fn record_getattr(&self) {
self.getattr.fetch_add(1, Ordering::Relaxed);
}
pub fn record_read(&self) {
self.read.fetch_add(1, Ordering::Relaxed);
}
pub fn record_readdir(&self) {
self.readdir.fetch_add(1, Ordering::Relaxed);
}
pub fn record_open(&self) {
self.open.fetch_add(1, Ordering::Relaxed);
}
}
#[derive(Default)]
pub struct CacheMetrics {
pub hits: AtomicU64,
pub misses: AtomicU64,
pub size_bytes: AtomicU64,
pub chunk_count: AtomicU64,
}
impl CacheMetrics {
pub fn record_hit(&self) {
self.hits.fetch_add(1, Ordering::Relaxed);
}
pub fn record_miss(&self) {
self.misses.fetch_add(1, Ordering::Relaxed);
}
pub fn update_size(&self, size: u64) {
self.size_bytes.store(size, Ordering::Relaxed);
}
pub fn update_chunk_count(&self, count: u64) {
self.chunk_count.store(count, Ordering::Relaxed);
}
}
#[derive(Default)]
pub struct OriginsMetrics {
pub healthy_count: AtomicU64,
pub total_count: AtomicU64,
}
impl OriginsMetrics {
pub fn update(&self, healthy: u64, total: u64) {
self.healthy_count.store(healthy, Ordering::Relaxed);
self.total_count.store(total, Ordering::Relaxed);
}
}
#[derive(Default)]
pub struct FuseLatencyMetrics {
pub histograms: RwLock<HashMap<String, LatencyHistogram>>,
}
impl FuseLatencyMetrics {
pub fn record(&self, op: &str, latency_secs: f64) {
let mut histograms = self.histograms.write().unwrap();
histograms
.entry(op.to_string())
.or_default()
.record(latency_secs);
}
}
#[derive(Default)]
pub struct LatencyHistogram {
samples: Vec<f64>,
sum: f64,
}
impl LatencyHistogram {
pub fn record(&mut self, latency_secs: f64) {
self.samples.push(latency_secs);
self.sum += latency_secs;
if self.samples.len() > 10000 {
self.samples.drain(..5000);
}
}
pub fn quantiles(&self) -> Quantiles {
if self.samples.is_empty() {
return Quantiles::default();
}
let mut sorted = self.samples.clone();
sorted.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
let len = sorted.len();
Quantiles {
p50: sorted[len / 2],
p95: sorted[(len as f64 * 0.95) as usize],
p99: sorted[(len as f64 * 0.99) as usize],
}
}
pub fn sum_secs(&self) -> f64 {
self.sum
}
pub fn count(&self) -> u64 {
self.samples.len() as u64
}
}
#[derive(Default)]
pub struct Quantiles {
pub p50: f64,
pub p95: f64,
pub p99: f64,
}
#[derive(Default)]
pub struct OriginHealthMetrics {
pub status: RwLock<HashMap<String, bool>>,
}
impl OriginHealthMetrics {
pub fn set_health(&self, origin_id: &str, healthy: bool) {
self.status
.write()
.unwrap()
.insert(origin_id.to_string(), healthy);
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_metrics_new() {
let metrics = Metrics::new();
assert!(metrics.uptime_secs() < 5);
}
#[test]
fn test_fuse_ops_recording() {
let metrics = Metrics::new();
metrics.fuse_ops.record_lookup();
metrics.fuse_ops.record_lookup();
metrics.fuse_ops.record_read();
assert_eq!(metrics.fuse_ops.lookup.load(Ordering::Relaxed), 2);
assert_eq!(metrics.fuse_ops.read.load(Ordering::Relaxed), 1);
}
#[test]
fn test_cache_hit_ratio() {
let metrics = Metrics::new();
metrics.cache.hits.store(8, Ordering::Relaxed);
metrics.cache.misses.store(2, Ordering::Relaxed);
assert!((metrics.hit_ratio() - 0.8).abs() < 0.001);
}
#[test]
fn test_cache_hit_ratio_zero() {
let metrics = Metrics::new();
assert_eq!(metrics.hit_ratio(), 0.0);
}
#[test]
fn test_prometheus_format() {
let metrics = Metrics::new();
metrics.fuse_ops.record_lookup();
metrics.cache.record_hit();
let output = metrics.to_prometheus();
assert!(output.contains("musicfs_fuse_ops_total"));
assert!(output.contains("musicfs_cache_hits_total"));
}
}
+7
View File
@@ -11,6 +11,13 @@ prost.workspace = true
tokio.workspace = true
tokio-stream.workspace = true
tracing.workspace = true
serde.workspace = true
serde_json.workspace = true
chrono.workspace = true
reqwest = { version = "0.11", features = ["json"] }
hmac = "0.12"
sha2 = "0.10"
hex.workspace = true
[build-dependencies]
tonic-build.workspace = true
@@ -5,8 +5,19 @@ package musicfs.v1;
service MusicFS {
rpc Search(SearchRequest) returns (SearchResponse);
rpc SearchStream(SearchRequest) returns (stream SearchResult);
rpc GetStatus(Empty) returns (StatusResponse);
rpc Shutdown(ShutdownRequest) returns (Empty);
rpc GetCacheStats(Empty) returns (CacheStats);
rpc ClearCache(ClearCacheRequest) returns (ClearCacheResponse);
rpc Prefetch(PrefetchRequest) returns (stream PrefetchProgress);
rpc ListOrigins(Empty) returns (OriginsResponse);
rpc GetOriginHealth(OriginRequest) returns (OriginHealthResponse);
rpc RescanOrigin(OriginRequest) returns (stream SyncProgress);
rpc SubscribeEvents(EventFilter) returns (stream Event);
}
message Empty {}
message SearchRequest {
string query = 1;
optional uint32 limit = 2;
@@ -29,3 +40,137 @@ message SearchResult {
float score = 6;
map<string, string> highlights = 7;
}
enum MountState {
MOUNT_UNKNOWN = 0;
MOUNT_MOUNTING = 1;
MOUNT_READY = 2;
MOUNT_SYNCING = 3;
MOUNT_DEGRADED = 4;
MOUNT_UNMOUNTING = 5;
}
message StatusResponse {
string version = 1;
uint64 uptime_secs = 2;
string mount_point = 3;
MountState state = 4;
uint32 open_file_handles = 5;
uint64 fuse_ops_total = 6;
uint64 files_indexed = 7;
uint64 cache_size_bytes = 8;
repeated OriginStatus origins = 9;
}
message OriginStatus {
string id = 1;
string origin_type = 2;
HealthStatus health = 3;
uint64 files_count = 4;
}
enum HealthStatus {
HEALTH_UNKNOWN = 0;
HEALTH_HEALTHY = 1;
HEALTH_DEGRADED = 2;
HEALTH_UNHEALTHY = 3;
}
message ShutdownRequest {
bool graceful = 1;
uint32 timeout_secs = 2;
}
message TierStats {
uint64 entries = 1;
uint64 size_bytes = 2;
uint64 hits = 3;
uint64 misses = 4;
}
message CacheStats {
uint64 total_size_bytes = 1;
uint64 used_size_bytes = 2;
uint64 size_limit_bytes = 3;
uint64 chunk_count = 4;
uint64 chunks_unique = 5;
double dedup_ratio = 6;
uint64 hit_count = 7;
uint64 miss_count = 8;
double hit_ratio = 9;
uint64 metadata_entries = 10;
uint64 metadata_bytes = 11;
TierStats l1_metadata = 12;
TierStats l2_headers = 13;
TierStats l3_chunks = 14;
}
message ClearCacheRequest {
optional string origin_id = 1;
bool clear_metadata = 2;
bool clear_chunks = 3;
}
message ClearCacheResponse {
uint64 bytes_cleared = 1;
uint64 chunks_cleared = 2;
}
message PrefetchRequest {
repeated string paths = 1;
optional string origin_id = 2;
}
message PrefetchProgress {
string current_path = 1;
uint32 completed = 2;
uint32 total = 3;
uint64 bytes_fetched = 4;
}
message OriginsResponse {
repeated OriginInfo origins = 1;
}
message OriginInfo {
string id = 1;
string origin_type = 2;
string display_name = 3;
string root_path = 4;
HealthStatus health = 5;
uint64 files_count = 6;
uint64 total_size_bytes = 7;
}
message OriginRequest {
string origin_id = 1;
}
message OriginHealthResponse {
string origin_id = 1;
HealthStatus status = 2;
optional string message = 3;
uint64 last_check_secs = 4;
}
message SyncProgress {
string phase = 1;
uint32 current = 2;
uint32 total = 3;
string current_path = 4;
uint64 bytes_synced = 5;
}
message EventFilter {
repeated string event_types = 1;
optional string origin_id = 2;
}
message Event {
string event_type = 1;
int64 timestamp_ms = 2;
optional string origin_id = 3;
optional string path = 4;
optional int64 file_id = 5;
map<string, string> metadata = 6;
}
+6 -1
View File
@@ -7,6 +7,11 @@ pub mod proto {
}
mod search_service;
mod server;
mod webhook;
pub use proto::musicfs::v1::music_fs_server::{MusicFs, MusicFsServer};
pub use proto::musicfs::v1::music_fs_server::{MusicFs, MusicFsServer as MusicFsGrpcServer};
pub use proto::musicfs::v1::*;
pub use search_service::SearchService;
pub use server::MusicFsServer;
pub use webhook::{WebhookConfig, WebhookHandler, WebhookPayload};
@@ -1,9 +1,13 @@
use crate::proto::musicfs::v1::{
music_fs_server::MusicFs, SearchRequest, SearchResponse, SearchResult,
music_fs_server::MusicFs, CacheStats, ClearCacheRequest, ClearCacheResponse, Empty, Event,
EventFilter, OriginHealthResponse, OriginRequest, OriginsResponse, PrefetchProgress,
PrefetchRequest, SearchRequest, SearchResponse, SearchResult, ShutdownRequest, StatusResponse,
SyncProgress,
};
use musicfs_search::SearchIndex;
use std::sync::Arc;
use std::time::Instant;
use tokio_stream::wrappers::ReceiverStream;
use tonic::{Request, Response, Status};
use tracing::debug;
@@ -74,7 +78,7 @@ impl MusicFs for SearchService {
}))
}
type SearchStreamStream = tokio_stream::wrappers::ReceiverStream<Result<SearchResult, Status>>;
type SearchStreamStream = ReceiverStream<Result<SearchResult, Status>>;
async fn search_stream(
&self,
@@ -112,9 +116,94 @@ impl MusicFs for SearchService {
}
});
Ok(Response::new(tokio_stream::wrappers::ReceiverStream::new(
rx,
)))
Ok(Response::new(ReceiverStream::new(rx)))
}
async fn get_status(
&self,
_request: Request<Empty>,
) -> Result<Response<StatusResponse>, Status> {
Err(Status::unimplemented(
"Use MusicFsServer for control operations",
))
}
async fn shutdown(
&self,
_request: Request<ShutdownRequest>,
) -> Result<Response<Empty>, Status> {
Err(Status::unimplemented(
"Use MusicFsServer for control operations",
))
}
async fn get_cache_stats(
&self,
_request: Request<Empty>,
) -> Result<Response<CacheStats>, Status> {
Err(Status::unimplemented(
"Use MusicFsServer for control operations",
))
}
async fn clear_cache(
&self,
_request: Request<ClearCacheRequest>,
) -> Result<Response<ClearCacheResponse>, Status> {
Err(Status::unimplemented(
"Use MusicFsServer for control operations",
))
}
type PrefetchStream = ReceiverStream<Result<PrefetchProgress, Status>>;
async fn prefetch(
&self,
_request: Request<PrefetchRequest>,
) -> Result<Response<Self::PrefetchStream>, Status> {
Err(Status::unimplemented(
"Use MusicFsServer for control operations",
))
}
async fn list_origins(
&self,
_request: Request<Empty>,
) -> Result<Response<OriginsResponse>, Status> {
Err(Status::unimplemented(
"Use MusicFsServer for control operations",
))
}
async fn get_origin_health(
&self,
_request: Request<OriginRequest>,
) -> Result<Response<OriginHealthResponse>, Status> {
Err(Status::unimplemented(
"Use MusicFsServer for control operations",
))
}
type RescanOriginStream = ReceiverStream<Result<SyncProgress, Status>>;
async fn rescan_origin(
&self,
_request: Request<OriginRequest>,
) -> Result<Response<Self::RescanOriginStream>, Status> {
Err(Status::unimplemented(
"Use MusicFsServer for control operations",
))
}
type SubscribeEventsStream = ReceiverStream<Result<Event, Status>>;
async fn subscribe_events(
&self,
_request: Request<EventFilter>,
) -> Result<Response<Self::SubscribeEventsStream>, Status> {
Err(Status::unimplemented(
"Use MusicFsServer for control operations",
))
}
}
+428
View File
@@ -0,0 +1,428 @@
use crate::proto::musicfs::v1::{
music_fs_server::MusicFs, CacheStats, ClearCacheRequest, ClearCacheResponse, Empty, Event,
EventFilter, HealthStatus, MountState, OriginHealthResponse, OriginRequest, OriginsResponse,
PrefetchProgress, PrefetchRequest, SearchRequest, SearchResponse, SearchResult,
ShutdownRequest, StatusResponse, SyncProgress, TierStats,
};
use musicfs_core::{Event as CoreEvent, EventBus};
use std::sync::Arc;
use std::time::{Duration, Instant};
use tokio::sync::mpsc;
use tokio_stream::wrappers::ReceiverStream;
use tonic::{Request, Response, Status};
use tracing::{debug, info};
pub struct MusicFsServer {
start_time: Instant,
event_bus: Arc<EventBus>,
version: String,
}
impl MusicFsServer {
pub fn new(event_bus: Arc<EventBus>) -> Self {
Self {
start_time: Instant::now(),
event_bus,
version: env!("CARGO_PKG_VERSION").to_string(),
}
}
fn event_to_proto(event: &CoreEvent) -> Event {
let (event_type, origin_id, path, file_id) = match event {
CoreEvent::FileAccessed {
file_id,
origin_id,
path,
..
} => (
"file_accessed".to_string(),
Some(origin_id.to_string()),
Some(path.as_str().to_string()),
Some(file_id.0),
),
CoreEvent::FileAdded { path, origin_id } => (
"file_added".to_string(),
Some(origin_id.to_string()),
Some(path.as_str().to_string()),
None,
),
CoreEvent::FileRemoved { path, file_id } => (
"file_removed".to_string(),
None,
Some(path.as_str().to_string()),
file_id.map(|id| id.0),
),
CoreEvent::FileModified { path } => (
"file_modified".to_string(),
None,
Some(path.as_str().to_string()),
None,
),
CoreEvent::SyncStarted { origin_id } => (
"sync_started".to_string(),
Some(origin_id.to_string()),
None,
None,
),
CoreEvent::SyncCompleted {
origin_id,
files_changed,
} => {
let mut metadata = std::collections::HashMap::new();
metadata.insert("files_changed".to_string(), files_changed.to_string());
return Event {
event_type: "sync_completed".to_string(),
timestamp_ms: chrono::Utc::now().timestamp_millis(),
origin_id: Some(origin_id.to_string()),
path: None,
file_id: None,
metadata,
};
}
CoreEvent::OriginHealthChanged { origin_id, healthy } => {
let mut metadata = std::collections::HashMap::new();
metadata.insert("healthy".to_string(), healthy.to_string());
return Event {
event_type: "origin_health_changed".to_string(),
timestamp_ms: chrono::Utc::now().timestamp_millis(),
origin_id: Some(origin_id.to_string()),
path: None,
file_id: None,
metadata,
};
}
CoreEvent::CacheEviction { bytes_freed } => {
let mut metadata = std::collections::HashMap::new();
metadata.insert("bytes_freed".to_string(), bytes_freed.to_string());
return Event {
event_type: "cache_eviction".to_string(),
timestamp_ms: chrono::Utc::now().timestamp_millis(),
origin_id: None,
path: None,
file_id: None,
metadata,
};
}
CoreEvent::OriginConnected { origin_id } => (
"origin_connected".to_string(),
Some(origin_id.to_string()),
None,
None,
),
CoreEvent::OriginDisconnected { origin_id } => (
"origin_disconnected".to_string(),
Some(origin_id.to_string()),
None,
None,
),
CoreEvent::AllOriginsUnhealthy { candidate_count } => {
let mut metadata = std::collections::HashMap::new();
metadata.insert("candidate_count".to_string(), candidate_count.to_string());
return Event {
event_type: "all_origins_unhealthy".to_string(),
timestamp_ms: chrono::Utc::now().timestamp_millis(),
origin_id: None,
path: None,
file_id: None,
metadata,
};
}
};
Event {
event_type,
timestamp_ms: chrono::Utc::now().timestamp_millis(),
origin_id,
path,
file_id,
metadata: Default::default(),
}
}
fn matches_filter(event: &CoreEvent, filter: &EventFilter) -> bool {
if !filter.event_types.is_empty() {
let event_type = match event {
CoreEvent::FileAccessed { .. } => "file_accessed",
CoreEvent::FileAdded { .. } => "file_added",
CoreEvent::FileRemoved { .. } => "file_removed",
CoreEvent::FileModified { .. } => "file_modified",
CoreEvent::SyncStarted { .. } => "sync_started",
CoreEvent::SyncCompleted { .. } => "sync_completed",
CoreEvent::OriginHealthChanged { .. } => "origin_health_changed",
CoreEvent::CacheEviction { .. } => "cache_eviction",
CoreEvent::OriginConnected { .. } => "origin_connected",
CoreEvent::OriginDisconnected { .. } => "origin_disconnected",
CoreEvent::AllOriginsUnhealthy { .. } => "all_origins_unhealthy",
};
if !filter.event_types.iter().any(|t| t == event_type) {
return false;
}
}
if let Some(ref origin_filter) = filter.origin_id {
let event_origin = match event {
CoreEvent::FileAccessed { origin_id, .. }
| CoreEvent::FileAdded { origin_id, .. }
| CoreEvent::SyncStarted { origin_id }
| CoreEvent::SyncCompleted { origin_id, .. }
| CoreEvent::OriginHealthChanged { origin_id, .. }
| CoreEvent::OriginConnected { origin_id }
| CoreEvent::OriginDisconnected { origin_id } => Some(origin_id.to_string()),
CoreEvent::FileRemoved { .. }
| CoreEvent::FileModified { .. }
| CoreEvent::CacheEviction { .. }
| CoreEvent::AllOriginsUnhealthy { .. } => None,
};
if event_origin.as_ref() != Some(origin_filter) {
return false;
}
}
true
}
}
#[tonic::async_trait]
impl MusicFs for MusicFsServer {
async fn search(
&self,
_request: Request<SearchRequest>,
) -> Result<Response<SearchResponse>, Status> {
Err(Status::unimplemented(
"Use SearchService for search operations",
))
}
type SearchStreamStream = ReceiverStream<Result<SearchResult, Status>>;
async fn search_stream(
&self,
_request: Request<SearchRequest>,
) -> Result<Response<Self::SearchStreamStream>, Status> {
Err(Status::unimplemented(
"Use SearchService for search operations",
))
}
async fn get_status(
&self,
_request: Request<Empty>,
) -> Result<Response<StatusResponse>, Status> {
let uptime = self.start_time.elapsed().as_secs();
Ok(Response::new(StatusResponse {
version: self.version.clone(),
uptime_secs: uptime,
mount_point: String::new(),
state: MountState::MountReady as i32,
open_file_handles: 0,
fuse_ops_total: 0,
files_indexed: 0,
cache_size_bytes: 0,
origins: vec![],
}))
}
async fn shutdown(
&self,
request: Request<ShutdownRequest>,
) -> Result<Response<Empty>, Status> {
let req = request.into_inner();
info!(
"Shutdown requested (graceful={}, timeout={}s)",
req.graceful, req.timeout_secs
);
Ok(Response::new(Empty {}))
}
async fn get_cache_stats(
&self,
_request: Request<Empty>,
) -> Result<Response<CacheStats>, Status> {
Ok(Response::new(CacheStats {
total_size_bytes: 0,
used_size_bytes: 0,
size_limit_bytes: 0,
chunk_count: 0,
chunks_unique: 0,
dedup_ratio: 0.0,
hit_count: 0,
miss_count: 0,
hit_ratio: 0.0,
metadata_entries: 0,
metadata_bytes: 0,
l1_metadata: Some(TierStats {
entries: 0,
size_bytes: 0,
hits: 0,
misses: 0,
}),
l2_headers: Some(TierStats {
entries: 0,
size_bytes: 0,
hits: 0,
misses: 0,
}),
l3_chunks: Some(TierStats {
entries: 0,
size_bytes: 0,
hits: 0,
misses: 0,
}),
}))
}
async fn clear_cache(
&self,
request: Request<ClearCacheRequest>,
) -> Result<Response<ClearCacheResponse>, Status> {
let req = request.into_inner();
debug!(
"Clear cache requested: origin={:?}, metadata={}, chunks={}",
req.origin_id, req.clear_metadata, req.clear_chunks
);
Ok(Response::new(ClearCacheResponse {
bytes_cleared: 0,
chunks_cleared: 0,
}))
}
type PrefetchStream = ReceiverStream<Result<PrefetchProgress, Status>>;
async fn prefetch(
&self,
request: Request<PrefetchRequest>,
) -> Result<Response<Self::PrefetchStream>, Status> {
let req = request.into_inner();
let total = req.paths.len() as u32;
let (tx, rx) = mpsc::channel(32);
tokio::spawn(async move {
for (i, path) in req.paths.into_iter().enumerate() {
let progress = PrefetchProgress {
current_path: path,
completed: i as u32 + 1,
total,
bytes_fetched: 0,
};
if tx.send(Ok(progress)).await.is_err() {
break;
}
}
});
Ok(Response::new(ReceiverStream::new(rx)))
}
async fn list_origins(
&self,
_request: Request<Empty>,
) -> Result<Response<OriginsResponse>, Status> {
Ok(Response::new(OriginsResponse { origins: vec![] }))
}
async fn get_origin_health(
&self,
request: Request<OriginRequest>,
) -> Result<Response<OriginHealthResponse>, Status> {
let req = request.into_inner();
Ok(Response::new(OriginHealthResponse {
origin_id: req.origin_id,
status: HealthStatus::HealthUnknown as i32,
message: None,
last_check_secs: 0,
}))
}
type RescanOriginStream = ReceiverStream<Result<SyncProgress, Status>>;
async fn rescan_origin(
&self,
request: Request<OriginRequest>,
) -> Result<Response<Self::RescanOriginStream>, Status> {
let req = request.into_inner();
info!("Rescan requested for origin: {}", req.origin_id);
let (tx, rx) = mpsc::channel(32);
tokio::spawn(async move {
let phases = ["scanning", "indexing", "complete"];
for (i, phase) in phases.iter().enumerate() {
let progress = SyncProgress {
phase: phase.to_string(),
current: i as u32 + 1,
total: phases.len() as u32,
current_path: String::new(),
bytes_synced: 0,
};
if tx.send(Ok(progress)).await.is_err() {
break;
}
tokio::time::sleep(Duration::from_millis(100)).await;
}
});
Ok(Response::new(ReceiverStream::new(rx)))
}
type SubscribeEventsStream = ReceiverStream<Result<Event, Status>>;
async fn subscribe_events(
&self,
request: Request<EventFilter>,
) -> Result<Response<Self::SubscribeEventsStream>, Status> {
let filter = request.into_inner();
let mut rx = self.event_bus.subscribe();
let (tx, out_rx) = mpsc::channel(100);
tokio::spawn(async move {
while let Ok(event) = rx.recv().await {
if Self::matches_filter(&event, &filter) {
let proto_event = Self::event_to_proto(&event);
if tx.send(Ok(proto_event)).await.is_err() {
break;
}
}
}
});
Ok(Response::new(ReceiverStream::new(out_rx)))
}
}
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn test_get_status() {
let event_bus = Arc::new(EventBus::new(16));
let server = MusicFsServer::new(event_bus);
let response = server.get_status(Request::new(Empty {})).await.unwrap();
let status = response.into_inner();
assert!(!status.version.is_empty());
assert!(status.uptime_secs < 5);
}
#[tokio::test]
async fn test_get_cache_stats() {
let event_bus = Arc::new(EventBus::new(16));
let server = MusicFsServer::new(event_bus);
let response = server
.get_cache_stats(Request::new(Empty {}))
.await
.unwrap();
let stats = response.into_inner();
assert_eq!(stats.hit_ratio, 0.0);
}
}
+288
View File
@@ -0,0 +1,288 @@
use musicfs_core::Event;
use serde::{Deserialize, Serialize};
use std::time::Duration;
use tokio::sync::broadcast;
use tracing::{debug, warn};
#[derive(Debug, Clone, Serialize)]
pub struct WebhookPayload {
pub event_type: String,
pub timestamp: i64,
pub data: serde_json::Value,
}
#[derive(Debug, Clone, Deserialize)]
pub struct WebhookConfig {
pub url: String,
pub secret: Option<String>,
pub events: Vec<String>,
#[serde(default = "default_retry_count")]
pub retry_count: u32,
#[serde(default = "default_timeout_ms")]
pub timeout_ms: u64,
}
fn default_retry_count() -> u32 {
3
}
fn default_timeout_ms() -> u64 {
5000
}
pub struct WebhookHandler {
client: reqwest::Client,
configs: Vec<WebhookConfig>,
}
impl WebhookHandler {
pub fn new(configs: Vec<WebhookConfig>) -> Self {
let client = reqwest::Client::builder()
.timeout(Duration::from_secs(30))
.build()
.expect("Failed to create HTTP client");
Self { client, configs }
}
pub async fn run(&self, mut rx: broadcast::Receiver<Event>) {
while let Ok(event) = rx.recv().await {
for config in &self.configs {
if self.matches_filter(&event, config) {
self.dispatch(config, &event).await;
}
}
}
}
async fn dispatch(&self, config: &WebhookConfig, event: &Event) {
let payload = WebhookPayload {
event_type: self.event_type_name(event),
timestamp: chrono::Utc::now().timestamp_millis(),
data: self.event_to_json(event),
};
let signature = self.sign(&payload, config);
let mut attempts = 0u32;
loop {
let result = self
.client
.post(&config.url)
.timeout(Duration::from_millis(config.timeout_ms))
.header("Content-Type", "application/json")
.header("X-MusicFS-Signature", &signature)
.header("X-MusicFS-Event", &payload.event_type)
.json(&payload)
.send()
.await;
match result {
Ok(resp) if resp.status().is_success() => {
debug!(
"Webhook delivered to {} for {}",
config.url, payload.event_type
);
break;
}
Ok(resp) => {
warn!(
"Webhook to {} returned status {}, attempt {}/{}",
config.url,
resp.status(),
attempts + 1,
config.retry_count + 1
);
}
Err(e) => {
warn!(
"Webhook to {} failed: {}, attempt {}/{}",
config.url,
e,
attempts + 1,
config.retry_count + 1
);
}
}
if attempts >= config.retry_count {
warn!(
"Webhook delivery to {} failed after {} attempts",
config.url,
attempts + 1
);
break;
}
attempts += 1;
let delay = Duration::from_millis(100 * 2u64.pow(attempts));
tokio::time::sleep(delay).await;
}
}
fn sign(&self, payload: &WebhookPayload, config: &WebhookConfig) -> String {
match &config.secret {
Some(secret) => {
use hmac::{Hmac, Mac};
use sha2::Sha256;
type HmacSha256 = Hmac<Sha256>;
let body = serde_json::to_string(payload).unwrap_or_default();
let mut mac =
HmacSha256::new_from_slice(secret.as_bytes()).expect("HMAC key invalid");
mac.update(body.as_bytes());
let result = mac.finalize();
format!("sha256={}", hex::encode(result.into_bytes()))
}
None => String::new(),
}
}
fn matches_filter(&self, event: &Event, config: &WebhookConfig) -> bool {
if config.events.is_empty() {
return true;
}
let event_type = self.event_type_name(event);
config.events.iter().any(|e| e == &event_type)
}
fn event_type_name(&self, event: &Event) -> String {
match event {
Event::FileAccessed { .. } => "file_accessed",
Event::FileAdded { .. } => "file_added",
Event::FileRemoved { .. } => "file_removed",
Event::FileModified { .. } => "file_modified",
Event::SyncStarted { .. } => "sync_started",
Event::SyncCompleted { .. } => "sync_completed",
Event::OriginHealthChanged { .. } => "origin_health_changed",
Event::CacheEviction { .. } => "cache_eviction",
Event::OriginConnected { .. } => "origin_connected",
Event::OriginDisconnected { .. } => "origin_disconnected",
Event::AllOriginsUnhealthy { .. } => "all_origins_unhealthy",
}
.to_string()
}
fn event_to_json(&self, event: &Event) -> serde_json::Value {
match event {
Event::FileAccessed {
file_id,
origin_id,
path,
offset,
size,
} => serde_json::json!({
"file_id": file_id.0,
"origin_id": origin_id.to_string(),
"path": path.as_str(),
"offset": offset,
"size": size,
}),
Event::FileAdded { path, origin_id } => serde_json::json!({
"path": path.as_str(),
"origin_id": origin_id.to_string(),
}),
Event::FileRemoved { path, file_id } => serde_json::json!({
"path": path.as_str(),
"file_id": file_id.map(|id| id.0),
}),
Event::FileModified { path } => serde_json::json!({
"path": path.as_str(),
}),
Event::SyncStarted { origin_id } => serde_json::json!({
"origin_id": origin_id.to_string(),
}),
Event::SyncCompleted {
origin_id,
files_changed,
} => serde_json::json!({
"origin_id": origin_id.to_string(),
"files_changed": files_changed,
}),
Event::OriginHealthChanged { origin_id, healthy } => serde_json::json!({
"origin_id": origin_id.to_string(),
"healthy": healthy,
}),
Event::CacheEviction { bytes_freed } => serde_json::json!({
"bytes_freed": bytes_freed,
}),
Event::OriginConnected { origin_id } => serde_json::json!({
"origin_id": origin_id.to_string(),
}),
Event::OriginDisconnected { origin_id } => serde_json::json!({
"origin_id": origin_id.to_string(),
}),
Event::AllOriginsUnhealthy { candidate_count } => serde_json::json!({
"candidate_count": candidate_count,
}),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use musicfs_core::OriginId;
#[test]
fn test_webhook_config_defaults() {
let json = r#"{"url": "http://example.com", "events": []}"#;
let config: WebhookConfig = serde_json::from_str(json).unwrap();
assert_eq!(config.retry_count, 3);
assert_eq!(config.timeout_ms, 5000);
}
#[test]
fn test_event_type_name() {
let handler = WebhookHandler::new(vec![]);
let event = Event::SyncStarted {
origin_id: OriginId::from("test"),
};
assert_eq!(handler.event_type_name(&event), "sync_started");
}
#[test]
fn test_matches_filter_empty() {
let handler = WebhookHandler::new(vec![]);
let config = WebhookConfig {
url: "http://example.com".to_string(),
secret: None,
events: vec![],
retry_count: 3,
timeout_ms: 5000,
};
let event = Event::SyncStarted {
origin_id: OriginId::from("test"),
};
assert!(handler.matches_filter(&event, &config));
}
#[test]
fn test_matches_filter_specific() {
let handler = WebhookHandler::new(vec![]);
let config = WebhookConfig {
url: "http://example.com".to_string(),
secret: None,
events: vec!["sync_started".to_string()],
retry_count: 3,
timeout_ms: 5000,
};
let event = Event::SyncStarted {
origin_id: OriginId::from("test"),
};
assert!(handler.matches_filter(&event, &config));
let event2 = Event::SyncCompleted {
origin_id: OriginId::from("test"),
files_changed: 0,
};
assert!(!handler.matches_filter(&event2, &config));
}
}
+17
View File
@@ -4,3 +4,20 @@ version.workspace = true
edition.workspace = true
[dependencies]
musicfs-core = { path = "../musicfs-core" }
async-trait.workspace = true
tokio.workspace = true
thiserror.workspace = true
serde.workspace = true
serde_json.workspace = true
tracing.workspace = true
libloading = "0.8"
wasmtime = { version = "19", optional = true }
semver = "1"
[features]
default = []
wasm = ["wasmtime"]
[dev-dependencies]
tempfile.workspace = true
@@ -0,0 +1,42 @@
use thiserror::Error;
#[derive(Debug, Error)]
pub enum PluginError {
#[error("Plugin not found: {0}")]
NotFound(String),
#[error("Plugin load failed: {0}")]
LoadFailed(String),
#[error("Plugin initialization failed: {0}")]
InitFailed(String),
#[error("Plugin API version mismatch: expected {expected}, got {actual}")]
VersionMismatch { expected: String, actual: String },
#[error("Plugin already loaded: {0}")]
AlreadyLoaded(String),
#[error("Plugin symbol not found: {0}")]
SymbolNotFound(String),
#[error("IO error: {0}")]
Io(#[from] std::io::Error),
#[error("Plugin execution error: {0}")]
Execution(String),
#[error("Plugin shutdown error: {0}")]
Shutdown(String),
#[error("Configuration error: {0}")]
Config(String),
#[error("WASM error: {0}")]
Wasm(String),
#[error("Resource limit exceeded: {0}")]
ResourceLimit(String),
}
pub type Result<T> = std::result::Result<T, PluginError>;
+15 -1
View File
@@ -1 +1,15 @@
#![allow(dead_code)]
pub mod error;
pub mod manager;
pub mod native;
pub mod traits;
pub mod wasm;
pub use error::{PluginError, Result};
pub use manager::{PluginConfig, PluginEntry, PluginManager, WasmConfig};
pub use native::NativePluginHost;
pub use traits::{
ExternalMetadata, FormatPlugin, MetadataPlugin, MetadataQuery, MetadataQueryType,
OriginDirEntry, OriginHealth, OriginInstance, OriginPlugin, OriginStat, Plugin, PluginId,
PluginInfo, PluginType, WatchEvent, WatchHandle, PLUGIN_API_VERSION,
};
pub use wasm::{ResourceLimits, WasmPluginHost};
@@ -0,0 +1,345 @@
use crate::error::{PluginError, Result};
use crate::native::NativePluginHost;
use crate::traits::{Plugin, PluginId, PluginInfo, PluginType};
use crate::wasm::{ResourceLimits, WasmPluginHost};
use serde::Deserialize;
use serde_json::Value;
use std::collections::HashMap;
use std::path::PathBuf;
use tracing::{debug, info};
#[derive(Debug, Clone, Deserialize, Default)]
pub struct PluginConfig {
#[serde(default)]
pub enabled: bool,
#[serde(default)]
pub search_paths: Vec<PathBuf>,
#[serde(default)]
pub plugins: HashMap<String, PluginEntry>,
#[serde(default)]
pub wasm: WasmConfig,
}
#[derive(Debug, Clone, Deserialize)]
pub struct PluginEntry {
pub path: PathBuf,
#[serde(default)]
pub enabled: bool,
#[serde(default)]
pub config: Value,
}
impl Default for PluginEntry {
fn default() -> Self {
Self {
path: PathBuf::new(),
enabled: true,
config: Value::Null,
}
}
}
#[derive(Debug, Clone, Deserialize, Default)]
pub struct WasmConfig {
#[serde(default)]
pub enabled: bool,
#[serde(default)]
pub max_memory_mb: Option<u32>,
#[serde(default)]
pub max_cpu_time_ms: Option<u32>,
}
pub struct PluginManager {
native_host: NativePluginHost,
wasm_host: WasmPluginHost,
registry: PluginRegistry,
config: PluginConfig,
}
struct PluginRegistry {
origin_plugins: Vec<PluginId>,
metadata_plugins: Vec<PluginId>,
format_plugins: Vec<PluginId>,
}
impl PluginRegistry {
fn new() -> Self {
Self {
origin_plugins: Vec::new(),
metadata_plugins: Vec::new(),
format_plugins: Vec::new(),
}
}
fn register(&mut self, id: PluginId, plugin_type: PluginType) {
match plugin_type {
PluginType::Origin => {
if !self.origin_plugins.contains(&id) {
self.origin_plugins.push(id);
}
}
PluginType::Metadata => {
if !self.metadata_plugins.contains(&id) {
self.metadata_plugins.push(id);
}
}
PluginType::Format => {
if !self.format_plugins.contains(&id) {
self.format_plugins.push(id);
}
}
}
}
fn unregister(&mut self, id: PluginId) {
self.origin_plugins.retain(|&x| x != id);
self.metadata_plugins.retain(|&x| x != id);
self.format_plugins.retain(|&x| x != id);
}
}
impl PluginManager {
pub fn new() -> Result<Self> {
Ok(Self {
native_host: NativePluginHost::new(),
wasm_host: WasmPluginHost::new()?,
registry: PluginRegistry::new(),
config: PluginConfig::default(),
})
}
pub fn init(config: &PluginConfig) -> Result<Self> {
let mut manager = Self::new()?;
manager.config = config.clone();
if !config.enabled {
info!("Plugin system disabled");
return Ok(manager);
}
info!("Initializing plugin system");
for path in &config.search_paths {
manager.native_host.add_search_path(path.clone());
}
if config.wasm.enabled {
let limits = ResourceLimits {
max_memory_mb: config.wasm.max_memory_mb.unwrap_or(64),
max_cpu_time_ms: config.wasm.max_cpu_time_ms.unwrap_or(5000),
..Default::default()
};
manager.wasm_host.set_limits(limits);
}
for (name, entry) in &config.plugins {
if !entry.enabled {
debug!("Skipping disabled plugin: {}", name);
continue;
}
match manager.load_and_init(&entry.path, &entry.config) {
Ok(id) => {
info!("Loaded plugin '{}' with id {:?}", name, id);
}
Err(e) => {
tracing::warn!("Failed to load plugin '{}': {}", name, e);
}
}
}
let discovered = manager.native_host.discover()?;
for id in discovered {
if let Some(info) = manager.native_host.list().iter().find(|i| i.id == id) {
manager.registry.register(id, info.plugin_type);
}
}
Ok(manager)
}
pub fn load_and_init(&mut self, path: &PathBuf, config: &Value) -> Result<PluginId> {
let id = self.native_host.load(path)?;
if let Some(plugin) = self.native_host.get_mut(id) {
plugin.init(config.clone())?;
}
if let Some(info) = self.native_host.list().iter().find(|i| i.id == id) {
self.registry.register(id, info.plugin_type);
}
Ok(id)
}
pub fn load_wasm(&mut self, wasm_bytes: &[u8]) -> Result<PluginId> {
if !self.config.wasm.enabled {
return Err(PluginError::Config("WASM plugins disabled".to_string()));
}
self.wasm_host.load(wasm_bytes)
}
pub fn unload(&mut self, id: PluginId) -> Result<()> {
self.registry.unregister(id);
if let Err(native_err) = self.native_host.unload(id) {
if let Err(wasm_err) = self.wasm_host.unload(id) {
return Err(PluginError::NotFound(format!(
"Plugin {:?} not found in native ({}) or WASM ({}) hosts",
id, native_err, wasm_err
)));
}
}
Ok(())
}
pub fn reload(&mut self, id: PluginId) -> Result<()> {
self.native_host.reload(id)
}
pub fn reload_all(&mut self) -> Result<()> {
let ids: Vec<PluginId> = self.native_host.list().iter().map(|i| i.id).collect();
for id in ids {
self.reload(id)?;
}
Ok(())
}
pub fn list(&self) -> Vec<PluginInfo> {
let mut all = self.native_host.list();
for (id, name) in self.wasm_host.list() {
all.push(PluginInfo {
id,
name: name.to_string(),
version: semver::Version::new(0, 0, 0),
description: "WASM plugin".to_string(),
plugin_type: PluginType::Origin,
});
}
all
}
pub fn get(&self, id: PluginId) -> Option<&dyn Plugin> {
self.native_host.get(id)
}
pub fn get_mut(&mut self, id: PluginId) -> Option<&mut dyn Plugin> {
self.native_host.get_mut(id)
}
pub fn origin_plugin_ids(&self) -> &[PluginId] {
&self.registry.origin_plugins
}
pub fn metadata_plugin_ids(&self) -> &[PluginId] {
&self.registry.metadata_plugins
}
pub fn format_plugin_ids(&self) -> &[PluginId] {
&self.registry.format_plugins
}
pub fn shutdown(&mut self) -> Result<()> {
info!("Shutting down plugin system");
let ids: Vec<PluginId> = self.list().iter().map(|i| i.id).collect();
for id in ids {
if let Err(e) = self.unload(id) {
tracing::warn!("Failed to unload plugin {:?}: {}", id, e);
}
}
Ok(())
}
}
impl Default for PluginManager {
fn default() -> Self {
Self::new().expect("Failed to create plugin manager")
}
}
impl Drop for PluginManager {
fn drop(&mut self) {
let _ = self.shutdown();
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_plugin_manager_new() {
let manager = PluginManager::new();
assert!(manager.is_ok());
}
#[test]
fn test_plugin_manager_disabled() {
let config = PluginConfig {
enabled: false,
..Default::default()
};
let manager = PluginManager::init(&config);
assert!(manager.is_ok());
let manager = manager.unwrap();
assert!(manager.list().is_empty());
}
#[test]
fn test_registry() {
let mut registry = PluginRegistry::new();
let id1 = PluginId::new(1);
let id2 = PluginId::new(2);
registry.register(id1, PluginType::Origin);
registry.register(id2, PluginType::Metadata);
assert_eq!(registry.origin_plugins.len(), 1);
assert_eq!(registry.metadata_plugins.len(), 1);
registry.unregister(id1);
assert!(registry.origin_plugins.is_empty());
}
#[test]
fn test_plugin_config_deserialize() {
let json = r#"{
"enabled": true,
"search_paths": ["/usr/lib/musicfs/plugins"],
"plugins": {
"example": {
"path": "/path/to/plugin.so",
"enabled": true,
"config": {"key": "value"}
}
},
"wasm": {
"enabled": false
}
}"#;
let config: PluginConfig = serde_json::from_str(json).unwrap();
assert!(config.enabled);
assert_eq!(config.search_paths.len(), 1);
assert!(config.plugins.contains_key("example"));
}
}
@@ -0,0 +1,300 @@
use crate::error::{PluginError, Result};
use crate::traits::{Plugin, PluginId, PluginInfo, PluginType, PLUGIN_API_VERSION};
use libloading::{Library, Symbol};
use semver::Version;
use std::collections::HashMap;
use std::ffi::CStr;
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicU64, Ordering};
use tracing::{debug, info, warn};
static NEXT_PLUGIN_ID: AtomicU64 = AtomicU64::new(1);
fn next_plugin_id() -> PluginId {
PluginId::new(NEXT_PLUGIN_ID.fetch_add(1, Ordering::SeqCst))
}
struct LoadedPlugin {
id: PluginId,
path: PathBuf,
library: Library,
instance: Box<dyn Plugin>,
plugin_type: PluginType,
}
pub struct NativePluginHost {
plugins: HashMap<PluginId, LoadedPlugin>,
search_paths: Vec<PathBuf>,
}
impl NativePluginHost {
pub fn new() -> Self {
Self {
plugins: HashMap::new(),
search_paths: Vec::new(),
}
}
pub fn add_search_path(&mut self, path: PathBuf) {
if !self.search_paths.contains(&path) {
self.search_paths.push(path);
}
}
pub fn load(&mut self, path: &Path) -> Result<PluginId> {
let canonical = path.canonicalize().map_err(|e| {
PluginError::LoadFailed(format!("Cannot resolve path {}: {}", path.display(), e))
})?;
for plugin in self.plugins.values() {
if plugin.path == canonical {
return Err(PluginError::AlreadyLoaded(canonical.display().to_string()));
}
}
info!("Loading native plugin from {:?}", canonical);
let library = unsafe {
Library::new(&canonical).map_err(|e| {
PluginError::LoadFailed(format!("Failed to load library: {}", e))
})?
};
self.verify_api_version(&library)?;
let instance = self.create_plugin_instance(&library)?;
let id = next_plugin_id();
let plugin_type = self.detect_plugin_type(&*instance);
debug!(
"Loaded plugin '{}' v{} as {:?}",
instance.name(),
instance.version(),
plugin_type
);
self.plugins.insert(
id,
LoadedPlugin {
id,
path: canonical,
library,
instance,
plugin_type,
},
);
Ok(id)
}
pub fn unload(&mut self, id: PluginId) -> Result<()> {
let mut plugin = self
.plugins
.remove(&id)
.ok_or_else(|| PluginError::NotFound(format!("Plugin {:?}", id)))?;
info!("Unloading plugin '{}'", plugin.instance.name());
plugin.instance.shutdown()?;
drop(plugin.instance);
drop(plugin.library);
Ok(())
}
pub fn reload(&mut self, id: PluginId) -> Result<()> {
let plugin = self
.plugins
.get(&id)
.ok_or_else(|| PluginError::NotFound(format!("Plugin {:?}", id)))?;
let path = plugin.path.clone();
info!("Hot-reloading plugin from {:?}", path);
self.unload(id)?;
let new_id = self.load(&path)?;
if let Some(plugin) = self.plugins.remove(&new_id) {
self.plugins.insert(id, LoadedPlugin { id, ..plugin });
}
Ok(())
}
pub fn get(&self, id: PluginId) -> Option<&dyn Plugin> {
self.plugins.get(&id).map(|p| &*p.instance as &dyn Plugin)
}
pub fn get_mut(&mut self, id: PluginId) -> Option<&mut dyn Plugin> {
self.plugins
.get_mut(&id)
.map(|p| &mut *p.instance as &mut dyn Plugin)
}
pub fn list(&self) -> Vec<PluginInfo> {
self.plugins
.values()
.map(|p| PluginInfo {
id: p.id,
name: p.instance.name().to_string(),
version: p.instance.version(),
description: p.instance.description().to_string(),
plugin_type: p.plugin_type,
})
.collect()
}
pub fn find_by_name(&self, name: &str) -> Option<PluginId> {
self.plugins
.iter()
.find(|(_, p)| p.instance.name() == name)
.map(|(id, _)| *id)
}
pub fn discover(&mut self) -> Result<Vec<PluginId>> {
let mut loaded = Vec::new();
for search_path in self.search_paths.clone() {
if !search_path.exists() {
continue;
}
let entries = std::fs::read_dir(&search_path).map_err(|e| {
PluginError::LoadFailed(format!(
"Cannot read plugin directory {}: {}",
search_path.display(),
e
))
})?;
for entry in entries.flatten() {
let path = entry.path();
if self.is_plugin_library(&path) {
match self.load(&path) {
Ok(id) => loaded.push(id),
Err(e) => {
warn!("Failed to load plugin {:?}: {}", path, e);
}
}
}
}
}
Ok(loaded)
}
fn verify_api_version(&self, library: &Library) -> Result<()> {
let version_fn: Symbol<unsafe extern "C" fn() -> *const std::ffi::c_char> = unsafe {
library
.get(b"musicfs_plugin_api_version")
.map_err(|_| PluginError::SymbolNotFound("musicfs_plugin_api_version".to_string()))?
};
let version_ptr = unsafe { version_fn() };
let version_str = unsafe { CStr::from_ptr(version_ptr) }
.to_str()
.map_err(|_| PluginError::VersionMismatch {
expected: PLUGIN_API_VERSION.to_string(),
actual: "<invalid UTF-8>".to_string(),
})?;
let plugin_version = Version::parse(version_str).map_err(|_| PluginError::VersionMismatch {
expected: PLUGIN_API_VERSION.to_string(),
actual: version_str.to_string(),
})?;
let expected_version = Version::parse(PLUGIN_API_VERSION).unwrap();
if plugin_version.major != expected_version.major {
return Err(PluginError::VersionMismatch {
expected: PLUGIN_API_VERSION.to_string(),
actual: version_str.to_string(),
});
}
Ok(())
}
fn create_plugin_instance(&self, library: &Library) -> Result<Box<dyn Plugin>> {
let create_fn: Symbol<unsafe extern "C" fn() -> *mut dyn Plugin> = unsafe {
library
.get(b"musicfs_plugin_create")
.map_err(|_| PluginError::SymbolNotFound("musicfs_plugin_create".to_string()))?
};
let plugin_ptr = unsafe { create_fn() };
if plugin_ptr.is_null() {
return Err(PluginError::LoadFailed(
"Plugin factory returned null".to_string(),
));
}
let plugin = unsafe { Box::from_raw(plugin_ptr) };
Ok(plugin)
}
fn detect_plugin_type(&self, plugin: &dyn Plugin) -> PluginType {
plugin.plugin_type()
}
fn is_plugin_library(&self, path: &Path) -> bool {
let extension = path.extension().and_then(|e| e.to_str());
match extension {
Some("so") => true,
Some("dylib") => true,
Some("dll") => true,
_ => false,
}
}
}
impl Default for NativePluginHost {
fn default() -> Self {
Self::new()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_native_host_creation() {
let host = NativePluginHost::new();
assert!(host.plugins.is_empty());
assert!(host.search_paths.is_empty());
}
#[test]
fn test_add_search_path() {
let mut host = NativePluginHost::new();
host.add_search_path(PathBuf::from("/usr/lib/musicfs/plugins"));
host.add_search_path(PathBuf::from("/usr/lib/musicfs/plugins"));
assert_eq!(host.search_paths.len(), 1);
}
#[test]
fn test_is_plugin_library() {
let host = NativePluginHost::new();
assert!(host.is_plugin_library(Path::new("plugin.so")));
assert!(host.is_plugin_library(Path::new("plugin.dylib")));
assert!(host.is_plugin_library(Path::new("plugin.dll")));
assert!(!host.is_plugin_library(Path::new("plugin.txt")));
assert!(!host.is_plugin_library(Path::new("plugin")));
}
#[test]
fn test_load_nonexistent() {
let mut host = NativePluginHost::new();
let result = host.load(Path::new("/nonexistent/plugin.so"));
assert!(result.is_err());
}
}
@@ -0,0 +1,343 @@
//! Plugin trait definitions (FR-23.1-23.4)
//!
//! Per architecture.md section 4.3.4:
//! - Plugin trait: Base interface for all plugins
//! - OriginPlugin: Creates Origin instances for storage backends
//! - MetadataPlugin: Provides external metadata lookup
//! - FormatPlugin: Handles custom audio format parsing
use crate::error::Result;
use async_trait::async_trait;
use musicfs_core::AudioMeta;
use semver::Version;
use serde_json::Value;
use std::io::Read;
/// Current plugin API version
pub const PLUGIN_API_VERSION: &str = "0.1.0";
/// Unique identifier for a loaded plugin
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub struct PluginId(pub u64);
impl PluginId {
pub fn new(id: u64) -> Self {
Self(id)
}
}
/// Plugin metadata returned by plugins
#[derive(Debug, Clone)]
pub struct PluginInfo {
pub id: PluginId,
pub name: String,
pub version: Version,
pub description: String,
pub plugin_type: PluginType,
}
/// Type of plugin
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PluginType {
Origin,
Metadata,
Format,
}
/// Base plugin interface (FR-23.1)
///
/// All plugins must implement this trait. It provides:
/// - Plugin identification (name, version)
/// - Lifecycle management (init, shutdown)
pub trait Plugin: Send + Sync {
/// Unique plugin name (e.g., "s3-origin", "musicbrainz-metadata")
fn name(&self) -> &str;
/// Plugin version following semver
fn version(&self) -> Version;
/// Human-readable description
fn description(&self) -> &str {
""
}
/// Plugin type for registry categorization
fn plugin_type(&self) -> PluginType;
/// Initialize plugin with configuration
///
/// Called once after loading. The config value contains
/// plugin-specific configuration from the main config file.
fn init(&mut self, config: Value) -> Result<()>;
/// Shutdown plugin and release resources
///
/// Called before unloading. Plugins should clean up any
/// resources (connections, file handles, etc).
fn shutdown(&mut self) -> Result<()>;
}
/// Origin plugin interface (FR-23.3)
///
/// Per architecture.md section 4.3.4:
/// Origin plugins create `Box<dyn Origin>` instances for custom storage backends.
///
/// Example use cases:
/// - Google Drive origin
/// - Dropbox origin
/// - Custom NAS protocol
#[async_trait]
pub trait OriginPlugin: Plugin {
/// Origin type identifier (e.g., "gdrive", "dropbox")
fn origin_type(&self) -> &str;
/// Create a new Origin instance with the given configuration
///
/// The config contains origin-specific settings (credentials, paths, etc).
/// Returns a boxed Origin that can be used by the OriginRouter.
async fn create_origin(
&self,
id: &str,
config: Value,
) -> Result<Box<dyn OriginInstance>>;
}
/// Instance created by OriginPlugin
///
/// This is a simplified async interface that maps to the full Origin trait.
/// The plugin host wraps this to provide the full Origin implementation.
#[async_trait]
pub trait OriginInstance: Send + Sync {
/// List directory contents
async fn readdir(&self, path: &str) -> Result<Vec<OriginDirEntry>>;
/// Get file/directory stats
async fn stat(&self, path: &str) -> Result<OriginStat>;
/// Read file data
async fn read(&self, path: &str, offset: u64, size: u32) -> Result<Vec<u8>>;
/// Check if path exists
async fn exists(&self, path: &str) -> Result<bool>;
/// Health check
async fn health(&self) -> OriginHealth;
/// Watch path for changes (FR-10.2)
async fn watch(
&self,
path: &str,
callback: Box<dyn Fn(WatchEvent) + Send + Sync>,
) -> Result<WatchHandle>;
}
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(String),
Modified(String),
Deleted(String),
}
/// Directory entry from plugin origin
#[derive(Debug, Clone)]
pub struct OriginDirEntry {
pub name: String,
pub is_dir: bool,
pub size: u64,
pub mtime_secs: u64,
}
/// File stats from plugin origin
#[derive(Debug, Clone)]
pub struct OriginStat {
pub size: u64,
pub mtime_secs: u64,
pub is_dir: bool,
}
/// Origin health status
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum OriginHealth {
Healthy,
Degraded,
Unhealthy,
}
/// Metadata plugin interface (FR-23.3)
///
/// Metadata plugins provide external metadata lookup from services like
/// MusicBrainz, Discogs, Last.fm, etc.
#[async_trait]
pub trait MetadataPlugin: Plugin {
/// Lookup metadata for a query
///
/// Returns enriched metadata if found, None otherwise.
async fn lookup(&self, query: &MetadataQuery) -> Result<Option<ExternalMetadata>>;
/// Supported query types
fn supported_queries(&self) -> &[MetadataQueryType] {
&[MetadataQueryType::ByTitleArtist]
}
}
/// Query for metadata lookup
#[derive(Debug, Clone)]
pub struct MetadataQuery {
pub query_type: MetadataQueryType,
pub title: Option<String>,
pub artist: Option<String>,
pub album: Option<String>,
pub fingerprint: Option<String>,
pub duration_ms: Option<u64>,
}
/// Type of metadata query
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum MetadataQueryType {
ByTitleArtist,
ByFingerprint,
ByAlbum,
}
/// External metadata returned by plugins
#[derive(Debug, Clone, Default)]
pub struct ExternalMetadata {
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 musicbrainz_id: Option<String>,
pub artwork_url: Option<String>,
}
/// Format plugin interface (FR-24.1)
///
/// Format plugins handle custom audio formats not supported by symphonia.
///
/// Example use cases:
/// - Custom lossless codecs
/// - Proprietary formats
/// - Game audio formats
pub trait FormatPlugin: Plugin {
/// File extensions this plugin handles
fn extensions(&self) -> &[&str];
/// Check if plugin can handle a specific extension
fn can_handle(&self, extension: &str) -> bool {
self.extensions()
.iter()
.any(|ext| ext.eq_ignore_ascii_case(extension))
}
/// Parse audio metadata from reader
///
/// The reader provides the raw file bytes. Plugin should parse
/// and return AudioMeta with whatever metadata it can extract.
fn parse(&self, reader: &mut dyn Read) -> Result<AudioMeta>;
/// Synthesize file header with updated metadata (FR-5.3)
///
/// Creates a new file header containing the provided metadata.
/// Used for metadata overlay - serving cached metadata without
/// modifying the original file.
fn synthesize_header(&self, metadata: &AudioMeta) -> Result<Vec<u8>>;
}
/// Declaration macro for native plugins
///
/// Native plugins must export a function with this signature:
/// ```ignore
/// #[no_mangle]
/// pub extern "C" fn musicfs_plugin_create() -> *mut dyn Plugin
/// ```
#[macro_export]
macro_rules! declare_plugin {
($plugin_type:ty, $constructor:expr) => {
#[no_mangle]
pub extern "C" fn musicfs_plugin_create() -> *mut dyn $crate::Plugin {
let plugin = $constructor;
let boxed: Box<dyn $crate::Plugin> = Box::new(plugin);
Box::into_raw(boxed)
}
#[no_mangle]
pub extern "C" fn musicfs_plugin_api_version() -> *const std::ffi::c_char {
concat!($crate::PLUGIN_API_VERSION, "\0").as_ptr() as *const std::ffi::c_char
}
};
}
#[cfg(test)]
mod tests {
use super::*;
struct TestPlugin {
name: String,
initialized: bool,
}
impl Plugin for TestPlugin {
fn name(&self) -> &str {
&self.name
}
fn version(&self) -> Version {
Version::new(1, 0, 0)
}
fn plugin_type(&self) -> PluginType {
PluginType::Origin
}
fn init(&mut self, _config: Value) -> Result<()> {
self.initialized = true;
Ok(())
}
fn shutdown(&mut self) -> Result<()> {
self.initialized = false;
Ok(())
}
}
#[test]
fn test_plugin_lifecycle() {
let mut plugin = TestPlugin {
name: "test".to_string(),
initialized: false,
};
assert_eq!(plugin.name(), "test");
assert!(!plugin.initialized);
plugin.init(Value::Null).unwrap();
assert!(plugin.initialized);
plugin.shutdown().unwrap();
assert!(!plugin.initialized);
}
#[test]
fn test_plugin_id() {
let id1 = PluginId::new(1);
let id2 = PluginId::new(1);
let id3 = PluginId::new(2);
assert_eq!(id1, id2);
assert_ne!(id1, id3);
}
}
+220
View File
@@ -0,0 +1,220 @@
use crate::error::{PluginError, Result};
use crate::traits::PluginId;
#[cfg(feature = "wasm")]
use std::sync::atomic::{AtomicU64, Ordering};
#[cfg(feature = "wasm")]
static NEXT_WASM_PLUGIN_ID: AtomicU64 = AtomicU64::new(1_000_000);
#[cfg(feature = "wasm")]
fn next_wasm_plugin_id() -> PluginId {
PluginId::new(NEXT_WASM_PLUGIN_ID.fetch_add(1, Ordering::SeqCst))
}
#[derive(Debug, Clone)]
pub struct ResourceLimits {
pub max_memory_mb: u32,
pub max_cpu_time_ms: u32,
pub allow_network: bool,
pub allow_filesystem: bool,
}
impl Default for ResourceLimits {
fn default() -> Self {
Self {
max_memory_mb: 64,
max_cpu_time_ms: 5000,
allow_network: false,
allow_filesystem: false,
}
}
}
#[cfg(feature = "wasm")]
mod wasm_impl {
use super::*;
use std::collections::HashMap;
use tracing::info;
use wasmtime::{Config, Engine, Linker, Module, Store};
pub struct PluginState {
limits: ResourceLimits,
}
pub struct WasmPlugin {
id: PluginId,
name: String,
_module: Module,
}
impl WasmPlugin {
pub fn id(&self) -> PluginId {
self.id
}
pub fn name(&self) -> &str {
&self.name
}
}
pub struct WasmPluginHost {
engine: Engine,
linker: Linker<PluginState>,
plugins: HashMap<PluginId, WasmPlugin>,
limits: ResourceLimits,
}
impl WasmPluginHost {
pub fn new() -> Result<Self> {
let mut config = Config::new();
config.consume_fuel(true);
config.epoch_interruption(true);
let engine = Engine::new(&config)
.map_err(|e| PluginError::Wasm(format!("Failed to create WASM engine: {}", e)))?;
let linker = Linker::new(&engine);
Ok(Self {
engine,
linker,
plugins: HashMap::new(),
limits: ResourceLimits::default(),
})
}
pub fn set_limits(&mut self, limits: ResourceLimits) {
self.limits = limits;
}
pub fn load(&mut self, wasm_bytes: &[u8]) -> Result<PluginId> {
info!("Loading WASM plugin ({} bytes)", wasm_bytes.len());
let module = Module::new(&self.engine, wasm_bytes)
.map_err(|e| PluginError::Wasm(format!("Failed to compile WASM module: {}", e)))?;
let id = next_wasm_plugin_id();
let name = module.name().unwrap_or("unnamed").to_string();
let plugin = WasmPlugin {
id,
name,
_module: module,
};
self.plugins.insert(id, plugin);
Ok(id)
}
pub fn unload(&mut self, id: PluginId) -> Result<()> {
self.plugins
.remove(&id)
.ok_or_else(|| PluginError::NotFound(format!("WASM plugin {:?}", id)))?;
Ok(())
}
pub fn get(&self, id: PluginId) -> Option<&WasmPlugin> {
self.plugins.get(&id)
}
pub fn list(&self) -> Vec<(PluginId, &str)> {
self.plugins.iter().map(|(id, p)| (*id, p.name())).collect()
}
fn create_store(&self) -> Store<PluginState> {
let state = PluginState {
limits: self.limits.clone(),
};
let mut store = Store::new(&self.engine, state);
let fuel = (self.limits.max_cpu_time_ms as u64) * 1_000_000;
store.set_fuel(fuel).ok();
store
}
}
impl Default for WasmPluginHost {
fn default() -> Self {
Self::new().expect("Failed to create WASM host")
}
}
}
#[cfg(not(feature = "wasm"))]
mod wasm_stub {
use super::*;
pub struct WasmPluginHost {
limits: ResourceLimits,
}
impl WasmPluginHost {
pub fn new() -> Result<Self> {
Ok(Self {
limits: ResourceLimits::default(),
})
}
pub fn set_limits(&mut self, limits: ResourceLimits) {
self.limits = limits;
}
pub fn load(&mut self, _wasm_bytes: &[u8]) -> Result<PluginId> {
Err(PluginError::Wasm(
"WASM support not enabled. Compile with --features wasm".to_string(),
))
}
pub fn unload(&mut self, _id: PluginId) -> Result<()> {
Err(PluginError::Wasm("WASM support not enabled".to_string()))
}
pub fn list(&self) -> Vec<(PluginId, &str)> {
Vec::new()
}
}
impl Default for WasmPluginHost {
fn default() -> Self {
Self::new().expect("Failed to create WASM stub host")
}
}
}
#[cfg(feature = "wasm")]
pub use wasm_impl::*;
#[cfg(not(feature = "wasm"))]
pub use wasm_stub::*;
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_resource_limits_default() {
let limits = ResourceLimits::default();
assert_eq!(limits.max_memory_mb, 64);
assert_eq!(limits.max_cpu_time_ms, 5000);
assert!(!limits.allow_network);
assert!(!limits.allow_filesystem);
}
#[test]
fn test_wasm_host_creation() {
let host = WasmPluginHost::new();
assert!(host.is_ok());
}
#[test]
#[cfg(not(feature = "wasm"))]
fn test_wasm_disabled_load_fails() {
let mut host = WasmPluginHost::new().unwrap();
let result = host.load(&[0x00, 0x61, 0x73, 0x6d]);
assert!(result.is_err());
}
}