feat(plugins): integrate event broker into plugin manager

Signed-off-by: Deluan <deluan@navidrome.org>
This commit is contained in:
Deluan 2025-12-29 11:09:23 -05:00
parent 628e0b58b7
commit cef1d339a3
7 changed files with 117 additions and 18 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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 (
<List {...props} sort={{ field: 'id', order: 'ASC' }} exporter={false}>

View File

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