mirror of
https://github.com/navidrome/navidrome.git
synced 2025-07-14 07:31:28 +03:00
* Add prometheus metrics to subsonic and plugins * address feedback, do not log error if operation is not supported * add missing timestamp and client to stats * remove .view from subsonic route * directly inject DataStore in Prometheus, to avoid having to pass it in every call Signed-off-by: Deluan <deluan@navidrome.org> --------- Signed-off-by: Deluan <deluan@navidrome.org> Co-authored-by: Deluan <deluan@navidrome.org>
387 lines
13 KiB
Go
387 lines
13 KiB
Go
package plugins
|
|
|
|
//go:generate protoc --go-plugin_out=. --go-plugin_opt=paths=source_relative api/api.proto
|
|
//go:generate protoc --go-plugin_out=. --go-plugin_opt=paths=source_relative host/http/http.proto
|
|
//go:generate protoc --go-plugin_out=. --go-plugin_opt=paths=source_relative host/config/config.proto
|
|
//go:generate protoc --go-plugin_out=. --go-plugin_opt=paths=source_relative host/websocket/websocket.proto
|
|
//go:generate protoc --go-plugin_out=. --go-plugin_opt=paths=source_relative host/scheduler/scheduler.proto
|
|
//go:generate protoc --go-plugin_out=. --go-plugin_opt=paths=source_relative host/cache/cache.proto
|
|
//go:generate protoc --go-plugin_out=. --go-plugin_opt=paths=source_relative host/artwork/artwork.proto
|
|
//go:generate protoc --go-plugin_out=. --go-plugin_opt=paths=source_relative host/subsonicapi/subsonicapi.proto
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"net/http"
|
|
"os"
|
|
"sync"
|
|
"sync/atomic"
|
|
"time"
|
|
|
|
"github.com/navidrome/navidrome/conf"
|
|
"github.com/navidrome/navidrome/core/agents"
|
|
"github.com/navidrome/navidrome/core/metrics"
|
|
"github.com/navidrome/navidrome/core/scrobbler"
|
|
"github.com/navidrome/navidrome/log"
|
|
"github.com/navidrome/navidrome/model"
|
|
"github.com/navidrome/navidrome/plugins/api"
|
|
"github.com/navidrome/navidrome/plugins/schema"
|
|
"github.com/navidrome/navidrome/utils/singleton"
|
|
"github.com/navidrome/navidrome/utils/slice"
|
|
"github.com/tetratelabs/wazero"
|
|
)
|
|
|
|
const (
|
|
CapabilityMetadataAgent = "MetadataAgent"
|
|
CapabilityScrobbler = "Scrobbler"
|
|
CapabilitySchedulerCallback = "SchedulerCallback"
|
|
CapabilityWebSocketCallback = "WebSocketCallback"
|
|
CapabilityLifecycleManagement = "LifecycleManagement"
|
|
)
|
|
|
|
// pluginCreators maps capability types to their respective creator functions
|
|
type pluginConstructor func(wasmPath, pluginID string, m *Manager, runtime api.WazeroNewRuntime, mc wazero.ModuleConfig) WasmPlugin
|
|
|
|
var pluginCreators = map[string]pluginConstructor{
|
|
CapabilityMetadataAgent: newWasmMediaAgent,
|
|
CapabilityScrobbler: newWasmScrobblerPlugin,
|
|
CapabilitySchedulerCallback: newWasmSchedulerCallback,
|
|
CapabilityWebSocketCallback: newWasmWebSocketCallback,
|
|
}
|
|
|
|
// WasmPlugin is the base interface that all WASM plugins implement
|
|
type WasmPlugin interface {
|
|
// PluginID returns the unique identifier of the plugin (folder name)
|
|
PluginID() string
|
|
// Instantiate creates a new instance of the plugin and returns it along with a cleanup function
|
|
Instantiate(ctx context.Context) (any, func(), error)
|
|
}
|
|
|
|
type plugin struct {
|
|
ID string
|
|
Path string
|
|
Capabilities []string
|
|
WasmPath string
|
|
Manifest *schema.PluginManifest // Loaded manifest
|
|
Runtime api.WazeroNewRuntime
|
|
ModConfig wazero.ModuleConfig
|
|
compilationReady chan struct{}
|
|
compilationErr error
|
|
}
|
|
|
|
func (p *plugin) waitForCompilation() error {
|
|
timeout := pluginCompilationTimeout()
|
|
select {
|
|
case <-p.compilationReady:
|
|
case <-time.After(timeout):
|
|
err := fmt.Errorf("timed out waiting for plugin %s to compile", p.ID)
|
|
log.Error("Timed out waiting for plugin compilation", "name", p.ID, "path", p.WasmPath, "timeout", timeout, "err", err)
|
|
return err
|
|
}
|
|
if p.compilationErr != nil {
|
|
log.Error("Failed to compile plugin", "name", p.ID, "path", p.WasmPath, p.compilationErr)
|
|
}
|
|
return p.compilationErr
|
|
}
|
|
|
|
type SubsonicRouter http.Handler
|
|
|
|
// Manager is a singleton that manages plugins
|
|
type Manager 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
|
|
schedulerService *schedulerService // Service for handling scheduled tasks
|
|
websocketService *websocketService // Service for handling WebSocket connections
|
|
lifecycle *pluginLifecycleManager // Manages plugin lifecycle and initialization
|
|
adapters map[string]WasmPlugin // Map of plugin folder name + capability to adapter
|
|
ds model.DataStore // DataStore for accessing persistent data
|
|
metrics metrics.Metrics
|
|
}
|
|
|
|
// GetManager returns the singleton instance of Manager
|
|
func GetManager(ds model.DataStore, metrics metrics.Metrics) *Manager {
|
|
return singleton.GetInstance(func() *Manager {
|
|
return createManager(ds, metrics)
|
|
})
|
|
}
|
|
|
|
// createManager creates a new Manager instance. Used in tests
|
|
func createManager(ds model.DataStore, metrics metrics.Metrics) *Manager {
|
|
m := &Manager{
|
|
plugins: make(map[string]*plugin),
|
|
lifecycle: newPluginLifecycleManager(),
|
|
ds: ds,
|
|
metrics: metrics,
|
|
}
|
|
|
|
// Create the host services
|
|
m.schedulerService = newSchedulerService(m)
|
|
m.websocketService = newWebsocketService(m)
|
|
|
|
return m
|
|
}
|
|
|
|
// SetSubsonicRouter sets the SubsonicRouter after Manager initialization
|
|
func (m *Manager) 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 {
|
|
// Create custom runtime function
|
|
customRuntime := m.createRuntime(pluginID, manifest.Permissions)
|
|
|
|
// Configure module and determine plugin name
|
|
mc := newWazeroModuleConfig()
|
|
|
|
// Check if it's a symlink, indicating development mode
|
|
isSymlink := false
|
|
if fileInfo, err := os.Lstat(pluginDir); err == nil {
|
|
isSymlink = fileInfo.Mode()&os.ModeSymlink != 0
|
|
}
|
|
|
|
// Store plugin info
|
|
p := &plugin{
|
|
ID: pluginID,
|
|
Path: pluginDir,
|
|
Capabilities: slice.Map(manifest.Capabilities, func(cap schema.PluginManifestCapabilitiesElem) string { return string(cap) }),
|
|
WasmPath: wasmPath,
|
|
Manifest: manifest,
|
|
Runtime: customRuntime,
|
|
ModConfig: mc,
|
|
compilationReady: make(chan struct{}),
|
|
}
|
|
|
|
// Start pre-compilation of WASM module in background
|
|
go func() {
|
|
precompilePlugin(p)
|
|
// Check if this plugin implements InitService and hasn't been initialized yet
|
|
m.initializePluginIfNeeded(p)
|
|
}()
|
|
|
|
// Register the plugin
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
m.plugins[pluginID] = p
|
|
|
|
// Register one plugin adapter for each capability
|
|
for _, capability := range manifest.Capabilities {
|
|
capabilityStr := string(capability)
|
|
constructor := pluginCreators[capabilityStr]
|
|
if constructor == nil {
|
|
// Warn about unknown capabilities, except for LifecycleManagement (it does not have an adapter)
|
|
if capability != CapabilityLifecycleManagement {
|
|
log.Warn("Unknown plugin capability type", "capability", capability, "plugin", pluginID)
|
|
}
|
|
continue
|
|
}
|
|
adapter := constructor(wasmPath, pluginID, m, customRuntime, mc)
|
|
if adapter == nil {
|
|
log.Error("Failed to create plugin adapter", "plugin", pluginID, "capability", capabilityStr, "path", wasmPath)
|
|
continue
|
|
}
|
|
m.adapters[pluginID+"_"+capabilityStr] = adapter
|
|
}
|
|
|
|
log.Info("Discovered plugin", "folder", pluginID, "name", manifest.Name, "capabilities", manifest.Capabilities, "wasm", wasmPath, "dev_mode", isSymlink)
|
|
return m.plugins[pluginID]
|
|
}
|
|
|
|
// initializePluginIfNeeded calls OnInit on plugins that implement LifecycleManagement
|
|
func (m *Manager) initializePluginIfNeeded(plugin *plugin) {
|
|
// Skip if already initialized
|
|
if m.lifecycle.isInitialized(plugin) {
|
|
return
|
|
}
|
|
|
|
// Check if the plugin implements LifecycleManagement
|
|
for _, capability := range plugin.Manifest.Capabilities {
|
|
if capability == CapabilityLifecycleManagement {
|
|
m.lifecycle.callOnInit(plugin)
|
|
m.lifecycle.markInitialized(plugin)
|
|
break
|
|
}
|
|
}
|
|
}
|
|
|
|
// ScanPlugins scans the plugins directory, discovers all valid plugins, and registers them for use.
|
|
func (m *Manager) ScanPlugins() {
|
|
// Clear existing plugins
|
|
m.mu.Lock()
|
|
m.plugins = make(map[string]*plugin)
|
|
m.adapters = make(map[string]WasmPlugin)
|
|
m.mu.Unlock()
|
|
|
|
// Get plugins directory from config
|
|
root := conf.Server.Plugins.Folder
|
|
log.Debug("Scanning plugins folder", "root", root)
|
|
|
|
// Fail fast if the compilation cache cannot be initialized
|
|
_, err := getCompilationCache()
|
|
if err != nil {
|
|
log.Error("Failed to initialize plugins compilation cache. Disabling plugins", err)
|
|
return
|
|
}
|
|
|
|
// Discover all plugins using the shared discovery function
|
|
discoveries := DiscoverPlugins(root)
|
|
|
|
var validPluginNames []string
|
|
for _, discovery := range discoveries {
|
|
if discovery.Error != nil {
|
|
// Handle global errors (like directory read failure)
|
|
if discovery.ID == "" {
|
|
log.Error("Plugin discovery failed", discovery.Error)
|
|
return
|
|
}
|
|
// Handle individual plugin errors
|
|
log.Error("Failed to process plugin", "plugin", discovery.ID, discovery.Error)
|
|
continue
|
|
}
|
|
|
|
// Log discovery details
|
|
log.Debug("Processing entry", "name", discovery.ID, "isSymlink", discovery.IsSymlink)
|
|
if discovery.IsSymlink {
|
|
log.Debug("Processing symlinked plugin directory", "name", discovery.ID, "target", discovery.Path)
|
|
}
|
|
log.Debug("Checking for plugin.wasm", "wasmPath", discovery.WasmPath)
|
|
log.Debug("Manifest loaded successfully", "folder", discovery.ID, "name", discovery.Manifest.Name, "capabilities", discovery.Manifest.Capabilities)
|
|
|
|
validPluginNames = append(validPluginNames, discovery.ID)
|
|
|
|
// Register the plugin
|
|
m.registerPlugin(discovery.ID, discovery.Path, discovery.WasmPath, discovery.Manifest)
|
|
}
|
|
|
|
log.Debug("Found valid plugins", "count", len(validPluginNames), "plugins", validPluginNames)
|
|
}
|
|
|
|
// PluginNames returns the folder names of all plugins that implement the specified capability
|
|
func (m *Manager) PluginNames(capability string) []string {
|
|
m.mu.RLock()
|
|
defer m.mu.RUnlock()
|
|
|
|
var names []string
|
|
for name, plugin := range m.plugins {
|
|
for _, c := range plugin.Manifest.Capabilities {
|
|
if string(c) == capability {
|
|
names = append(names, name)
|
|
break
|
|
}
|
|
}
|
|
}
|
|
return names
|
|
}
|
|
|
|
func (m *Manager) getPlugin(name string, capability string) (*plugin, WasmPlugin) {
|
|
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
|
|
}
|
|
if !adapterOk {
|
|
log.Warn("Plugin adapter not found", "name", name, "capability", capability)
|
|
return nil, nil
|
|
}
|
|
return info, adapter
|
|
}
|
|
|
|
// 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)
|
|
return nil
|
|
}
|
|
|
|
log.Debug("Loading plugin", "name", name, "path", info.Path)
|
|
|
|
// Wait for the plugin to be ready before using it.
|
|
if err := info.waitForCompilation(); err != nil {
|
|
log.Error("Plugin is not ready, cannot be loaded", "plugin", name, "capability", capability, "err", err)
|
|
return nil
|
|
}
|
|
|
|
if adapter == nil {
|
|
log.Warn("Plugin adapter not found", "name", name, "capability", capability)
|
|
return nil
|
|
}
|
|
return adapter
|
|
}
|
|
|
|
// 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 {
|
|
m.mu.RLock()
|
|
plugin, ok := m.plugins[name]
|
|
m.mu.RUnlock()
|
|
|
|
if !ok {
|
|
return fmt.Errorf("plugin not found: %s", name)
|
|
}
|
|
|
|
return plugin.waitForCompilation()
|
|
}
|
|
|
|
// LoadAllPlugins instantiates and returns all plugins that implement the specified capability
|
|
func (m *Manager) LoadAllPlugins(capability string) []WasmPlugin {
|
|
names := m.PluginNames(capability)
|
|
if len(names) == 0 {
|
|
return nil
|
|
}
|
|
|
|
var plugins []WasmPlugin
|
|
for _, name := range names {
|
|
plugin := m.LoadPlugin(name, capability)
|
|
if plugin != nil {
|
|
plugins = append(plugins, plugin)
|
|
}
|
|
}
|
|
return plugins
|
|
}
|
|
|
|
// LoadMediaAgent instantiates and returns a media agent plugin by folder name
|
|
func (m *Manager) LoadMediaAgent(name string) (agents.Interface, bool) {
|
|
plugin := m.LoadPlugin(name, CapabilityMetadataAgent)
|
|
if plugin == nil {
|
|
return nil, false
|
|
}
|
|
agent, ok := plugin.(*wasmMediaAgent)
|
|
return agent, ok
|
|
}
|
|
|
|
// LoadAllMediaAgents instantiates and returns all media agent plugins
|
|
func (m *Manager) LoadAllMediaAgents() []agents.Interface {
|
|
plugins := m.LoadAllPlugins(CapabilityMetadataAgent)
|
|
|
|
return slice.Map(plugins, func(p WasmPlugin) agents.Interface {
|
|
return p.(agents.Interface)
|
|
})
|
|
}
|
|
|
|
// LoadScrobbler instantiates and returns a scrobbler plugin by folder name
|
|
func (m *Manager) LoadScrobbler(name string) (scrobbler.Scrobbler, bool) {
|
|
plugin := m.LoadPlugin(name, CapabilityScrobbler)
|
|
if plugin == nil {
|
|
return nil, false
|
|
}
|
|
s, ok := plugin.(scrobbler.Scrobbler)
|
|
return s, ok
|
|
}
|
|
|
|
// LoadAllScrobblers instantiates and returns all scrobbler plugins
|
|
func (m *Manager) LoadAllScrobblers() []scrobbler.Scrobbler {
|
|
plugins := m.LoadAllPlugins(CapabilityScrobbler)
|
|
|
|
return slice.Map(plugins, func(p WasmPlugin) scrobbler.Scrobbler {
|
|
return p.(scrobbler.Scrobbler)
|
|
})
|
|
}
|