diff --git a/cmd/wire_gen.go b/cmd/wire_gen.go index ee5fd025e..187ab488d 100644 --- a/cmd/wire_gen.go +++ b/cmd/wire_gen.go @@ -47,7 +47,9 @@ func CreateServer() *server.Server { sqlDB := db.Db() dataStore := persistence.New(sqlDB) broker := events.GetBroker() - insights := metrics.GetInstance(dataStore) + metricsMetrics := metrics.GetPrometheusInstance(dataStore) + manager := plugins.GetManager(dataStore, metricsMetrics) + insights := metrics.GetInstance(dataStore, manager) serverServer := server.New(dataStore, broker, insights) return serverServer } @@ -57,11 +59,11 @@ func CreateNativeAPIRouter(ctx context.Context) *nativeapi.Router { dataStore := persistence.New(sqlDB) share := core.NewShare(dataStore) playlists := core.NewPlaylists(dataStore) - insights := metrics.GetInstance(dataStore) - fileCache := artwork.GetImageCache() - fFmpeg := ffmpeg.New() metricsMetrics := metrics.GetPrometheusInstance(dataStore) manager := plugins.GetManager(dataStore, metricsMetrics) + insights := metrics.GetInstance(dataStore, manager) + fileCache := artwork.GetImageCache() + fFmpeg := ffmpeg.New() agentsAgents := agents.GetAgents(dataStore, manager) provider := external.NewProvider(dataStore, agentsAgents) artworkArtwork := artwork.NewArtwork(dataStore, fileCache, fFmpeg, provider) @@ -134,7 +136,9 @@ func CreateListenBrainzRouter() *listenbrainz.Router { func CreateInsights() metrics.Insights { sqlDB := db.Db() dataStore := persistence.New(sqlDB) - insights := metrics.GetInstance(dataStore) + metricsMetrics := metrics.GetPrometheusInstance(dataStore) + manager := plugins.GetManager(dataStore, metricsMetrics) + insights := metrics.GetInstance(dataStore, manager) return insights } @@ -197,7 +201,7 @@ 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.GetWatcher, 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(core.Scanner), new(scanner.Scanner)), wire.Bind(new(core.Watcher), new(scanner.Watcher))) +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.GetWatcher, 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(metrics.PluginLoader), new(plugins.Manager)), wire.Bind(new(core.Scanner), new(scanner.Scanner)), wire.Bind(new(core.Watcher), new(scanner.Watcher))) func GetPluginManager(ctx context.Context) plugins.Manager { manager := getPluginManager() diff --git a/cmd/wire_injectors.go b/cmd/wire_injectors.go index ec469b8be..e8759ac53 100644 --- a/cmd/wire_injectors.go +++ b/cmd/wire_injectors.go @@ -44,6 +44,7 @@ var allProviders = wire.NewSet( db.Db, wire.Bind(new(agents.PluginLoader), new(plugins.Manager)), wire.Bind(new(scrobbler.PluginLoader), new(plugins.Manager)), + wire.Bind(new(metrics.PluginLoader), new(plugins.Manager)), wire.Bind(new(core.Scanner), new(scanner.Scanner)), wire.Bind(new(core.Watcher), new(scanner.Watcher)), ) diff --git a/conf/configuration.go b/conf/configuration.go index bb1ae120b..7292c7dfe 100644 --- a/conf/configuration.go +++ b/conf/configuration.go @@ -127,6 +127,7 @@ type configOptions struct { DevScannerThreads uint DevInsightsInitialDelay time.Duration DevEnablePlayerInsights bool + DevEnablePluginsInsights bool DevPluginCompilationTimeout time.Duration DevExternalArtistFetchMultiplier float64 } @@ -601,6 +602,7 @@ func setViperDefaults() { viper.SetDefault("devscannerthreads", 5) viper.SetDefault("devinsightsinitialdelay", consts.InsightsInitialDelay) viper.SetDefault("devenableplayerinsights", true) + viper.SetDefault("devenablepluginsinsights", true) viper.SetDefault("devplugincompilationtimeout", time.Minute) viper.SetDefault("devexternalartistfetchmultiplier", 1.5) } diff --git a/core/agents/agents.go b/core/agents/agents.go index 225411ecd..4ec324b71 100644 --- a/core/agents/agents.go +++ b/core/agents/agents.go @@ -17,7 +17,7 @@ import ( // PluginLoader defines an interface for loading plugins type PluginLoader interface { // PluginNames returns the names of all plugins that implement a particular service - PluginNames(serviceName string) []string + PluginNames(capability string) []string // LoadMediaAgent loads and returns a media agent plugin LoadMediaAgent(name string) (Interface, bool) } diff --git a/core/metrics/insights.go b/core/metrics/insights.go index 29284a908..f4f8738e7 100644 --- a/core/metrics/insights.go +++ b/core/metrics/insights.go @@ -21,6 +21,7 @@ import ( "github.com/navidrome/navidrome/core/metrics/insights" "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/plugins/schema" "github.com/navidrome/navidrome/utils/singleton" ) @@ -34,12 +35,18 @@ var ( ) type insightsCollector struct { - ds model.DataStore - lastRun atomic.Int64 - lastStatus atomic.Bool + ds model.DataStore + pluginLoader PluginLoader + lastRun atomic.Int64 + lastStatus atomic.Bool } -func GetInstance(ds model.DataStore) Insights { +// PluginLoader defines an interface for loading plugins +type PluginLoader interface { + PluginList() map[string]schema.PluginManifest +} + +func GetInstance(ds model.DataStore, pluginLoader PluginLoader) Insights { return singleton.GetInstance(func() *insightsCollector { id, err := ds.Property(context.TODO()).Get(consts.InsightsIDKey) if err != nil { @@ -51,7 +58,7 @@ func GetInstance(ds model.DataStore) Insights { } } insightsID = id - return &insightsCollector{ds: ds} + return &insightsCollector{ds: ds, pluginLoader: pluginLoader} }) } @@ -180,10 +187,11 @@ var staticData = sync.OnceValue(func() insights.Data { data.Config.EnableDownloads = conf.Server.EnableDownloads data.Config.EnableSharing = conf.Server.EnableSharing data.Config.EnableStarRating = conf.Server.EnableStarRating - data.Config.EnableLastFM = conf.Server.LastFM.Enabled + data.Config.EnableLastFM = conf.Server.LastFM.Enabled && conf.Server.LastFM.ApiKey != "" && conf.Server.LastFM.Secret != "" + data.Config.EnableSpotify = conf.Server.Spotify.ID != "" && conf.Server.Spotify.Secret != "" data.Config.EnableListenBrainz = conf.Server.ListenBrainz.Enabled + data.Config.EnableDeezer = conf.Server.Deezer.Enabled data.Config.EnableMediaFileCoverArt = conf.Server.EnableMediaFileCoverArt - data.Config.EnableSpotify = conf.Server.Spotify.ID != "" data.Config.EnableJukebox = conf.Server.Jukebox.Enabled data.Config.EnablePrometheus = conf.Server.Prometheus.Enabled data.Config.TranscodingCacheSize = conf.Server.TranscodingCacheSize @@ -199,6 +207,9 @@ var staticData = sync.OnceValue(func() insights.Data { data.Config.ScanSchedule = conf.Server.Scanner.Schedule data.Config.ScanWatcherWait = uint64(math.Trunc(conf.Server.Scanner.WatcherWait.Seconds())) data.Config.ScanOnStartup = conf.Server.Scanner.ScanOnStartup + data.Config.ReverseProxyConfigured = conf.Server.ReverseProxyWhitelist != "" + data.Config.HasCustomPID = conf.Server.PID.Track != "" || conf.Server.PID.Album != "" + data.Config.HasCustomTags = len(conf.Server.Tags) > 0 return data }) @@ -233,12 +244,29 @@ func (c *insightsCollector) collect(ctx context.Context) []byte { if err != nil { log.Trace(ctx, "Error reading radios count", err) } + data.Library.Libraries, err = c.ds.Library(ctx).CountAll() + if err != nil { + log.Trace(ctx, "Error reading libraries count", err) + } data.Library.ActiveUsers, err = c.ds.User(ctx).CountAll(model.QueryOptions{ Filters: squirrel.Gt{"last_access_at": time.Now().Add(-7 * 24 * time.Hour)}, }) if err != nil { log.Trace(ctx, "Error reading active users count", err) } + + // Check for smart playlists + data.Config.HasSmartPlaylists, err = c.hasSmartPlaylists(ctx) + if err != nil { + log.Trace(ctx, "Error checking for smart playlists", err) + } + + // Collect plugins if permitted and enabled + if conf.Server.DevEnablePluginsInsights && conf.Server.Plugins.Enabled { + data.Plugins = c.collectPlugins(ctx) + } + + // Collect active players if permitted if conf.Server.DevEnablePlayerInsights { data.Library.ActivePlayers, err = c.ds.Player(ctx).CountByClient(model.QueryOptions{ Filters: squirrel.Gt{"last_seen": time.Now().Add(-7 * 24 * time.Hour)}, @@ -264,3 +292,23 @@ func (c *insightsCollector) collect(ctx context.Context) []byte { } return resp } + +// hasSmartPlaylists checks if there are any smart playlists (playlists with rules) +func (c *insightsCollector) hasSmartPlaylists(ctx context.Context) (bool, error) { + count, err := c.ds.Playlist(ctx).CountAll(model.QueryOptions{ + Filters: squirrel.And{squirrel.NotEq{"rules": ""}, squirrel.NotEq{"rules": nil}}, + }) + return count > 0, err +} + +// collectPlugins collects information about installed plugins +func (c *insightsCollector) collectPlugins(_ context.Context) map[string]insights.PluginInfo { + plugins := make(map[string]insights.PluginInfo) + for id, manifest := range c.pluginLoader.PluginList() { + plugins[id] = insights.PluginInfo{ + Name: manifest.Name, + Version: manifest.Version, + } + } + return plugins +} diff --git a/core/metrics/insights/data.go b/core/metrics/insights/data.go index 85c1ad18b..105a6218e 100644 --- a/core/metrics/insights/data.go +++ b/core/metrics/insights/data.go @@ -36,6 +36,7 @@ type Data struct { Playlists int64 `json:"playlists"` Shares int64 `json:"shares"` Radios int64 `json:"radios"` + Libraries int64 `json:"libraries"` ActiveUsers int64 `json:"activeUsers"` ActivePlayers map[string]int64 `json:"activePlayers,omitempty"` } `json:"library"` @@ -55,6 +56,7 @@ type Data struct { EnableStarRating bool `json:"enableStarRating,omitempty"` EnableLastFM bool `json:"enableLastFM,omitempty"` EnableListenBrainz bool `json:"enableListenBrainz,omitempty"` + EnableDeezer bool `json:"enableDeezer,omitempty"` EnableMediaFileCoverArt bool `json:"enableMediaFileCoverArt,omitempty"` EnableSpotify bool `json:"enableSpotify,omitempty"` EnableJukebox bool `json:"enableJukebox,omitempty"` @@ -69,7 +71,17 @@ type Data struct { BackupCount int `json:"backupCount,omitempty"` DevActivityPanel bool `json:"devActivityPanel,omitempty"` DefaultBackgroundURLSet bool `json:"defaultBackgroundURL,omitempty"` + HasSmartPlaylists bool `json:"hasSmartPlaylists,omitempty"` + ReverseProxyConfigured bool `json:"reverseProxyConfigured,omitempty"` + HasCustomPID bool `json:"hasCustomPID,omitempty"` + HasCustomTags bool `json:"hasCustomTags,omitempty"` } `json:"config"` + Plugins map[string]PluginInfo `json:"plugins,omitempty"` +} + +type PluginInfo struct { + Name string `json:"name"` + Version string `json:"version"` } type FSInfo struct { diff --git a/core/scrobbler/play_tracker.go b/core/scrobbler/play_tracker.go index 6c017c0bc..3b71a2100 100644 --- a/core/scrobbler/play_tracker.go +++ b/core/scrobbler/play_tracker.go @@ -40,7 +40,7 @@ type PlayTracker interface { // PluginLoader is a minimal interface for plugin manager usage in PlayTracker // (avoids import cycles) type PluginLoader interface { - PluginNames(service string) []string + PluginNames(capability string) []string LoadScrobbler(name string) (Scrobbler, bool) } diff --git a/plugins/manager.go b/plugins/manager.go index 7c735c740..35a1130fd 100644 --- a/plugins/manager.go +++ b/plugins/manager.go @@ -87,7 +87,8 @@ type SubsonicRouter http.Handler type Manager interface { SetSubsonicRouter(router SubsonicRouter) EnsureCompiled(name string) error - PluginNames(serviceName string) []string + PluginList() map[string]schema.PluginManifest + PluginNames(capability string) []string LoadPlugin(name string, capability string) WasmPlugin LoadMediaAgent(name string) (agents.Interface, bool) LoadScrobbler(name string) (scrobbler.Scrobbler, bool) @@ -97,7 +98,7 @@ type Manager interface { // 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 + pluginsMu 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 @@ -166,7 +167,7 @@ func (m *managerImpl) registerPlugin(pluginID, pluginDir, wasmPath string, manif } // Register the plugin first - m.mu.Lock() + m.pluginsMu.Lock() m.plugins[pluginID] = p // Register one plugin adapter for each capability @@ -187,7 +188,7 @@ func (m *managerImpl) registerPlugin(pluginID, pluginDir, wasmPath string, manif } m.adapters[pluginID+"_"+capabilityStr] = adapter } - m.mu.Unlock() + m.pluginsMu.Unlock() log.Info("Discovered plugin", "folder", pluginID, "name", manifest.Name, "capabilities", manifest.Capabilities, "wasm", wasmPath, "dev_mode", isSymlink) return m.plugins[pluginID] @@ -210,8 +211,8 @@ func (m *managerImpl) initializePluginIfNeeded(plugin *plugin) { // unregisterPlugin removes a plugin from the manager func (m *managerImpl) unregisterPlugin(pluginID string) { - m.mu.Lock() - defer m.mu.Unlock() + m.pluginsMu.Lock() + defer m.pluginsMu.Unlock() plugin, ok := m.plugins[pluginID] if !ok { @@ -234,10 +235,10 @@ func (m *managerImpl) unregisterPlugin(pluginID string) { // ScanPlugins scans the plugins directory, discovers all valid plugins, and registers them for use. func (m *managerImpl) ScanPlugins() { // Clear existing plugins - m.mu.Lock() + m.pluginsMu.Lock() m.plugins = make(map[string]*plugin) m.adapters = make(map[string]WasmPlugin) - m.mu.Unlock() + m.pluginsMu.Unlock() // Get plugins directory from config root := conf.Server.Plugins.Folder @@ -297,10 +298,24 @@ func (m *managerImpl) ScanPlugins() { log.Debug("Found valid plugins", "count", len(validPluginNames), "plugins", validPluginNames) } +// PluginList returns a map of all registered plugins with their manifests +func (m *managerImpl) PluginList() map[string]schema.PluginManifest { + m.pluginsMu.RLock() + defer m.pluginsMu.RUnlock() + + // Create a map to hold the plugin manifests + pluginList := make(map[string]schema.PluginManifest, len(m.plugins)) + for name, plugin := range m.plugins { + // Use the plugin ID as the key and the manifest as the value + pluginList[name] = *plugin.Manifest + } + return pluginList +} + // PluginNames returns the folder names of all plugins that implement the specified capability func (m *managerImpl) PluginNames(capability string) []string { - m.mu.RLock() - defer m.mu.RUnlock() + m.pluginsMu.RLock() + defer m.pluginsMu.RUnlock() var names []string for name, plugin := range m.plugins { @@ -315,8 +330,8 @@ func (m *managerImpl) PluginNames(capability string) []string { } func (m *managerImpl) getPlugin(name string, capability string) (*plugin, WasmPlugin, error) { - m.mu.RLock() - defer m.mu.RUnlock() + m.pluginsMu.RLock() + defer m.pluginsMu.RUnlock() info, infoOk := m.plugins[name] adapter, adapterOk := m.adapters[name+"_"+capability] @@ -356,9 +371,9 @@ func (m *managerImpl) LoadPlugin(name string, capability string) WasmPlugin { // 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 *managerImpl) EnsureCompiled(name string) error { - m.mu.RLock() + m.pluginsMu.RLock() plugin, ok := m.plugins[name] - m.mu.RUnlock() + m.pluginsMu.RUnlock() if !ok { return fmt.Errorf("plugin not found: %s", name) @@ -393,7 +408,9 @@ 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) PluginList() map[string]schema.PluginManifest { return nil } + +func (n noopManager) PluginNames(capability string) []string { return nil } func (n noopManager) LoadPlugin(name string, capability string) WasmPlugin { return nil } diff --git a/plugins/manager_test.go b/plugins/manager_test.go index 2a6ad575f..207908ebc 100644 --- a/plugins/manager_test.go +++ b/plugins/manager_test.go @@ -65,6 +65,13 @@ var _ = Describe("Plugin Manager", func() { Expect(schedulerCallbackNames).To(ContainElement("multi_plugin")) }) + It("should load all plugins from folder", func() { + all := mgr.PluginList() + Expect(all).To(HaveLen(6)) + Expect(all["fake_artist_agent"].Name).To(Equal("fake_artist_agent")) + Expect(all["unauthorized_plugin"].Capabilities).To(HaveExactElements(schema.PluginManifestCapabilitiesElem("MetadataAgent"))) + }) + It("should load a MetadataAgent plugin and invoke artist-related methods", func() { plugin := mgr.LoadPlugin("fake_artist_agent", CapabilityMetadataAgent) Expect(plugin).NotTo(BeNil()) @@ -332,9 +339,9 @@ var _ = Describe("Plugin Manager", func() { } // Register the plugin in the manager - mgr.mu.Lock() + mgr.pluginsMu.Lock() mgr.plugins[plugin.ID] = plugin - mgr.mu.Unlock() + mgr.pluginsMu.Unlock() // Mark the plugin as initialized in the lifecycle manager mgr.lifecycle.markInitialized(plugin) @@ -344,9 +351,9 @@ var _ = Describe("Plugin Manager", func() { mgr.unregisterPlugin(plugin.ID) // Verify that the plugin is no longer in the manager - mgr.mu.RLock() + mgr.pluginsMu.RLock() _, exists := mgr.plugins[plugin.ID] - mgr.mu.RUnlock() + mgr.pluginsMu.RUnlock() Expect(exists).To(BeFalse()) // Verify that the lifecycle state has been cleared