From d5e88c1117d8061b6d1ed7f99484265b2f44a104 Mon Sep 17 00:00:00 2001 From: Deluan Date: Mon, 29 Dec 2025 11:09:23 -0500 Subject: [PATCH] feat(plugins): integrate event broker into plugin manager Signed-off-by: Deluan --- cmd/wire_gen.go | 22 ++++++----- core/metrics/insights.go | 4 +- plugins/manager.go | 18 ++++++++- plugins/manager_sync.go | 13 +++++-- plugins/manager_test.go | 72 ++++++++++++++++++++++++++++++++++++ ui/src/plugin/PluginList.jsx | 3 +- ui/src/plugin/PluginShow.jsx | 3 +- 7 files changed, 117 insertions(+), 18 deletions(-) diff --git a/cmd/wire_gen.go b/cmd/wire_gen.go index d9450738f..7740cb763 100644 --- a/cmd/wire_gen.go +++ b/cmd/wire_gen.go @@ -60,12 +60,12 @@ func CreateNativeAPIRouter(ctx context.Context) *nativeapi.Router { insights := metrics.GetInstance(dataStore) fileCache := artwork.GetImageCache() fFmpeg := ffmpeg.New() - manager := plugins.GetManager(dataStore) + broker := events.GetBroker() + manager := plugins.GetManager(dataStore, broker) agentsAgents := agents.GetAgents(dataStore, manager) provider := external.NewProvider(dataStore, agentsAgents) artworkArtwork := artwork.NewArtwork(dataStore, fileCache, fFmpeg, provider) cacheWarmer := artwork.NewCacheWarmer(artworkArtwork, fileCache) - broker := events.GetBroker() metricsMetrics := metrics.GetPrometheusInstance(dataStore) modelScanner := scanner.New(ctx, dataStore, cacheWarmer, broker, playlists, metricsMetrics) watcher := scanner.GetWatcher(dataStore, modelScanner) @@ -80,7 +80,8 @@ func CreateSubsonicAPIRouter(ctx context.Context) *subsonic.Router { dataStore := persistence.New(sqlDB) fileCache := artwork.GetImageCache() fFmpeg := ffmpeg.New() - manager := plugins.GetManager(dataStore) + broker := events.GetBroker() + manager := plugins.GetManager(dataStore, broker) agentsAgents := agents.GetAgents(dataStore, manager) provider := external.NewProvider(dataStore, agentsAgents) artworkArtwork := artwork.NewArtwork(dataStore, fileCache, fFmpeg, provider) @@ -90,7 +91,6 @@ func CreateSubsonicAPIRouter(ctx context.Context) *subsonic.Router { archiver := core.NewArchiver(mediaStreamer, dataStore, share) players := core.NewPlayers(dataStore) cacheWarmer := artwork.NewCacheWarmer(artworkArtwork, fileCache) - broker := events.GetBroker() playlists := core.NewPlaylists(dataStore) metricsMetrics := metrics.GetPrometheusInstance(dataStore) modelScanner := scanner.New(ctx, dataStore, cacheWarmer, broker, playlists, metricsMetrics) @@ -105,7 +105,8 @@ func CreatePublicRouter() *public.Router { dataStore := persistence.New(sqlDB) fileCache := artwork.GetImageCache() fFmpeg := ffmpeg.New() - manager := plugins.GetManager(dataStore) + broker := events.GetBroker() + manager := plugins.GetManager(dataStore, broker) agentsAgents := agents.GetAgents(dataStore, manager) provider := external.NewProvider(dataStore, agentsAgents) artworkArtwork := artwork.NewArtwork(dataStore, fileCache, fFmpeg, provider) @@ -150,12 +151,12 @@ func CreateScanner(ctx context.Context) model.Scanner { dataStore := persistence.New(sqlDB) fileCache := artwork.GetImageCache() fFmpeg := ffmpeg.New() - manager := plugins.GetManager(dataStore) + broker := events.GetBroker() + manager := plugins.GetManager(dataStore, broker) agentsAgents := agents.GetAgents(dataStore, manager) provider := external.NewProvider(dataStore, agentsAgents) artworkArtwork := artwork.NewArtwork(dataStore, fileCache, fFmpeg, provider) cacheWarmer := artwork.NewCacheWarmer(artworkArtwork, fileCache) - broker := events.GetBroker() playlists := core.NewPlaylists(dataStore) metricsMetrics := metrics.GetPrometheusInstance(dataStore) modelScanner := scanner.New(ctx, dataStore, cacheWarmer, broker, playlists, metricsMetrics) @@ -167,12 +168,12 @@ func CreateScanWatcher(ctx context.Context) scanner.Watcher { dataStore := persistence.New(sqlDB) fileCache := artwork.GetImageCache() fFmpeg := ffmpeg.New() - manager := plugins.GetManager(dataStore) + broker := events.GetBroker() + manager := plugins.GetManager(dataStore, broker) agentsAgents := agents.GetAgents(dataStore, manager) provider := external.NewProvider(dataStore, agentsAgents) artworkArtwork := artwork.NewArtwork(dataStore, fileCache, fFmpeg, provider) cacheWarmer := artwork.NewCacheWarmer(artworkArtwork, fileCache) - broker := events.GetBroker() playlists := core.NewPlaylists(dataStore) metricsMetrics := metrics.GetPrometheusInstance(dataStore) modelScanner := scanner.New(ctx, dataStore, cacheWarmer, broker, playlists, metricsMetrics) @@ -190,7 +191,8 @@ func GetPlaybackServer() playback.PlaybackServer { func getPluginManager() *plugins.Manager { sqlDB := db.Db() dataStore := persistence.New(sqlDB) - manager := plugins.GetManager(dataStore) + broker := events.GetBroker() + manager := plugins.GetManager(dataStore, broker) return manager } diff --git a/core/metrics/insights.go b/core/metrics/insights.go index a7e06f51c..ef24b6e0f 100644 --- a/core/metrics/insights.go +++ b/core/metrics/insights.go @@ -24,6 +24,7 @@ import ( "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/model/request" "github.com/navidrome/navidrome/plugins" + "github.com/navidrome/navidrome/server/events" "github.com/navidrome/navidrome/utils/singleton" ) @@ -313,7 +314,8 @@ func (c *insightsCollector) hasSmartPlaylists(ctx context.Context) (bool, error) // collectPlugins collects information about installed plugins func (c *insightsCollector) collectPlugins(_ context.Context) map[string]insights.PluginInfo { - manager := plugins.GetManager(c.ds) + // TODO Fix import/inject cycles + manager := plugins.GetManager(c.ds, events.GetBroker()) info := manager.GetPluginInfo() result := make(map[string]insights.PluginInfo, len(info)) diff --git a/plugins/manager.go b/plugins/manager.go index 3340a1c3d..1ca0eccfd 100644 --- a/plugins/manager.go +++ b/plugins/manager.go @@ -17,6 +17,7 @@ import ( "github.com/navidrome/navidrome/core/scrobbler" "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/server/events" "github.com/navidrome/navidrome/utils/singleton" "github.com/rjeczalik/notify" "github.com/tetratelabs/wazero" @@ -54,19 +55,31 @@ type Manager struct { // SubsonicAPI host function dependencies (set once before Start, not modified after) subsonicRouter SubsonicRouter ds model.DataStore + broker events.Broker } // GetManager returns a singleton instance of the plugin manager. // The manager is not started automatically; call Start() to begin loading plugins. -func GetManager(ds model.DataStore) *Manager { +func GetManager(ds model.DataStore, broker events.Broker) *Manager { return singleton.GetInstance(func() *Manager { return &Manager{ ds: ds, + broker: broker, plugins: make(map[string]*plugin), } }) } +// sendPluginRefreshEvent broadcasts a refresh event for the plugin resource. +// This notifies connected UI clients that plugin data has changed. +func (m *Manager) sendPluginRefreshEvent(ctx context.Context, pluginIDs ...string) { + if m.broker == nil { + return + } + event := (&events.RefreshResource{}).With("plugin", pluginIDs...) + m.broker.SendBroadcastMessage(ctx, event) +} + // SetSubsonicRouter sets the Subsonic router for SubsonicAPI host functions. // This should be called after the subsonic router is created but before plugins // that require SubsonicAPI access are loaded. @@ -304,6 +317,7 @@ func (m *Manager) EnablePlugin(ctx context.Context, id string) error { } log.Info(ctx, "Enabled plugin", "plugin", id) + m.sendPluginRefreshEvent(ctx, id) return nil } @@ -339,6 +353,7 @@ func (m *Manager) DisablePlugin(ctx context.Context, id string) error { } log.Info(ctx, "Disabled plugin", "plugin", id) + m.sendPluginRefreshEvent(ctx, id) return nil } @@ -380,6 +395,7 @@ func (m *Manager) UpdatePluginConfig(ctx context.Context, id, configJSON string) } log.Info(ctx, "Updated plugin config", "plugin", id) + m.sendPluginRefreshEvent(ctx, id) return nil } diff --git a/plugins/manager_sync.go b/plugins/manager_sync.go index aefa9f3b1..2e024ca37 100644 --- a/plugins/manager_sync.go +++ b/plugins/manager_sync.go @@ -15,6 +15,7 @@ import ( "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/model/request" + "github.com/navidrome/navidrome/server/events" ) // PluginMetadata holds the extracted information from a plugin file @@ -67,6 +68,7 @@ func (m *Manager) addPluginToDB(ctx context.Context, repo model.PluginRepository return fmt.Errorf("adding plugin to DB: %w", err) } log.Info(ctx, "Discovered new plugin", "plugin", name) + m.sendPluginRefreshEvent(ctx, events.Any) return nil } @@ -89,21 +91,24 @@ func (m *Manager) updatePluginInDB(ctx context.Context, repo model.PluginReposit return fmt.Errorf("updating plugin in DB: %w", err) } log.Info(ctx, "Plugin file changed", "plugin", dbPlugin.ID, "wasEnabled", wasEnabled) + m.sendPluginRefreshEvent(ctx, dbPlugin.ID) return nil } // removePluginFromDB removes a plugin from the database. // If the plugin was enabled, it will be unloaded first. func (m *Manager) removePluginFromDB(ctx context.Context, repo model.PluginRepository, dbPlugin *model.Plugin) error { + pluginID := dbPlugin.ID if dbPlugin.Enabled { - if err := m.unloadPlugin(dbPlugin.ID); err != nil { - log.Debug(ctx, "Plugin not loaded during removal", "plugin", dbPlugin.ID, err) + if err := m.unloadPlugin(pluginID); err != nil { + log.Debug(ctx, "Plugin not loaded during removal", "plugin", pluginID, err) } } - if err := repo.Delete(dbPlugin.ID); err != nil { + if err := repo.Delete(pluginID); err != nil { return fmt.Errorf("deleting plugin from DB: %w", err) } - log.Info(ctx, "Plugin removed", "plugin", dbPlugin.ID) + log.Info(ctx, "Plugin removed", "plugin", pluginID) + m.sendPluginRefreshEvent(ctx, events.Any) return nil } diff --git a/plugins/manager_test.go b/plugins/manager_test.go index 1ae10e1e7..6cf90994a 100644 --- a/plugins/manager_test.go +++ b/plugins/manager_test.go @@ -3,9 +3,11 @@ package plugins import ( "context" "fmt" + "net/http" "sync" "github.com/navidrome/navidrome/core/agents" + "github.com/navidrome/navidrome/server/events" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" ) @@ -121,4 +123,74 @@ var _ = Describe("Manager", Ordered, func() { } } }) + + Describe("sendPluginRefreshEvent", func() { + var broker *testBroker + var manager *Manager + + BeforeEach(func() { + broker = &testBroker{} + manager = &Manager{ + broker: broker, + } + }) + + It("sends refresh event with single plugin ID", func() { + manager.sendPluginRefreshEvent(ctx, "test-plugin") + + Expect(broker.broadcastCalled).To(BeTrue()) + Expect(broker.lastEvent).ToNot(BeNil()) + Expect(broker.lastEventCtx).To(Equal(ctx)) + + refreshEvent, ok := broker.lastEvent.(*events.RefreshResource) + Expect(ok).To(BeTrue(), "event should be a RefreshResource") + Expect(refreshEvent.Data(refreshEvent)).To(Equal(`{"plugin":["test-plugin"]}`)) + }) + + It("sends refresh event with multiple plugin IDs", func() { + manager.sendPluginRefreshEvent(ctx, "plugin-1", "plugin-2", "plugin-3") + + Expect(broker.broadcastCalled).To(BeTrue()) + refreshEvent, ok := broker.lastEvent.(*events.RefreshResource) + Expect(ok).To(BeTrue()) + Expect(refreshEvent.Data(refreshEvent)).To(Equal(`{"plugin":["plugin-1","plugin-2","plugin-3"]}`)) + }) + + It("sends refresh event with wildcard when using events.Any", func() { + manager.sendPluginRefreshEvent(ctx, events.Any) + + Expect(broker.broadcastCalled).To(BeTrue()) + refreshEvent, ok := broker.lastEvent.(*events.RefreshResource) + Expect(ok).To(BeTrue()) + Expect(refreshEvent.Data(refreshEvent)).To(Equal(`{"plugin":["*"]}`)) + }) + + It("does not panic when broker is nil", func() { + manager.broker = nil + Expect(func() { + manager.sendPluginRefreshEvent(ctx, "test-plugin") + }).ToNot(Panic()) + }) + }) }) + +// testBroker is a simple mock implementation of events.Broker for testing +type testBroker struct { + lastEvent events.Event + lastEventCtx context.Context + broadcastCalled bool +} + +func (m *testBroker) ServeHTTP(w http.ResponseWriter, r *http.Request) { + // Not used in tests +} + +func (m *testBroker) SendMessage(ctx context.Context, event events.Event) { + // Not used in tests +} + +func (m *testBroker) SendBroadcastMessage(ctx context.Context, event events.Event) { + m.lastEvent = event + m.lastEventCtx = ctx + m.broadcastCalled = true +} diff --git a/ui/src/plugin/PluginList.jsx b/ui/src/plugin/PluginList.jsx index 5c71b6c04..20a05230f 100644 --- a/ui/src/plugin/PluginList.jsx +++ b/ui/src/plugin/PluginList.jsx @@ -8,7 +8,7 @@ import { import { makeStyles } from '@material-ui/core/styles' import { useMediaQuery, Tooltip, Chip, Typography } from '@material-ui/core' import { MdError } from 'react-icons/md' -import { List, DateField, SimpleList } from '../common' +import { List, DateField, SimpleList, useResourceRefresh } from '../common' import ToggleEnabledSwitch from './ToggleEnabledSwitch' const useStyles = makeStyles((theme) => ({ @@ -69,6 +69,7 @@ const ManifestField = ({ source }) => { const PluginList = (props) => { const isXsmall = useMediaQuery((theme) => theme.breakpoints.down('xs')) const translate = useTranslate() + useResourceRefresh('plugin') return ( diff --git a/ui/src/plugin/PluginShow.jsx b/ui/src/plugin/PluginShow.jsx index 9f90df309..93e93baf1 100644 --- a/ui/src/plugin/PluginShow.jsx +++ b/ui/src/plugin/PluginShow.jsx @@ -12,7 +12,7 @@ import { } from 'react-admin' import { Box, useMediaQuery } from '@material-ui/core' import Alert from '@material-ui/lab/Alert' -import { Title } from '../common' +import { Title, useResourceRefresh } from '../common' import { usePluginShowStyles } from './styles.js' import { ErrorSection } from './ErrorSection' import { StatusCard } from './StatusCard' @@ -28,6 +28,7 @@ const PluginShowLayout = () => { const notify = useNotify() const refresh = useRefresh() const isSmall = useMediaQuery((theme) => theme.breakpoints.down('xs')) + useResourceRefresh('plugin') const [configPairs, setConfigPairs] = useState([]) const [isDirty, setIsDirty] = useState(false)