feat: enhance plugin manager to support metrics recording

Signed-off-by: Deluan <deluan@navidrome.org>
This commit is contained in:
Deluan 2026-01-02 00:23:58 -05:00
parent b90ecb9754
commit 7b1126201e
14 changed files with 318 additions and 13 deletions

View File

@ -61,12 +61,12 @@ func CreateNativeAPIRouter(ctx context.Context) *nativeapi.Router {
fileCache := artwork.GetImageCache()
fFmpeg := ffmpeg.New()
broker := events.GetBroker()
manager := plugins.GetManager(dataStore, broker)
metricsMetrics := metrics.GetPrometheusInstance(dataStore)
manager := plugins.GetManager(dataStore, broker, metricsMetrics)
agentsAgents := agents.GetAgents(dataStore, manager)
provider := external.NewProvider(dataStore, agentsAgents)
artworkArtwork := artwork.NewArtwork(dataStore, fileCache, fFmpeg, provider)
cacheWarmer := artwork.NewCacheWarmer(artworkArtwork, fileCache)
metricsMetrics := metrics.GetPrometheusInstance(dataStore)
modelScanner := scanner.New(ctx, dataStore, cacheWarmer, broker, playlists, metricsMetrics)
watcher := scanner.GetWatcher(dataStore, modelScanner)
library := core.NewLibrary(dataStore, modelScanner, watcher, broker)
@ -81,7 +81,8 @@ func CreateSubsonicAPIRouter(ctx context.Context) *subsonic.Router {
fileCache := artwork.GetImageCache()
fFmpeg := ffmpeg.New()
broker := events.GetBroker()
manager := plugins.GetManager(dataStore, broker)
metricsMetrics := metrics.GetPrometheusInstance(dataStore)
manager := plugins.GetManager(dataStore, broker, metricsMetrics)
agentsAgents := agents.GetAgents(dataStore, manager)
provider := external.NewProvider(dataStore, agentsAgents)
artworkArtwork := artwork.NewArtwork(dataStore, fileCache, fFmpeg, provider)
@ -92,7 +93,6 @@ func CreateSubsonicAPIRouter(ctx context.Context) *subsonic.Router {
players := core.NewPlayers(dataStore)
cacheWarmer := artwork.NewCacheWarmer(artworkArtwork, fileCache)
playlists := core.NewPlaylists(dataStore)
metricsMetrics := metrics.GetPrometheusInstance(dataStore)
modelScanner := scanner.New(ctx, dataStore, cacheWarmer, broker, playlists, metricsMetrics)
playTracker := scrobbler.GetPlayTracker(dataStore, broker, manager)
playbackServer := playback.GetInstance(dataStore)
@ -106,7 +106,8 @@ func CreatePublicRouter() *public.Router {
fileCache := artwork.GetImageCache()
fFmpeg := ffmpeg.New()
broker := events.GetBroker()
manager := plugins.GetManager(dataStore, broker)
metricsMetrics := metrics.GetPrometheusInstance(dataStore)
manager := plugins.GetManager(dataStore, broker, metricsMetrics)
agentsAgents := agents.GetAgents(dataStore, manager)
provider := external.NewProvider(dataStore, agentsAgents)
artworkArtwork := artwork.NewArtwork(dataStore, fileCache, fFmpeg, provider)
@ -152,13 +153,13 @@ func CreateScanner(ctx context.Context) model.Scanner {
fileCache := artwork.GetImageCache()
fFmpeg := ffmpeg.New()
broker := events.GetBroker()
manager := plugins.GetManager(dataStore, broker)
metricsMetrics := metrics.GetPrometheusInstance(dataStore)
manager := plugins.GetManager(dataStore, broker, metricsMetrics)
agentsAgents := agents.GetAgents(dataStore, manager)
provider := external.NewProvider(dataStore, agentsAgents)
artworkArtwork := artwork.NewArtwork(dataStore, fileCache, fFmpeg, provider)
cacheWarmer := artwork.NewCacheWarmer(artworkArtwork, fileCache)
playlists := core.NewPlaylists(dataStore)
metricsMetrics := metrics.GetPrometheusInstance(dataStore)
modelScanner := scanner.New(ctx, dataStore, cacheWarmer, broker, playlists, metricsMetrics)
return modelScanner
}
@ -169,13 +170,13 @@ func CreateScanWatcher(ctx context.Context) scanner.Watcher {
fileCache := artwork.GetImageCache()
fFmpeg := ffmpeg.New()
broker := events.GetBroker()
manager := plugins.GetManager(dataStore, broker)
metricsMetrics := metrics.GetPrometheusInstance(dataStore)
manager := plugins.GetManager(dataStore, broker, metricsMetrics)
agentsAgents := agents.GetAgents(dataStore, manager)
provider := external.NewProvider(dataStore, agentsAgents)
artworkArtwork := artwork.NewArtwork(dataStore, fileCache, fFmpeg, provider)
cacheWarmer := artwork.NewCacheWarmer(artworkArtwork, fileCache)
playlists := core.NewPlaylists(dataStore)
metricsMetrics := metrics.GetPrometheusInstance(dataStore)
modelScanner := scanner.New(ctx, dataStore, cacheWarmer, broker, playlists, metricsMetrics)
watcher := scanner.GetWatcher(dataStore, modelScanner)
return watcher
@ -192,13 +193,14 @@ func getPluginManager() *plugins.Manager {
sqlDB := db.Db()
dataStore := persistence.New(sqlDB)
broker := events.GetBroker()
manager := plugins.GetManager(dataStore, broker)
metricsMetrics := metrics.GetPrometheusInstance(dataStore)
manager := plugins.GetManager(dataStore, broker, metricsMetrics)
return 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, metrics.GetPrometheusInstance, db.Db, plugins.GetManager, wire.Bind(new(agents.PluginLoader), new(*plugins.Manager)), wire.Bind(new(scrobbler.PluginLoader), new(*plugins.Manager)), wire.Bind(new(nativeapi.PluginManager), new(*plugins.Manager)), 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, metrics.GetPrometheusInstance, db.Db, plugins.GetManager, wire.Bind(new(agents.PluginLoader), new(*plugins.Manager)), wire.Bind(new(scrobbler.PluginLoader), new(*plugins.Manager)), wire.Bind(new(nativeapi.PluginManager), new(*plugins.Manager)), wire.Bind(new(plugins.PluginMetricsRecorder), new(metrics.Metrics)), wire.Bind(new(core.Watcher), new(scanner.Watcher)))
func GetPluginManager(ctx context.Context) *plugins.Manager {
manager := getPluginManager()

View File

@ -45,6 +45,7 @@ var allProviders = wire.NewSet(
wire.Bind(new(agents.PluginLoader), new(*plugins.Manager)),
wire.Bind(new(scrobbler.PluginLoader), new(*plugins.Manager)),
wire.Bind(new(nativeapi.PluginManager), new(*plugins.Manager)),
wire.Bind(new(plugins.PluginMetricsRecorder), new(metrics.Metrics)),
wire.Bind(new(core.Watcher), new(scanner.Watcher)),
)

View File

@ -315,7 +315,7 @@ 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 {
// TODO Fix import/inject cycles
manager := plugins.GetManager(c.ds, events.GetBroker())
manager := plugins.GetManager(c.ds, events.GetBroker(), nil)
info := manager.GetPluginInfo()
result := make(map[string]insights.PluginInfo, len(info))

View File

@ -35,6 +35,12 @@ const (
// SubsonicRouter is an http.Handler that serves Subsonic API requests.
type SubsonicRouter = http.Handler
// PluginMetricsRecorder is an interface for recording plugin metrics.
// This is satisfied by core/metrics.Metrics but defined here to avoid import cycles.
type PluginMetricsRecorder interface {
RecordPluginRequest(ctx context.Context, plugin, method string, ok bool, elapsed int64)
}
// Manager manages loading and lifecycle of WebAssembly plugins.
// It implements both agents.PluginLoader and scrobbler.PluginLoader interfaces.
type Manager struct {
@ -56,15 +62,17 @@ type Manager struct {
subsonicRouter SubsonicRouter
ds model.DataStore
broker events.Broker
metrics PluginMetricsRecorder
}
// 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, broker events.Broker) *Manager {
func GetManager(ds model.DataStore, broker events.Broker, m PluginMetricsRecorder) *Manager {
return singleton.GetInstance(func() *Manager {
return &Manager{
ds: ds,
broker: broker,
metrics: m,
plugins: make(map[string]*plugin),
}
})

View File

@ -12,6 +12,12 @@ import (
)
var errFunctionNotFound = errors.New("function not found")
var errNotImplemented = errors.New("function not implemented")
// notImplementedCode is the standard return code from plugin PDKs
// indicating a function exists but is not implemented by this plugin.
// The plugin returns -2 as int32, which becomes 0xFFFFFFFE as uint32.
const notImplementedCode uint32 = 0xFFFFFFFE
// callPluginFunctionNoInput is a helper to call a plugin function with no input and output.
func callPluginFunctionNoInput(ctx context.Context, plugin *plugin, funcName string) error {
@ -54,15 +60,26 @@ func callPluginFunction[I any, O any](ctx context.Context, plugin *plugin, funcN
startCall := time.Now()
exit, output, err := p.CallWithContext(ctx, funcName, inputBytes)
if err != nil {
elapsed := time.Since(startCall).Milliseconds()
// If context was cancelled, return that error instead of the plugin error
if ctx.Err() != nil {
log.Debug(ctx, "Plugin call cancelled", "plugin", plugin.name, "function", funcName, "pluginDuration", time.Since(startCall))
return result, ctx.Err()
}
if plugin.metrics != nil {
plugin.metrics.RecordPluginRequest(ctx, plugin.name, funcName, false, elapsed)
}
log.Trace(ctx, "Plugin call failed", "plugin", plugin.name, "function", funcName, "pluginDuration", time.Since(startCall), "navidromeDuration", startCall.Sub(start), err)
return result, fmt.Errorf("plugin call failed: %w", err)
}
if exit != 0 {
elapsed := time.Since(startCall).Milliseconds()
if plugin.metrics != nil {
plugin.metrics.RecordPluginRequest(ctx, plugin.name, funcName, false, elapsed)
}
if exit == notImplementedCode {
return result, fmt.Errorf("%w: %s", errNotImplemented, funcName)
}
return result, fmt.Errorf("plugin call exited with code %d", exit)
}
@ -73,6 +90,12 @@ func callPluginFunction[I any, O any](ctx context.Context, plugin *plugin, funcN
}
}
// Record metrics for successful calls (or JSON unmarshal failures)
if plugin.metrics != nil {
elapsed := time.Since(startCall).Milliseconds()
plugin.metrics.RecordPluginRequest(ctx, plugin.name, funcName, err == nil, elapsed)
}
log.Trace(ctx, "Plugin call succeeded", "plugin", plugin.name, "function", funcName, "pluginDuration", time.Since(startCall), "navidromeDuration", startCall.Sub(start))
return result, err
}

View File

@ -0,0 +1,131 @@
//go:build !windows
package plugins
import (
"context"
"sync"
"github.com/navidrome/navidrome/core/agents"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
// mockMetricsRecorder tracks calls to RecordPluginRequest for testing
type mockMetricsRecorder struct {
mu sync.Mutex
calls []metricsCall
}
type metricsCall struct {
plugin string
method string
ok bool
elapsed int64
}
func (m *mockMetricsRecorder) RecordPluginRequest(_ context.Context, plugin, method string, ok bool, elapsed int64) {
m.mu.Lock()
defer m.mu.Unlock()
m.calls = append(m.calls, metricsCall{plugin: plugin, method: method, ok: ok, elapsed: elapsed})
}
func (m *mockMetricsRecorder) getCalls() []metricsCall {
m.mu.Lock()
defer m.mu.Unlock()
return append([]metricsCall{}, m.calls...)
}
func (m *mockMetricsRecorder) reset() {
m.mu.Lock()
defer m.mu.Unlock()
m.calls = nil
}
var _ = Describe("callPluginFunction metrics", Ordered, func() {
var (
metricsManager *Manager
metricsRecorder *mockMetricsRecorder
agent agents.Interface
)
BeforeAll(func() {
metricsRecorder = &mockMetricsRecorder{}
// Create a manager with the metrics recorder
metricsManager, _ = createTestManagerWithPluginsAndMetrics(
nil,
metricsRecorder,
"test-metadata-agent"+PackageExtension,
)
var ok bool
agent, ok = metricsManager.LoadMediaAgent("test-metadata-agent")
Expect(ok).To(BeTrue())
})
BeforeEach(func() {
metricsRecorder.reset()
})
It("records metrics for successful plugin calls", func() {
retriever := agent.(agents.ArtistBiographyRetriever)
_, err := retriever.GetArtistBiography(GinkgoT().Context(), "artist-1", "Test Artist", "mbid")
Expect(err).ToNot(HaveOccurred())
calls := metricsRecorder.getCalls()
Expect(calls).To(HaveLen(1))
Expect(calls[0].plugin).To(Equal("test-metadata-agent"))
Expect(calls[0].method).To(Equal(FuncGetArtistBiography))
Expect(calls[0].ok).To(BeTrue())
Expect(calls[0].elapsed).To(BeNumerically(">=", 0))
})
It("records metrics for failed plugin calls (error returned)", func() {
// Create a manager with error config to force plugin errors
errorRecorder := &mockMetricsRecorder{}
errorManager, _ := createTestManagerWithPluginsAndMetrics(
map[string]map[string]string{
"test-metadata-agent": {"error": "simulated error"},
},
errorRecorder,
"test-metadata-agent"+PackageExtension,
)
errorAgent, ok := errorManager.LoadMediaAgent("test-metadata-agent")
Expect(ok).To(BeTrue())
retriever := errorAgent.(agents.ArtistBiographyRetriever)
_, err := retriever.GetArtistBiography(GinkgoT().Context(), "artist-1", "Test Artist", "mbid")
Expect(err).To(HaveOccurred())
calls := errorRecorder.getCalls()
Expect(calls).To(HaveLen(1))
Expect(calls[0].plugin).To(Equal("test-metadata-agent"))
Expect(calls[0].method).To(Equal(FuncGetArtistBiography))
Expect(calls[0].ok).To(BeFalse())
})
It("records metrics for not-implemented functions", func() {
// Use partial metadata agent that doesn't implement GetArtistMBID
partialRecorder := &mockMetricsRecorder{}
partialManager, _ := createTestManagerWithPluginsAndMetrics(
nil,
partialRecorder,
"partial-metadata-agent"+PackageExtension,
)
partialAgent, ok := partialManager.LoadMediaAgent("partial-metadata-agent")
Expect(ok).To(BeTrue())
retriever := partialAgent.(agents.ArtistMBIDRetriever)
_, err := retriever.GetArtistMBID(GinkgoT().Context(), "artist-1", "Test Artist")
Expect(err).To(MatchError(errNotImplemented))
calls := partialRecorder.getCalls()
Expect(calls).To(HaveLen(1))
Expect(calls[0].plugin).To(Equal("partial-metadata-agent"))
Expect(calls[0].method).To(Equal(FuncGetArtistMBID))
Expect(calls[0].ok).To(BeFalse())
})
})

View File

@ -317,6 +317,7 @@ func (m *Manager) loadPluginWithConfig(name, ndpPath, configJSON string) error {
compiled: compiled,
capabilities: capabilities,
closers: closers,
metrics: m.metrics,
}
m.mu.Unlock()

View File

@ -18,6 +18,7 @@ type plugin struct {
compiled *extism.CompiledPlugin
capabilities []Capability // Auto-detected capabilities based on exported functions
closers []io.Closer // Cleanup functions to call on unload
metrics PluginMetricsRecorder
}
// instance creates a new plugin instance for the given context.

View File

@ -187,3 +187,74 @@ var _ = Describe("MetadataAgent error handling", Ordered, func() {
Expect(err.Error()).To(ContainSubstring("simulated plugin error"))
})
})
var _ = Describe("MetadataAgent partial implementation", Ordered, func() {
// Tests the "not implemented" code path when a plugin only implements some methods
var (
partialManager *Manager
partialAgent agents.Interface
)
BeforeAll(func() {
// Create manager with the partial metadata agent plugin
partialManager, _ = createTestManagerWithPlugins(nil, "partial-metadata-agent"+PackageExtension)
// Load the agent
var ok bool
partialAgent, ok = partialManager.LoadMediaAgent("partial-metadata-agent")
Expect(ok).To(BeTrue())
})
It("returns data from implemented method (GetArtistBiography)", func() {
retriever := partialAgent.(agents.ArtistBiographyRetriever)
bio, err := retriever.GetArtistBiography(GinkgoT().Context(), "artist-1", "Test Artist", "mbid")
Expect(err).ToNot(HaveOccurred())
Expect(bio).To(Equal("Partial agent biography for Test Artist"))
})
It("returns ErrNotFound for unimplemented method (GetArtistMBID)", func() {
retriever := partialAgent.(agents.ArtistMBIDRetriever)
_, err := retriever.GetArtistMBID(GinkgoT().Context(), "artist-1", "Test Artist")
Expect(err).To(MatchError(errNotImplemented))
})
It("returns ErrNotFound for unimplemented method (GetArtistURL)", func() {
retriever := partialAgent.(agents.ArtistURLRetriever)
_, err := retriever.GetArtistURL(GinkgoT().Context(), "artist-1", "Test Artist", "mbid")
Expect(err).To(MatchError(errNotImplemented))
})
It("returns ErrNotFound for unimplemented method (GetArtistImages)", func() {
retriever := partialAgent.(agents.ArtistImageRetriever)
_, err := retriever.GetArtistImages(GinkgoT().Context(), "artist-1", "Test Artist", "mbid")
Expect(err).To(MatchError(errNotImplemented))
})
It("returns ErrNotFound for unimplemented method (GetSimilarArtists)", func() {
retriever := partialAgent.(agents.ArtistSimilarRetriever)
_, err := retriever.GetSimilarArtists(GinkgoT().Context(), "artist-1", "Test Artist", "mbid", 5)
Expect(err).To(MatchError(errNotImplemented))
})
It("returns ErrNotFound for unimplemented method (GetArtistTopSongs)", func() {
retriever := partialAgent.(agents.ArtistTopSongsRetriever)
_, err := retriever.GetArtistTopSongs(GinkgoT().Context(), "artist-1", "Test Artist", "mbid", 5)
Expect(err).To(MatchError(errNotImplemented))
})
It("returns ErrNotFound for unimplemented method (GetAlbumInfo)", func() {
retriever := partialAgent.(agents.AlbumInfoRetriever)
_, err := retriever.GetAlbumInfo(GinkgoT().Context(), "Album", "Artist", "mbid")
Expect(err).To(MatchError(errNotImplemented))
})
It("returns ErrNotFound for unimplemented method (GetAlbumImages)", func() {
retriever := partialAgent.(agents.AlbumImageRetriever)
_, err := retriever.GetAlbumImages(GinkgoT().Context(), "Album", "Artist", "mbid")
Expect(err).To(MatchError(errNotImplemented))
})
})

View File

@ -61,6 +61,13 @@ func createTestManager(pluginConfig map[string]map[string]string) (*Manager, str
// and specified plugins. It creates a temp directory, copies the specified plugins, and starts the manager.
// Returns the manager and temp directory path.
func createTestManagerWithPlugins(pluginConfig map[string]map[string]string, plugins ...string) (*Manager, string) {
return createTestManagerWithPluginsAndMetrics(pluginConfig, nil, plugins...)
}
// createTestManagerWithPluginsAndMetrics creates a new plugin Manager with the given plugin config,
// metrics recorder, and specified plugins. It creates a temp directory, copies the specified plugins, and starts the manager.
// Returns the manager and temp directory path.
func createTestManagerWithPluginsAndMetrics(pluginConfig map[string]map[string]string, metrics PluginMetricsRecorder, plugins ...string) (*Manager, string) {
// Create temp directory
tmpDir, err := os.MkdirTemp("", "plugins-test-*")
Expect(err).ToNot(HaveOccurred())
@ -115,6 +122,7 @@ func createTestManagerWithPlugins(pluginConfig map[string]map[string]string, plu
manager := &Manager{
plugins: make(map[string]*plugin),
ds: dataStore,
metrics: metrics,
subsonicRouter: http.NotFoundHandler(), // Stub router for tests
}
err = manager.Start(GinkgoT().Context())

View File

@ -0,0 +1,16 @@
module partial-metadata-agent
go 1.25
require github.com/navidrome/navidrome/plugins/pdk/go v0.0.0
require (
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/extism/go-pdk v1.1.3 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/stretchr/objx v0.5.2 // indirect
github.com/stretchr/testify v1.11.1 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)
replace github.com/navidrome/navidrome/plugins/pdk/go => ../../pdk/go

View File

@ -0,0 +1,14 @@
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/extism/go-pdk v1.1.3 h1:hfViMPWrqjN6u67cIYRALZTZLk/enSPpNKa+rZ9X2SQ=
github.com/extism/go-pdk v1.1.3/go.mod h1:Gz+LIU/YCKnKXhgge8yo5Yu1F/lbv7KtKFkiCSzW/P4=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY=
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

View File

@ -0,0 +1,23 @@
// Test plugin that only implements some metadata methods.
// Used to test the "not implemented" code path (-2 return code).
// Build with: tinygo build -o ../partial-metadata-agent.wasm -target wasip1 -buildmode=c-shared .
package main
import (
"github.com/navidrome/navidrome/plugins/pdk/go/metadata"
)
func init() {
metadata.Register(&partialMetadataAgent{})
}
// partialMetadataAgent only implements GetArtistBiography.
// All other methods will return NotImplementedCode (-2).
type partialMetadataAgent struct{}
// GetArtistBiography is the only method we implement.
func (t *partialMetadataAgent) GetArtistBiography(input metadata.ArtistRequest) (*metadata.ArtistBiographyResponse, error) {
return &metadata.ArtistBiographyResponse{Biography: "Partial agent biography for " + input.Name}, nil
}
func main() {}

View File

@ -0,0 +1,6 @@
{
"name": "Partial Metadata Agent",
"author": "Navidrome Test",
"version": "1.0.0",
"description": "A test plugin that only implements some metadata methods"
}