diff --git a/cmd/wire_gen.go b/cmd/wire_gen.go index 7740cb763..82f3ca9bf 100644 --- a/cmd/wire_gen.go +++ b/cmd/wire_gen.go @@ -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() diff --git a/cmd/wire_injectors.go b/cmd/wire_injectors.go index 782f2277c..6107fdf9b 100644 --- a/cmd/wire_injectors.go +++ b/cmd/wire_injectors.go @@ -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)), ) diff --git a/core/metrics/insights.go b/core/metrics/insights.go index ef24b6e0f..d48e7d67c 100644 --- a/core/metrics/insights.go +++ b/core/metrics/insights.go @@ -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)) diff --git a/plugins/manager.go b/plugins/manager.go index 1ca0eccfd..203d40c3b 100644 --- a/plugins/manager.go +++ b/plugins/manager.go @@ -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), } }) diff --git a/plugins/manager_call.go b/plugins/manager_call.go index 77e5b37ac..50b1f0797 100644 --- a/plugins/manager_call.go +++ b/plugins/manager_call.go @@ -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 } diff --git a/plugins/manager_call_test.go b/plugins/manager_call_test.go new file mode 100644 index 000000000..742c0e084 --- /dev/null +++ b/plugins/manager_call_test.go @@ -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()) + }) +}) diff --git a/plugins/manager_loader.go b/plugins/manager_loader.go index da48a33de..84043098e 100644 --- a/plugins/manager_loader.go +++ b/plugins/manager_loader.go @@ -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() diff --git a/plugins/manager_plugin.go b/plugins/manager_plugin.go index 44751e178..ec801f345 100644 --- a/plugins/manager_plugin.go +++ b/plugins/manager_plugin.go @@ -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. diff --git a/plugins/metadata_agent_test.go b/plugins/metadata_agent_test.go index 96957b71a..b4c37a88c 100644 --- a/plugins/metadata_agent_test.go +++ b/plugins/metadata_agent_test.go @@ -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)) + + }) +}) diff --git a/plugins/plugins_suite_test.go b/plugins/plugins_suite_test.go index 96379b621..013e80b6b 100644 --- a/plugins/plugins_suite_test.go +++ b/plugins/plugins_suite_test.go @@ -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()) diff --git a/plugins/testdata/partial-metadata-agent/go.mod b/plugins/testdata/partial-metadata-agent/go.mod new file mode 100644 index 000000000..b37144d9a --- /dev/null +++ b/plugins/testdata/partial-metadata-agent/go.mod @@ -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 diff --git a/plugins/testdata/partial-metadata-agent/go.sum b/plugins/testdata/partial-metadata-agent/go.sum new file mode 100644 index 000000000..af880eb51 --- /dev/null +++ b/plugins/testdata/partial-metadata-agent/go.sum @@ -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= diff --git a/plugins/testdata/partial-metadata-agent/main.go b/plugins/testdata/partial-metadata-agent/main.go new file mode 100644 index 000000000..c11febf00 --- /dev/null +++ b/plugins/testdata/partial-metadata-agent/main.go @@ -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() {} diff --git a/plugins/testdata/partial-metadata-agent/manifest.json b/plugins/testdata/partial-metadata-agent/manifest.json new file mode 100644 index 000000000..a600985a8 --- /dev/null +++ b/plugins/testdata/partial-metadata-agent/manifest.json @@ -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" +}