Move the files around
This commit is contained in:
@@ -0,0 +1,23 @@
|
||||
[package]
|
||||
name = "musicfs-plugins"
|
||||
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>;
|
||||
@@ -0,0 +1,15 @@
|
||||
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,346 @@
|
||||
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) {
|
||||
debug!(plugin_count = self.list().len(), "PluginManager dropping");
|
||||
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,339 @@
|
||||
//! 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);
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user