diff --git a/cmd/wire_gen.go b/cmd/wire_gen.go index 59cf91e89..dc558c393 100644 --- a/cmd/wire_gen.go +++ b/cmd/wire_gen.go @@ -175,7 +175,7 @@ func GetPlaybackServer() playback.PlaybackServer { return playbackServer } -func getPluginManager() *plugins.Manager { +func getPluginManager() plugins.Manager { sqlDB := db.Db() dataStore := persistence.New(sqlDB) metricsMetrics := metrics.GetPrometheusInstance(dataStore) @@ -185,9 +185,9 @@ func getPluginManager() *plugins.Manager { // wire_injectors.go: -var allProviders = wire.NewSet(core.Set, artwork.Set, server.New, subsonic.New, nativeapi.New, public.New, persistence.New, lastfm.NewRouter, listenbrainz.NewRouter, events.GetBroker, scanner.New, scanner.NewWatcher, plugins.GetManager, metrics.GetPrometheusInstance, db.Db, wire.Bind(new(agents.PluginLoader), new(*plugins.Manager)), wire.Bind(new(scrobbler.PluginLoader), new(*plugins.Manager))) +var allProviders = wire.NewSet(core.Set, artwork.Set, server.New, subsonic.New, nativeapi.New, public.New, persistence.New, lastfm.NewRouter, listenbrainz.NewRouter, events.GetBroker, scanner.New, scanner.NewWatcher, plugins.GetManager, metrics.GetPrometheusInstance, db.Db, wire.Bind(new(agents.PluginLoader), new(plugins.Manager)), wire.Bind(new(scrobbler.PluginLoader), new(plugins.Manager))) -func GetPluginManager(ctx context.Context) *plugins.Manager { +func GetPluginManager(ctx context.Context) plugins.Manager { manager := getPluginManager() manager.SetSubsonicRouter(CreateSubsonicAPIRouter(ctx)) return manager diff --git a/cmd/wire_injectors.go b/cmd/wire_injectors.go index 9530e9bcf..e2bc6cd1b 100644 --- a/cmd/wire_injectors.go +++ b/cmd/wire_injectors.go @@ -42,8 +42,8 @@ var allProviders = wire.NewSet( plugins.GetManager, metrics.GetPrometheusInstance, db.Db, - wire.Bind(new(agents.PluginLoader), new(*plugins.Manager)), - wire.Bind(new(scrobbler.PluginLoader), new(*plugins.Manager)), + wire.Bind(new(agents.PluginLoader), new(plugins.Manager)), + wire.Bind(new(scrobbler.PluginLoader), new(plugins.Manager)), ) func CreateDataStore() model.DataStore { @@ -118,13 +118,13 @@ func GetPlaybackServer() playback.PlaybackServer { )) } -func getPluginManager() *plugins.Manager { +func getPluginManager() plugins.Manager { panic(wire.Build( allProviders, )) } -func GetPluginManager(ctx context.Context) *plugins.Manager { +func GetPluginManager(ctx context.Context) plugins.Manager { manager := getPluginManager() manager.SetSubsonicRouter(CreateSubsonicAPIRouter(ctx)) return manager diff --git a/conf/configuration.go b/conf/configuration.go index 258e3727f..bb1ae120b 100644 --- a/conf/configuration.go +++ b/conf/configuration.go @@ -264,13 +264,15 @@ func Load(noConfigDump bool) { os.Exit(1) } - if Server.Plugins.Folder == "" { - Server.Plugins.Folder = filepath.Join(Server.DataFolder, "plugins") - } - err = os.MkdirAll(Server.Plugins.Folder, 0700) - if err != nil { - _, _ = fmt.Fprintln(os.Stderr, "FATAL: Error creating plugins path:", err) - os.Exit(1) + if Server.Plugins.Enabled { + if Server.Plugins.Folder == "" { + Server.Plugins.Folder = filepath.Join(Server.DataFolder, "plugins") + } + err = os.MkdirAll(Server.Plugins.Folder, 0700) + if err != nil { + _, _ = fmt.Fprintln(os.Stderr, "FATAL: Error creating plugins path:", err) + os.Exit(1) + } } Server.ConfigFile = viper.GetViper().ConfigFileUsed() diff --git a/plugins/adapter_media_agent.go b/plugins/adapter_media_agent.go index 43fc0e030..7f29051e4 100644 --- a/plugins/adapter_media_agent.go +++ b/plugins/adapter_media_agent.go @@ -10,7 +10,7 @@ import ( ) // NewWasmMediaAgent creates a new adapter for a MetadataAgent plugin -func newWasmMediaAgent(wasmPath, pluginID string, m *Manager, runtime api.WazeroNewRuntime, mc wazero.ModuleConfig) WasmPlugin { +func newWasmMediaAgent(wasmPath, pluginID string, m *managerImpl, runtime api.WazeroNewRuntime, mc wazero.ModuleConfig) WasmPlugin { loader, err := api.NewMetadataAgentPlugin(context.Background(), api.WazeroRuntime(runtime), api.WazeroModuleConfig(mc)) if err != nil { log.Error("Error creating media metadata service plugin", "plugin", pluginID, "path", wasmPath, err) diff --git a/plugins/adapter_media_agent_test.go b/plugins/adapter_media_agent_test.go index 709fd62cd..e730507f3 100644 --- a/plugins/adapter_media_agent_test.go +++ b/plugins/adapter_media_agent_test.go @@ -14,7 +14,7 @@ import ( var _ = Describe("Adapter Media Agent", func() { var ctx context.Context - var mgr *Manager + var mgr *managerImpl BeforeEach(func() { ctx = GinkgoT().Context() diff --git a/plugins/adapter_scheduler_callback.go b/plugins/adapter_scheduler_callback.go index 2fe94d613..2e9f5a968 100644 --- a/plugins/adapter_scheduler_callback.go +++ b/plugins/adapter_scheduler_callback.go @@ -9,7 +9,7 @@ import ( ) // newWasmSchedulerCallback creates a new adapter for a SchedulerCallback plugin -func newWasmSchedulerCallback(wasmPath, pluginID string, m *Manager, runtime api.WazeroNewRuntime, mc wazero.ModuleConfig) WasmPlugin { +func newWasmSchedulerCallback(wasmPath, pluginID string, m *managerImpl, runtime api.WazeroNewRuntime, mc wazero.ModuleConfig) WasmPlugin { loader, err := api.NewSchedulerCallbackPlugin(context.Background(), api.WazeroRuntime(runtime), api.WazeroModuleConfig(mc)) if err != nil { log.Error("Error creating scheduler callback plugin", "plugin", pluginID, "path", wasmPath, err) diff --git a/plugins/adapter_scrobbler.go b/plugins/adapter_scrobbler.go index b9c27901f..874ce6b3d 100644 --- a/plugins/adapter_scrobbler.go +++ b/plugins/adapter_scrobbler.go @@ -12,7 +12,7 @@ import ( "github.com/tetratelabs/wazero" ) -func newWasmScrobblerPlugin(wasmPath, pluginID string, m *Manager, runtime api.WazeroNewRuntime, mc wazero.ModuleConfig) WasmPlugin { +func newWasmScrobblerPlugin(wasmPath, pluginID string, m *managerImpl, runtime api.WazeroNewRuntime, mc wazero.ModuleConfig) WasmPlugin { loader, err := api.NewScrobblerPlugin(context.Background(), api.WazeroRuntime(runtime), api.WazeroModuleConfig(mc)) if err != nil { log.Error("Error creating scrobbler service plugin", "plugin", pluginID, "path", wasmPath, err) diff --git a/plugins/adapter_websocket_callback.go b/plugins/adapter_websocket_callback.go index c45ee342e..288578d82 100644 --- a/plugins/adapter_websocket_callback.go +++ b/plugins/adapter_websocket_callback.go @@ -9,7 +9,7 @@ import ( ) // newWasmWebSocketCallback creates a new adapter for a WebSocketCallback plugin -func newWasmWebSocketCallback(wasmPath, pluginID string, m *Manager, runtime api.WazeroNewRuntime, mc wazero.ModuleConfig) WasmPlugin { +func newWasmWebSocketCallback(wasmPath, pluginID string, m *managerImpl, runtime api.WazeroNewRuntime, mc wazero.ModuleConfig) WasmPlugin { loader, err := api.NewWebSocketCallbackPlugin(context.Background(), api.WazeroRuntime(runtime), api.WazeroModuleConfig(mc)) if err != nil { log.Error("Error creating WebSocket callback plugin", "plugin", pluginID, "path", wasmPath, err) diff --git a/plugins/host_scheduler.go b/plugins/host_scheduler.go index 6cea93280..185e6c500 100644 --- a/plugins/host_scheduler.go +++ b/plugins/host_scheduler.go @@ -49,13 +49,13 @@ func (s SchedulerHostFunctions) CancelSchedule(ctx context.Context, req *schedul type schedulerService struct { // Map of schedule IDs to their callback info schedules map[string]*ScheduledCallback - manager *Manager + manager *managerImpl navidSched navidsched.Scheduler // Navidrome scheduler for recurring jobs mu sync.Mutex } // newSchedulerService creates a new schedulerService instance -func newSchedulerService(manager *Manager) *schedulerService { +func newSchedulerService(manager *managerImpl) *schedulerService { return &schedulerService{ schedules: make(map[string]*ScheduledCallback), manager: manager, diff --git a/plugins/host_scheduler_test.go b/plugins/host_scheduler_test.go index f544d716e..e4176e435 100644 --- a/plugins/host_scheduler_test.go +++ b/plugins/host_scheduler_test.go @@ -11,7 +11,7 @@ import ( var _ = Describe("SchedulerService", func() { var ( ss *schedulerService - manager *Manager + manager *managerImpl pluginName = "test_plugin" ) diff --git a/plugins/host_websocket.go b/plugins/host_websocket.go index 131596b94..452ea6633 100644 --- a/plugins/host_websocket.go +++ b/plugins/host_websocket.go @@ -50,12 +50,12 @@ func (s WebSocketHostFunctions) Close(ctx context.Context, req *websocket.CloseR // websocketService implements the WebSocket service functionality type websocketService struct { connections map[string]*WebSocketConnection - manager *Manager + manager *managerImpl mu sync.RWMutex } // newWebsocketService creates a new websocketService instance -func newWebsocketService(manager *Manager) *websocketService { +func newWebsocketService(manager *managerImpl) *websocketService { return &websocketService{ connections: make(map[string]*WebSocketConnection), manager: manager, diff --git a/plugins/host_websocket_test.go b/plugins/host_websocket_test.go index b6f4e2094..00b20b452 100644 --- a/plugins/host_websocket_test.go +++ b/plugins/host_websocket_test.go @@ -17,7 +17,7 @@ import ( var _ = Describe("WebSocket Host Service", func() { var ( wsService *websocketService - manager *Manager + manager *managerImpl ctx context.Context server *httptest.Server upgrader gorillaws.Upgrader diff --git a/plugins/manager.go b/plugins/manager.go index 89ff854ae..6d872eff4 100644 --- a/plugins/manager.go +++ b/plugins/manager.go @@ -40,7 +40,7 @@ const ( ) // pluginCreators maps capability types to their respective creator functions -type pluginConstructor func(wasmPath, pluginID string, m *Manager, runtime api.WazeroNewRuntime, mc wazero.ModuleConfig) WasmPlugin +type pluginConstructor func(wasmPath, pluginID string, m *managerImpl, runtime api.WazeroNewRuntime, mc wazero.ModuleConfig) WasmPlugin var pluginCreators = map[string]pluginConstructor{ CapabilityMetadataAgent: newWasmMediaAgent, @@ -86,8 +86,21 @@ func (p *plugin) waitForCompilation() error { type SubsonicRouter http.Handler -// Manager is a singleton that manages plugins -type Manager struct { +type Manager interface { + SetSubsonicRouter(router SubsonicRouter) + EnsureCompiled(name string) error + PluginNames(serviceName string) []string + LoadPlugin(name string, capability string) WasmPlugin + LoadAllPlugins(capability string) []WasmPlugin + LoadMediaAgent(name string) (agents.Interface, bool) + LoadAllMediaAgents() []agents.Interface + LoadScrobbler(name string) (scrobbler.Scrobbler, bool) + LoadAllScrobblers() []scrobbler.Scrobbler + ScanPlugins() +} + +// managerImpl is a singleton that manages plugins +type managerImpl struct { plugins map[string]*plugin // Map of plugin folder name to plugin info mu sync.RWMutex // Protects plugins map subsonicRouter atomic.Pointer[SubsonicRouter] // Subsonic API router @@ -99,16 +112,19 @@ type Manager struct { metrics metrics.Metrics } -// GetManager returns the singleton instance of Manager -func GetManager(ds model.DataStore, metrics metrics.Metrics) *Manager { - return singleton.GetInstance(func() *Manager { +// GetManager returns the singleton instance of managerImpl +func GetManager(ds model.DataStore, metrics metrics.Metrics) Manager { + if !conf.Server.Plugins.Enabled { + return &noopManager{} + } + return singleton.GetInstance(func() *managerImpl { return createManager(ds, metrics) }) } -// createManager creates a new Manager instance. Used in tests -func createManager(ds model.DataStore, metrics metrics.Metrics) *Manager { - m := &Manager{ +// createManager creates a new managerImpl instance. Used in tests +func createManager(ds model.DataStore, metrics metrics.Metrics) *managerImpl { + m := &managerImpl{ plugins: make(map[string]*plugin), lifecycle: newPluginLifecycleManager(), ds: ds, @@ -122,14 +138,14 @@ func createManager(ds model.DataStore, metrics metrics.Metrics) *Manager { return m } -// SetSubsonicRouter sets the SubsonicRouter after Manager initialization -func (m *Manager) SetSubsonicRouter(router SubsonicRouter) { +// SetSubsonicRouter sets the SubsonicRouter after managerImpl initialization +func (m *managerImpl) SetSubsonicRouter(router SubsonicRouter) { m.subsonicRouter.Store(&router) } // registerPlugin adds a plugin to the registry with the given parameters // Used internally by ScanPlugins to register plugins -func (m *Manager) registerPlugin(pluginID, pluginDir, wasmPath string, manifest *schema.PluginManifest) *plugin { +func (m *managerImpl) registerPlugin(pluginID, pluginDir, wasmPath string, manifest *schema.PluginManifest) *plugin { // Create custom runtime function customRuntime := m.createRuntime(pluginID, manifest.Permissions) @@ -190,7 +206,7 @@ func (m *Manager) registerPlugin(pluginID, pluginDir, wasmPath string, manifest } // initializePluginIfNeeded calls OnInit on plugins that implement LifecycleManagement -func (m *Manager) initializePluginIfNeeded(plugin *plugin) { +func (m *managerImpl) initializePluginIfNeeded(plugin *plugin) { // Skip if already initialized if m.lifecycle.isInitialized(plugin) { return @@ -207,7 +223,7 @@ func (m *Manager) initializePluginIfNeeded(plugin *plugin) { } // ScanPlugins scans the plugins directory, discovers all valid plugins, and registers them for use. -func (m *Manager) ScanPlugins() { +func (m *managerImpl) ScanPlugins() { // Clear existing plugins m.mu.Lock() m.plugins = make(map[string]*plugin) @@ -259,7 +275,7 @@ func (m *Manager) ScanPlugins() { } // PluginNames returns the folder names of all plugins that implement the specified capability -func (m *Manager) PluginNames(capability string) []string { +func (m *managerImpl) PluginNames(capability string) []string { m.mu.RLock() defer m.mu.RUnlock() @@ -275,28 +291,26 @@ func (m *Manager) PluginNames(capability string) []string { return names } -func (m *Manager) getPlugin(name string, capability string) (*plugin, WasmPlugin) { +func (m *managerImpl) getPlugin(name string, capability string) (*plugin, WasmPlugin, error) { m.mu.RLock() defer m.mu.RUnlock() info, infoOk := m.plugins[name] adapter, adapterOk := m.adapters[name+"_"+capability] if !infoOk { - log.Warn("Plugin not found", "name", name) - return nil, nil + return nil, nil, fmt.Errorf("plugin not registered: %s", name) } if !adapterOk { - log.Warn("Plugin adapter not found", "name", name, "capability", capability) - return nil, nil + return nil, nil, fmt.Errorf("plugin adapter not registered: %s, capability: %s", name, capability) } - return info, adapter + return info, adapter, nil } // LoadPlugin instantiates and returns a plugin by folder name -func (m *Manager) LoadPlugin(name string, capability string) WasmPlugin { - info, adapter := m.getPlugin(name, capability) - if info == nil { - log.Warn("Plugin not found", "name", name, "capability", capability) +func (m *managerImpl) LoadPlugin(name string, capability string) WasmPlugin { + info, adapter, err := m.getPlugin(name, capability) + if err != nil { + log.Warn("Error loading plugin", err) return nil } @@ -318,7 +332,7 @@ func (m *Manager) LoadPlugin(name string, capability string) WasmPlugin { // EnsureCompiled waits for a plugin to finish compilation and returns any compilation error. // This is useful when you need to wait for compilation without loading a specific capability, // such as during plugin refresh operations or health checks. -func (m *Manager) EnsureCompiled(name string) error { +func (m *managerImpl) EnsureCompiled(name string) error { m.mu.RLock() plugin, ok := m.plugins[name] m.mu.RUnlock() @@ -331,7 +345,7 @@ func (m *Manager) EnsureCompiled(name string) error { } // LoadAllPlugins instantiates and returns all plugins that implement the specified capability -func (m *Manager) LoadAllPlugins(capability string) []WasmPlugin { +func (m *managerImpl) LoadAllPlugins(capability string) []WasmPlugin { names := m.PluginNames(capability) if len(names) == 0 { return nil @@ -348,7 +362,7 @@ func (m *Manager) LoadAllPlugins(capability string) []WasmPlugin { } // LoadMediaAgent instantiates and returns a media agent plugin by folder name -func (m *Manager) LoadMediaAgent(name string) (agents.Interface, bool) { +func (m *managerImpl) LoadMediaAgent(name string) (agents.Interface, bool) { plugin := m.LoadPlugin(name, CapabilityMetadataAgent) if plugin == nil { return nil, false @@ -358,7 +372,7 @@ func (m *Manager) LoadMediaAgent(name string) (agents.Interface, bool) { } // LoadAllMediaAgents instantiates and returns all media agent plugins -func (m *Manager) LoadAllMediaAgents() []agents.Interface { +func (m *managerImpl) LoadAllMediaAgents() []agents.Interface { plugins := m.LoadAllPlugins(CapabilityMetadataAgent) return slice.Map(plugins, func(p WasmPlugin) agents.Interface { @@ -367,7 +381,7 @@ func (m *Manager) LoadAllMediaAgents() []agents.Interface { } // LoadScrobbler instantiates and returns a scrobbler plugin by folder name -func (m *Manager) LoadScrobbler(name string) (scrobbler.Scrobbler, bool) { +func (m *managerImpl) LoadScrobbler(name string) (scrobbler.Scrobbler, bool) { plugin := m.LoadPlugin(name, CapabilityScrobbler) if plugin == nil { return nil, false @@ -377,10 +391,32 @@ func (m *Manager) LoadScrobbler(name string) (scrobbler.Scrobbler, bool) { } // LoadAllScrobblers instantiates and returns all scrobbler plugins -func (m *Manager) LoadAllScrobblers() []scrobbler.Scrobbler { +func (m *managerImpl) LoadAllScrobblers() []scrobbler.Scrobbler { plugins := m.LoadAllPlugins(CapabilityScrobbler) return slice.Map(plugins, func(p WasmPlugin) scrobbler.Scrobbler { return p.(scrobbler.Scrobbler) }) } + +type noopManager struct{} + +func (n noopManager) SetSubsonicRouter(router SubsonicRouter) {} + +func (n noopManager) EnsureCompiled(name string) error { return nil } + +func (n noopManager) PluginNames(serviceName string) []string { return nil } + +func (n noopManager) LoadPlugin(name string, capability string) WasmPlugin { return nil } + +func (n noopManager) LoadAllPlugins(capability string) []WasmPlugin { return nil } + +func (n noopManager) LoadMediaAgent(name string) (agents.Interface, bool) { return nil, false } + +func (n noopManager) LoadAllMediaAgents() []agents.Interface { return nil } + +func (n noopManager) LoadScrobbler(name string) (scrobbler.Scrobbler, bool) { return nil, false } + +func (n noopManager) LoadAllScrobblers() []scrobbler.Scrobbler { return nil } + +func (n noopManager) ScanPlugins() {} diff --git a/plugins/manager_test.go b/plugins/manager_test.go index 55a3b8f72..a6bb8ff0f 100644 --- a/plugins/manager_test.go +++ b/plugins/manager_test.go @@ -12,7 +12,7 @@ import ( ) var _ = Describe("Plugin Manager", func() { - var mgr *Manager + var mgr *managerImpl var ctx context.Context BeforeEach(func() { @@ -76,7 +76,7 @@ var _ = Describe("Plugin Manager", func() { Describe("ScanPlugins", func() { var tempPluginsDir string - var m *Manager + var m *managerImpl BeforeEach(func() { tempPluginsDir, _ = os.MkdirTemp("", "navidrome-plugins-test-*") diff --git a/plugins/manifest_permissions_test.go b/plugins/manifest_permissions_test.go index da221eb56..188e17746 100644 --- a/plugins/manifest_permissions_test.go +++ b/plugins/manifest_permissions_test.go @@ -47,7 +47,7 @@ func createTestPlugin(tempDir, name string, permissions schema.PluginManifestPer var _ = Describe("Plugin Permissions", func() { var ( - mgr *Manager + mgr *managerImpl tempDir string ctx context.Context ) diff --git a/plugins/plugin_lifecycle_manager_test.go b/plugins/plugin_lifecycle_manager_test.go index c0621b2a7..e46f29b76 100644 --- a/plugins/plugin_lifecycle_manager_test.go +++ b/plugins/plugin_lifecycle_manager_test.go @@ -18,7 +18,7 @@ func hasInitService(info *plugin) bool { } var _ = Describe("LifecycleManagement", func() { - Describe("Plugin Lifecycle Manager", func() { + Describe("Plugin Lifecycle managerImpl", func() { var lifecycleManager *pluginLifecycleManager BeforeEach(func() { diff --git a/plugins/runtime.go b/plugins/runtime.go index f68175efc..ee298e63d 100644 --- a/plugins/runtime.go +++ b/plugins/runtime.go @@ -41,7 +41,7 @@ var ( // createRuntime returns a function that creates a new wazero runtime and instantiates the required host functions // based on the given plugin permissions -func (m *Manager) createRuntime(pluginID string, permissions schema.PluginManifestPermissions) api.WazeroNewRuntime { +func (m *managerImpl) createRuntime(pluginID string, permissions schema.PluginManifestPermissions) api.WazeroNewRuntime { return func(ctx context.Context) (wazero.Runtime, error) { // Check if runtime already exists if rt, ok := runtimePool.Load(pluginID); ok { @@ -70,7 +70,7 @@ func (m *Manager) createRuntime(pluginID string, permissions schema.PluginManife } // createCachingRuntime handles the complex logic of setting up a new cachingRuntime -func (m *Manager) createCachingRuntime(ctx context.Context, pluginID string, permissions schema.PluginManifestPermissions) (*cachingRuntime, error) { +func (m *managerImpl) createCachingRuntime(ctx context.Context, pluginID string, permissions schema.PluginManifestPermissions) (*cachingRuntime, error) { // Get compilation cache compCache, err := getCompilationCache() if err != nil { @@ -94,7 +94,7 @@ func (m *Manager) createCachingRuntime(ctx context.Context, pluginID string, per } // setupHostServices configures all the permitted host services for a plugin -func (m *Manager) setupHostServices(ctx context.Context, r wazero.Runtime, pluginID string, permissions schema.PluginManifestPermissions) error { +func (m *managerImpl) setupHostServices(ctx context.Context, r wazero.Runtime, pluginID string, permissions schema.PluginManifestPermissions) error { // Define all available host services type hostService struct { name string diff --git a/plugins/runtime_test.go b/plugins/runtime_test.go index 32cd42118..507f68b20 100644 --- a/plugins/runtime_test.go +++ b/plugins/runtime_test.go @@ -34,7 +34,7 @@ var _ = Describe("Runtime", func() { var _ = Describe("CachingRuntime", func() { var ( ctx context.Context - mgr *Manager + mgr *managerImpl plugin *wasmScrobblerPlugin )