diff --git a/conf/configuration.go b/conf/configuration.go index 07e04e965..89cb7ab3e 100644 --- a/conf/configuration.go +++ b/conf/configuration.go @@ -226,9 +226,10 @@ type inspectOptions struct { } type pluginsOptions struct { - Enabled bool - Folder string - CacheSize string + Enabled bool + Folder string + CacheSize string + AutoReload bool } type extAuthOptions struct { @@ -634,6 +635,7 @@ func setViperDefaults() { viper.SetDefault("plugins.folder", "") viper.SetDefault("plugins.enabled", false) viper.SetDefault("plugins.cachesize", "100MB") + viper.SetDefault("plugins.autoreload", false) // DevFlags. These are used to enable/disable debugging and incomplete features viper.SetDefault("devlogsourceline", false) diff --git a/plugins/README.md b/plugins/README.md index 33e09b3c6..01f90efd3 100644 --- a/plugins/README.md +++ b/plugins/README.md @@ -172,6 +172,50 @@ if !ok { } ``` +## Runtime Loading + +Navidrome supports loading, unloading, and reloading plugins at runtime without restarting the server. + +### Auto-Reload (File Watcher) + +Enable automatic plugin reloading when files change: + +```toml +[Plugins] +Enabled = true +AutoReload = true # Default: false +``` + +When enabled, Navidrome watches the plugins folder and automatically: +- **Loads** new `.wasm` files when they are created +- **Reloads** plugins when their `.wasm` file is modified +- **Unloads** plugins when their `.wasm` file is removed + +This is especially useful during plugin development - just rebuild your plugin and it will be automatically reloaded. + +### Programmatic API + +The plugin Manager exposes methods for runtime plugin management: + +```go +manager := plugins.GetManager() + +// Load a new plugin (file must exist at /.wasm) +err := manager.LoadPlugin("my-plugin") + +// Unload a running plugin +err := manager.UnloadPlugin("my-plugin") + +// Reload a plugin (unload + load) +err := manager.ReloadPlugin("my-plugin") +``` + +### Notes on Runtime Loading + +- **In-flight requests**: When a plugin is unloaded, existing plugin instances continue working until their request completes. New requests use the reloaded version. +- **Config changes**: Plugin configuration (`PluginConfig.`) is read at load time. Changes require a reload. +- **Failed reloads**: If loading fails after unloading, the plugin remains unloaded. Check logs for errors. + ## Security Plugins run in a secure WebAssembly sandbox with these restrictions: diff --git a/plugins/manager.go b/plugins/manager.go index 9d43cc74d..3eb51a03a 100644 --- a/plugins/manager.go +++ b/plugins/manager.go @@ -16,6 +16,7 @@ import ( "github.com/navidrome/navidrome/core/scrobbler" "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/utils/singleton" + "github.com/rjeczalik/notify" "github.com/tetratelabs/wazero" ) @@ -36,6 +37,12 @@ type Manager struct { ctx context.Context cancel context.CancelFunc cache wazero.CompilationCache + + // File watcher fields (used when AutoReload is enabled) + watcherEvents chan notify.EventInfo + watcherDone chan struct{} + debounceTimers map[string]*time.Timer + debounceMu sync.Mutex } // pluginInstance represents a loaded plugin @@ -90,11 +97,22 @@ func (m *Manager) Start(ctx context.Context) error { return err } + // Start file watcher if auto-reload is enabled + if autoReloadEnabled() { + if err := m.startWatcher(); err != nil { + log.Error(ctx, "Failed to start plugin file watcher", err) + // Non-fatal - plugins are still loaded, just no auto-reload + } + } + return nil } // Stop shuts down the plugin manager and releases all resources. func (m *Manager) Stop() error { + // Stop file watcher first + m.stopWatcher() + if m.cancel != nil { m.cancel() } @@ -273,7 +291,7 @@ func (m *Manager) loadPlugin(name, wasmPath string) error { return err } if exit != 0 { - return err + return fmt.Errorf("calling %s: %d", ManifestFunction, exit) } // Parse manifest (validation happens during unmarshal via generated code) @@ -342,6 +360,83 @@ func (m *Manager) createMetadataAgent(instance *pluginInstance) (*MetadataAgent, return NewMetadataAgent(instance.name, plugin), nil } +// UnloadPlugin removes a plugin from the manager and closes its resources. +// Returns an error if the plugin is not found. +func (m *Manager) UnloadPlugin(name string) error { + m.mu.Lock() + instance, ok := m.plugins[name] + if !ok { + m.mu.Unlock() + return fmt.Errorf("plugin %q not found", name) + } + delete(m.plugins, name) + m.mu.Unlock() + + // Close the compiled plugin outside the lock with a grace period + // to allow in-flight requests to complete + if instance.compiled != nil { + // Use a brief timeout for cleanup + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + if err := instance.compiled.Close(ctx); err != nil { + log.Error("Error closing plugin during unload", "plugin", name, err) + } + } + + log.Info(m.ctx, "Unloaded plugin", "plugin", name) + return nil +} + +// LoadPlugin loads a new plugin by name from the plugins folder. +// The plugin file must exist at /.wasm. +// Returns an error if the plugin is already loaded or fails to load. +func (m *Manager) LoadPlugin(name string) error { + m.mu.RLock() + _, exists := m.plugins[name] + m.mu.RUnlock() + + if exists { + return fmt.Errorf("plugin %q is already loaded", name) + } + + folder := m.pluginsFolder() + if folder == "" { + return fmt.Errorf("no plugins folder configured") + } + + wasmPath := filepath.Join(folder, name+".wasm") + if _, err := os.Stat(wasmPath); err != nil { + if os.IsNotExist(err) { + return fmt.Errorf("plugin file not found: %s", wasmPath) + } + return err + } + + if err := m.loadPlugin(name, wasmPath); err != nil { + return fmt.Errorf("failed to load plugin %q: %w", name, err) + } + + log.Info(m.ctx, "Loaded plugin", "plugin", name) + return nil +} + +// ReloadPlugin unloads and reloads a plugin by name. +// If the plugin was loaded and unload succeeds but reload fails, +// the plugin remains unloaded and the error is returned. +func (m *Manager) ReloadPlugin(name string) error { + if err := m.UnloadPlugin(name); err != nil { + return fmt.Errorf("failed to unload plugin %q: %w", name, err) + } + + if err := m.LoadPlugin(name); err != nil { + log.Error(m.ctx, "Failed to reload plugin, plugin remains unloaded", "plugin", name, err) + return fmt.Errorf("failed to reload plugin %q: %w", name, err) + } + + log.Info(m.ctx, "Reloaded plugin", "plugin", name) + return nil +} + // Verify interface implementations at compile time var ( _ agents.PluginLoader = (*Manager)(nil) diff --git a/plugins/manager_test.go b/plugins/manager_test.go new file mode 100644 index 000000000..149ce312d --- /dev/null +++ b/plugins/manager_test.go @@ -0,0 +1,160 @@ +package plugins + +import ( + "context" + "os" + "path/filepath" + "runtime" + + "github.com/navidrome/navidrome/conf" + "github.com/navidrome/navidrome/conf/configtest" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("Manager", func() { + var ( + manager *Manager + ctx context.Context + testdataDir string + tmpDir string + ) + + BeforeEach(func() { + ctx = GinkgoT().Context() + + // Get testdata directory + _, currentFile, _, ok := runtime.Caller(0) + Expect(ok).To(BeTrue()) + testdataDir = filepath.Join(filepath.Dir(currentFile), "testdata") + + // Create temp dir for plugins + var err error + tmpDir, err = os.MkdirTemp("", "plugins-test-*") + Expect(err).ToNot(HaveOccurred()) + + // Setup config + DeferCleanup(configtest.SetupConfig()) + conf.Server.Plugins.Enabled = true + conf.Server.Plugins.Folder = tmpDir + + // Create a fresh manager for each test + manager = &Manager{ + plugins: make(map[string]*pluginInstance), + } + err = manager.Start(ctx) + Expect(err).ToNot(HaveOccurred()) + + DeferCleanup(func() { + _ = manager.Stop() + _ = os.RemoveAll(tmpDir) + }) + }) + + copyTestPlugin := func(destName string) string { + srcPath := filepath.Join(testdataDir, "test-plugin.wasm") + destPath := filepath.Join(tmpDir, destName+".wasm") + data, err := os.ReadFile(srcPath) + Expect(err).ToNot(HaveOccurred()) + err = os.WriteFile(destPath, data, 0600) + Expect(err).ToNot(HaveOccurred()) + return destPath + } + + Describe("LoadPlugin", func() { + It("loads a new plugin by name", func() { + copyTestPlugin("new-plugin") + + err := manager.LoadPlugin("new-plugin") + Expect(err).ToNot(HaveOccurred()) + + names := manager.PluginNames(string(CapabilityMetadataAgent)) + Expect(names).To(ContainElement("new-plugin")) + }) + + It("returns error when plugin file does not exist", func() { + err := manager.LoadPlugin("nonexistent") + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("plugin file not found")) + }) + + It("returns error when plugin is already loaded", func() { + copyTestPlugin("duplicate") + + err := manager.LoadPlugin("duplicate") + Expect(err).ToNot(HaveOccurred()) + + err = manager.LoadPlugin("duplicate") + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("already loaded")) + }) + + It("returns error when plugins folder is not configured", func() { + conf.Server.Plugins.Folder = "" + conf.Server.DataFolder = "" + + err := manager.LoadPlugin("test") + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("no plugins folder configured")) + }) + }) + + Describe("UnloadPlugin", func() { + It("removes a loaded plugin", func() { + copyTestPlugin("to-unload") + err := manager.LoadPlugin("to-unload") + Expect(err).ToNot(HaveOccurred()) + + err = manager.UnloadPlugin("to-unload") + Expect(err).ToNot(HaveOccurred()) + + names := manager.PluginNames(string(CapabilityMetadataAgent)) + Expect(names).ToNot(ContainElement("to-unload")) + }) + + It("returns error when plugin not found", func() { + err := manager.UnloadPlugin("nonexistent") + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("not found")) + }) + }) + + Describe("ReloadPlugin", func() { + It("unloads and reloads a plugin", func() { + copyTestPlugin("to-reload") + err := manager.LoadPlugin("to-reload") + Expect(err).ToNot(HaveOccurred()) + + err = manager.ReloadPlugin("to-reload") + Expect(err).ToNot(HaveOccurred()) + + names := manager.PluginNames(string(CapabilityMetadataAgent)) + Expect(names).To(ContainElement("to-reload")) + }) + + It("returns error when plugin not found", func() { + err := manager.ReloadPlugin("nonexistent") + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("failed to unload")) + }) + + It("keeps plugin unloaded if reload fails", func() { + copyTestPlugin("fail-reload") + err := manager.LoadPlugin("fail-reload") + Expect(err).ToNot(HaveOccurred()) + + // Remove the wasm file so reload will fail + wasmPath := filepath.Join(tmpDir, "fail-reload.wasm") + err = os.Remove(wasmPath) + Expect(err).ToNot(HaveOccurred()) + + err = manager.ReloadPlugin("fail-reload") + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("failed to reload")) + + // Plugin should no longer be loaded + names := manager.PluginNames(string(CapabilityMetadataAgent)) + Expect(names).ToNot(ContainElement("fail-reload")) + }) + }) +}) diff --git a/plugins/metadata_agent_test.go b/plugins/metadata_agent_test.go index b2eadd435..750b9e25f 100644 --- a/plugins/metadata_agent_test.go +++ b/plugins/metadata_agent_test.go @@ -18,7 +18,7 @@ var _ = Describe("MetadataAgent", func() { ) BeforeEach(func() { - ctx = context.Background() + ctx = GinkgoT().Context() // Load the test plugin _, currentFile, _, ok := runtime.Caller(0) diff --git a/plugins/watcher.go b/plugins/watcher.go new file mode 100644 index 000000000..3b8086834 --- /dev/null +++ b/plugins/watcher.go @@ -0,0 +1,153 @@ +package plugins + +import ( + "path/filepath" + "strings" + "sync" + "time" + + "github.com/navidrome/navidrome/conf" + "github.com/navidrome/navidrome/log" + "github.com/rjeczalik/notify" +) + +const ( + // debounceDuration is the time to wait before acting on file events + // to handle multiple rapid events for the same file + debounceDuration = 500 * time.Millisecond +) + +// startWatcher starts the file watcher for the plugins folder. +// It watches for CREATE, WRITE, and REMOVE events on .wasm files. +func (m *Manager) startWatcher() error { + folder := m.pluginsFolder() + if folder == "" { + return nil + } + + m.watcherEvents = make(chan notify.EventInfo, 10) + m.watcherDone = make(chan struct{}) + m.debounceTimers = make(map[string]*time.Timer) + m.debounceMu = sync.Mutex{} + + // Watch the plugins folder (not recursive) + // We filter for .wasm files in the event handler + if err := notify.Watch(folder, m.watcherEvents, notify.Create, notify.Write, notify.Remove, notify.Rename); err != nil { + close(m.watcherEvents) + return err + } + + log.Info(m.ctx, "Started plugin file watcher", "folder", folder) + + go m.watcherLoop() + + return nil +} + +// stopWatcher stops the file watcher +func (m *Manager) stopWatcher() { + if m.watcherEvents == nil { + return + } + + notify.Stop(m.watcherEvents) + close(m.watcherDone) + + // Cancel any pending debounce timers + m.debounceMu.Lock() + for _, timer := range m.debounceTimers { + timer.Stop() + } + m.debounceTimers = nil + m.debounceMu.Unlock() + + log.Debug(m.ctx, "Stopped plugin file watcher") +} + +// watcherLoop processes file watcher events +func (m *Manager) watcherLoop() { + for { + select { + case event, ok := <-m.watcherEvents: + if !ok { + return + } + m.handleWatcherEvent(event) + case <-m.ctx.Done(): + return + case <-m.watcherDone: + return + } + } +} + +// handleWatcherEvent processes a single file watcher event with debouncing +func (m *Manager) handleWatcherEvent(event notify.EventInfo) { + path := event.Path() + + // Only process .wasm files + if !strings.HasSuffix(path, ".wasm") { + return + } + + pluginName := strings.TrimSuffix(filepath.Base(path), ".wasm") + + log.Debug(m.ctx, "Plugin file event", "plugin", pluginName, "event", event.Event(), "path", path) + + // Debounce: cancel any pending timer for this plugin and start a new one + m.debounceMu.Lock() + if timer, exists := m.debounceTimers[pluginName]; exists { + timer.Stop() + } + + eventType := event.Event() + m.debounceTimers[pluginName] = time.AfterFunc(debounceDuration, func() { + m.processPluginEvent(pluginName, eventType) + }) + m.debounceMu.Unlock() +} + +// processPluginEvent handles the actual plugin load/unload/reload after debouncing +func (m *Manager) processPluginEvent(pluginName string, eventType notify.Event) { + // Clean up debounce timer entry + m.debounceMu.Lock() + delete(m.debounceTimers, pluginName) + m.debounceMu.Unlock() + + switch { + case eventType¬ify.Remove != 0 || eventType¬ify.Rename != 0: + // File removed or renamed away - unload if loaded + if err := m.UnloadPlugin(pluginName); err != nil { + // Plugin may not have been loaded, that's okay + log.Debug(m.ctx, "Plugin not loaded, skipping unload", "plugin", pluginName, err) + } + + case eventType¬ify.Create != 0: + // New file - load it + if err := m.LoadPlugin(pluginName); err != nil { + log.Error(m.ctx, "Failed to load new plugin", "plugin", pluginName, err) + } + + case eventType¬ify.Write != 0: + // File modified - check if it's loaded and reload + m.mu.RLock() + _, isLoaded := m.plugins[pluginName] + m.mu.RUnlock() + + if isLoaded { + if err := m.ReloadPlugin(pluginName); err != nil { + log.Error(m.ctx, "Failed to reload plugin", "plugin", pluginName, err) + } + } else { + // Not loaded yet, try to load it (might be a new file that was written after create) + if err := m.LoadPlugin(pluginName); err != nil { + log.Error(m.ctx, "Failed to load plugin", "plugin", pluginName, err) + } + } + } +} + +// autoReloadEnabled returns true if auto-reload is enabled +func autoReloadEnabled() bool { + return conf.Server.Plugins.AutoReload +} diff --git a/plugins/watcher_test.go b/plugins/watcher_test.go new file mode 100644 index 000000000..66cb2a785 --- /dev/null +++ b/plugins/watcher_test.go @@ -0,0 +1,150 @@ +package plugins + +import ( + "context" + "os" + "path/filepath" + "runtime" + "time" + + "github.com/navidrome/navidrome/conf" + "github.com/navidrome/navidrome/conf/configtest" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("Watcher", func() { + var ( + manager *Manager + ctx context.Context + testdataDir string + tmpDir string + ) + + BeforeEach(func() { + ctx = GinkgoT().Context() + + // Get testdata directory + _, currentFile, _, ok := runtime.Caller(0) + Expect(ok).To(BeTrue()) + testdataDir = filepath.Join(filepath.Dir(currentFile), "testdata") + + // Create temp dir for plugins + var err error + tmpDir, err = os.MkdirTemp("", "plugins-watcher-test-*") + Expect(err).ToNot(HaveOccurred()) + + // Setup config with AutoReload enabled + DeferCleanup(configtest.SetupConfig()) + conf.Server.Plugins.Enabled = true + conf.Server.Plugins.Folder = tmpDir + conf.Server.Plugins.AutoReload = true + + // Create a fresh manager for each test + manager = &Manager{ + plugins: make(map[string]*pluginInstance), + } + err = manager.Start(ctx) + Expect(err).ToNot(HaveOccurred()) + + DeferCleanup(func() { + _ = manager.Stop() + _ = os.RemoveAll(tmpDir) + }) + }) + + copyTestPlugin := func(destName string) string { + srcPath := filepath.Join(testdataDir, "test-plugin.wasm") + destPath := filepath.Join(tmpDir, destName+".wasm") + data, err := os.ReadFile(srcPath) + Expect(err).ToNot(HaveOccurred()) + err = os.WriteFile(destPath, data, 0600) + Expect(err).ToNot(HaveOccurred()) + return destPath + } + + Describe("Auto-reload via file watcher", func() { + It("loads a plugin when a new wasm file is created", func() { + // Copy plugin file to trigger CREATE event + copyTestPlugin("watch-create") + + // Wait for debounce + processing + Eventually(func() []string { + return manager.PluginNames(string(CapabilityMetadataAgent)) + }, 2*time.Second, 100*time.Millisecond).Should(ContainElement("watch-create")) + }) + + It("reloads a plugin when the wasm file is modified", func() { + // First, load a plugin + copyTestPlugin("watch-modify") + + // Wait for it to be loaded + Eventually(func() []string { + return manager.PluginNames(string(CapabilityMetadataAgent)) + }, 2*time.Second, 100*time.Millisecond).Should(ContainElement("watch-modify")) + + // Get the original plugin info + originalInfo := manager.GetPluginInfo()["watch-modify"] + Expect(originalInfo.Name).ToNot(BeEmpty()) + + // Modify the file (re-copy to trigger WRITE event) + wasmPath := filepath.Join(tmpDir, "watch-modify.wasm") + data, err := os.ReadFile(wasmPath) + Expect(err).ToNot(HaveOccurred()) + + // Touch the file to trigger write event + err = os.WriteFile(wasmPath, data, 0600) + Expect(err).ToNot(HaveOccurred()) + + // Wait for reload - the plugin should still be there + // We can't easily verify it was reloaded without adding tracking, + // but at least verify it's still loaded + Consistently(func() []string { + return manager.PluginNames(string(CapabilityMetadataAgent)) + }, 1*time.Second, 100*time.Millisecond).Should(ContainElement("watch-modify")) + }) + + It("unloads a plugin when the wasm file is removed", func() { + // First, load a plugin + wasmPath := copyTestPlugin("watch-remove") + + // Wait for it to be loaded + Eventually(func() []string { + return manager.PluginNames(string(CapabilityMetadataAgent)) + }, 2*time.Second, 100*time.Millisecond).Should(ContainElement("watch-remove")) + + // Remove the file + err := os.Remove(wasmPath) + Expect(err).ToNot(HaveOccurred()) + + // Wait for it to be unloaded + Eventually(func() []string { + return manager.PluginNames(string(CapabilityMetadataAgent)) + }, 2*time.Second, 100*time.Millisecond).ShouldNot(ContainElement("watch-remove")) + }) + }) + + Describe("Watcher disabled", func() { + BeforeEach(func() { + // Stop existing manager and create one without auto-reload + _ = manager.Stop() + + conf.Server.Plugins.AutoReload = false + manager = &Manager{ + plugins: make(map[string]*pluginInstance), + } + err := manager.Start(ctx) + Expect(err).ToNot(HaveOccurred()) + }) + + It("does not auto-load plugins when AutoReload is disabled", func() { + // Copy plugin file + copyTestPlugin("no-watch") + + // Wait a bit and verify plugin is NOT loaded + Consistently(func() []string { + return manager.PluginNames(string(CapabilityMetadataAgent)) + }, 1*time.Second, 100*time.Millisecond).ShouldNot(ContainElement("no-watch")) + }) + }) +})