fix(plugins): silence plugin warnings and folder creation when plugins disabled (#4297)

* fix(plugins): silence repeated “Plugin not found” spam for inactive Spotify/Last.fm plugins

Navidrome was emitting a warning when the optional Spotify or
Last.fm agents weren’t enabled, filling the journal with entries like:

    level=warning msg="Plugin not found" capability=MetadataAgent name=spotify

Fixed by completely disable the plugin system when Plugins.Enabled = false.

Signed-off-by: Deluan <deluan@navidrome.org>

* style: update test description for clarity

Signed-off-by: Deluan <deluan@navidrome.org>

* fix: ensure plugin folder is created only if plugins are enabled

Signed-off-by: Deluan <deluan@navidrome.org>

---------

Signed-off-by: Deluan <deluan@navidrome.org>
This commit is contained in:
Deluan Quintão
2025-07-02 13:17:59 -04:00
committed by GitHub
parent 82f490d066
commit a3d1a9dbe5
18 changed files with 102 additions and 64 deletions

View File

@@ -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

View File

@@ -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

View File

@@ -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()

View File

@@ -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)

View File

@@ -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()

View File

@@ -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)

View File

@@ -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)

View File

@@ -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)

View File

@@ -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,

View File

@@ -11,7 +11,7 @@ import (
var _ = Describe("SchedulerService", func() {
var (
ss *schedulerService
manager *Manager
manager *managerImpl
pluginName = "test_plugin"
)

View File

@@ -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,

View File

@@ -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

View File

@@ -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() {}

View File

@@ -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-*")

View File

@@ -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
)

View File

@@ -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() {

View File

@@ -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

View File

@@ -34,7 +34,7 @@ var _ = Describe("Runtime", func() {
var _ = Describe("CachingRuntime", func() {
var (
ctx context.Context
mgr *Manager
mgr *managerImpl
plugin *wasmScrobblerPlugin
)