mirror of
https://github.com/navidrome/navidrome.git
synced 2026-08-31 07:30:32 +00:00
refactor(plugins): implement plugin function call helper and refactor MetadataAgent methods
Signed-off-by: Deluan <deluan@navidrome.org>
This commit is contained in:
parent
6d13416c15
commit
81e9c5b4e9
@ -55,6 +55,12 @@ type pluginInstance struct {
|
|||||||
capabilities []Capability // Auto-detected capabilities based on exported functions
|
capabilities []Capability // Auto-detected capabilities based on exported functions
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (p *pluginInstance) create() (*extism.Plugin, error) {
|
||||||
|
return p.compiled.Instance(context.Background(), extism.PluginInstanceConfig{
|
||||||
|
ModuleConfig: wazero.NewModuleConfig().WithSysWalltime().WithRandSource(rand.Reader),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
// GetManager returns a singleton instance of the plugin manager.
|
// GetManager returns a singleton instance of the plugin manager.
|
||||||
// The manager is not started automatically; call Start() to begin loading plugins.
|
// The manager is not started automatically; call Start() to begin loading plugins.
|
||||||
func GetManager() *Manager {
|
func GetManager() *Manager {
|
||||||
@ -174,14 +180,11 @@ func (m *Manager) LoadMediaAgent(name string) (agents.Interface, bool) {
|
|||||||
return nil, false
|
return nil, false
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create a new plugin instance for this agent
|
// Create a new metadata agent adapter for this plugin
|
||||||
agent, err := m.createMetadataAgent(instance)
|
return &MetadataAgent{
|
||||||
if err != nil {
|
name: instance.name,
|
||||||
log.Error("Failed to create metadata agent from plugin", "plugin", name, err)
|
plugin: instance,
|
||||||
return nil, false
|
}, true
|
||||||
}
|
|
||||||
|
|
||||||
return agent, true
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// LoadScrobbler loads and returns a scrobbler plugin by name.
|
// LoadScrobbler loads and returns a scrobbler plugin by name.
|
||||||
@ -340,19 +343,6 @@ func (m *Manager) getPluginConfig(name string) map[string]string {
|
|||||||
return conf.Server.PluginConfig[name]
|
return conf.Server.PluginConfig[name]
|
||||||
}
|
}
|
||||||
|
|
||||||
// createMetadataAgent creates a new MetadataAgent from a plugin instance
|
|
||||||
func (m *Manager) createMetadataAgent(instance *pluginInstance) (*MetadataAgent, error) {
|
|
||||||
// Create a new plugin instance from the compiled plugin
|
|
||||||
plugin, err := instance.compiled.Instance(m.ctx, extism.PluginInstanceConfig{
|
|
||||||
ModuleConfig: wazero.NewModuleConfig().WithSysWalltime().WithRandSource(rand.Reader),
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
return NewMetadataAgent(instance.name, plugin), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// UnloadPlugin removes a plugin from the manager and closes its resources.
|
// UnloadPlugin removes a plugin from the manager and closes its resources.
|
||||||
// Returns an error if the plugin is not found.
|
// Returns an error if the plugin is not found.
|
||||||
func (m *Manager) UnloadPlugin(name string) error {
|
func (m *Manager) UnloadPlugin(name string) error {
|
||||||
@ -430,6 +420,43 @@ func (m *Manager) ReloadPlugin(name string) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// callPluginFunction is a helper to call a plugin function with input and output types.
|
||||||
|
// It handles JSON marshalling/unmarshalling and error checking.
|
||||||
|
func callPluginFunction[I any, O any](ctx context.Context, plugin *pluginInstance, funcName string, input I) (O, error) {
|
||||||
|
var result O
|
||||||
|
|
||||||
|
// Create plugin instance
|
||||||
|
p, err := plugin.create()
|
||||||
|
if err != nil {
|
||||||
|
return result, fmt.Errorf("failed to create plugin: %w", err)
|
||||||
|
}
|
||||||
|
defer p.Close(ctx)
|
||||||
|
|
||||||
|
if !p.FunctionExists(funcName) {
|
||||||
|
return result, fmt.Errorf("%s does not exist", funcName)
|
||||||
|
}
|
||||||
|
|
||||||
|
inputBytes, err := json.Marshal(input)
|
||||||
|
if err != nil {
|
||||||
|
return result, fmt.Errorf("failed to marshal input: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
exit, output, err := p.Call(funcName, inputBytes)
|
||||||
|
if err != nil {
|
||||||
|
log.Debug(ctx, "Plugin call failed", "p", plugin.name, "function", funcName, err)
|
||||||
|
return result, fmt.Errorf("plugin call failed: %w", err)
|
||||||
|
}
|
||||||
|
if exit != 0 {
|
||||||
|
return result, fmt.Errorf("plugin call exited with code %d", exit)
|
||||||
|
}
|
||||||
|
|
||||||
|
err = json.Unmarshal(output, &result)
|
||||||
|
if err != nil {
|
||||||
|
log.Debug(ctx, "Plugin call failed", "p", plugin.name, "function", funcName, err)
|
||||||
|
}
|
||||||
|
return result, err
|
||||||
|
}
|
||||||
|
|
||||||
// Verify interface implementations at compile time
|
// Verify interface implementations at compile time
|
||||||
var (
|
var (
|
||||||
_ agents.PluginLoader = (*Manager)(nil)
|
_ agents.PluginLoader = (*Manager)(nil)
|
||||||
|
|||||||
@ -2,12 +2,15 @@ package plugins
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"runtime"
|
"runtime"
|
||||||
|
"sync"
|
||||||
|
|
||||||
"github.com/navidrome/navidrome/conf"
|
"github.com/navidrome/navidrome/conf"
|
||||||
"github.com/navidrome/navidrome/conf/configtest"
|
"github.com/navidrome/navidrome/conf/configtest"
|
||||||
|
"github.com/navidrome/navidrome/core/agents"
|
||||||
. "github.com/onsi/ginkgo/v2"
|
. "github.com/onsi/ginkgo/v2"
|
||||||
. "github.com/onsi/gomega"
|
. "github.com/onsi/gomega"
|
||||||
)
|
)
|
||||||
@ -37,6 +40,7 @@ var _ = Describe("Manager", func() {
|
|||||||
DeferCleanup(configtest.SetupConfig())
|
DeferCleanup(configtest.SetupConfig())
|
||||||
conf.Server.Plugins.Enabled = true
|
conf.Server.Plugins.Enabled = true
|
||||||
conf.Server.Plugins.Folder = tmpDir
|
conf.Server.Plugins.Folder = tmpDir
|
||||||
|
conf.Server.CacheFolder = filepath.Join(tmpDir, "cache")
|
||||||
|
|
||||||
// Create a fresh manager for each test
|
// Create a fresh manager for each test
|
||||||
manager = &Manager{
|
manager = &Manager{
|
||||||
@ -157,4 +161,43 @@ var _ = Describe("Manager", func() {
|
|||||||
Expect(names).ToNot(ContainElement("fail-reload"))
|
Expect(names).ToNot(ContainElement("fail-reload"))
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
It("can call the plugin concurrently", func() {
|
||||||
|
copyTestPlugin("new-plugin")
|
||||||
|
err := manager.LoadPlugin("new-plugin")
|
||||||
|
Expect(err).ToNot(HaveOccurred())
|
||||||
|
|
||||||
|
const concurrency = 100
|
||||||
|
errs := make(chan error, concurrency)
|
||||||
|
bios := make(chan string, concurrency)
|
||||||
|
|
||||||
|
g := sync.WaitGroup{}
|
||||||
|
g.Add(concurrency)
|
||||||
|
for i := range concurrency {
|
||||||
|
go func(i int) {
|
||||||
|
defer g.Done()
|
||||||
|
a, ok := manager.LoadMediaAgent("new-plugin")
|
||||||
|
Expect(ok).To(BeTrue())
|
||||||
|
agent := a.(agents.ArtistBiographyRetriever)
|
||||||
|
bio, err := agent.GetArtistBiography(ctx, fmt.Sprintf("artist-%d", i), fmt.Sprintf("Artist %d", i), "")
|
||||||
|
if err != nil {
|
||||||
|
errs <- err
|
||||||
|
return
|
||||||
|
}
|
||||||
|
bios <- bio
|
||||||
|
}(i)
|
||||||
|
}
|
||||||
|
g.Wait()
|
||||||
|
|
||||||
|
// Collect results
|
||||||
|
for range concurrency {
|
||||||
|
select {
|
||||||
|
case err := <-errs:
|
||||||
|
Expect(err).ToNot(HaveOccurred())
|
||||||
|
case bio := <-bios:
|
||||||
|
Expect(bio).To(ContainSubstring("Biography for Artist"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
})
|
})
|
||||||
|
|||||||
@ -2,11 +2,9 @@ package plugins
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"errors"
|
||||||
|
|
||||||
extism "github.com/extism/go-sdk"
|
|
||||||
"github.com/navidrome/navidrome/core/agents"
|
"github.com/navidrome/navidrome/core/agents"
|
||||||
"github.com/navidrome/navidrome/log"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// Export function names (snake_case as per design)
|
// Export function names (snake_case as per design)
|
||||||
@ -25,15 +23,7 @@ const (
|
|||||||
// the agents interfaces for metadata retrieval.
|
// the agents interfaces for metadata retrieval.
|
||||||
type MetadataAgent struct {
|
type MetadataAgent struct {
|
||||||
name string
|
name string
|
||||||
plugin *extism.Plugin
|
plugin *pluginInstance
|
||||||
}
|
|
||||||
|
|
||||||
// NewMetadataAgent creates a new MetadataAgent wrapping the given plugin.
|
|
||||||
func NewMetadataAgent(name string, plugin *extism.Plugin) *MetadataAgent {
|
|
||||||
return &MetadataAgent{
|
|
||||||
name: name,
|
|
||||||
plugin: plugin,
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// AgentName returns the plugin name
|
// AgentName returns the plugin name
|
||||||
@ -41,120 +31,14 @@ func (a *MetadataAgent) AgentName() string {
|
|||||||
return a.name
|
return a.name
|
||||||
}
|
}
|
||||||
|
|
||||||
// Close closes the plugin instance
|
|
||||||
func (a *MetadataAgent) Close() error {
|
|
||||||
if a.plugin != nil {
|
|
||||||
return a.plugin.Close(context.Background())
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Input/Output JSON structures ---
|
|
||||||
|
|
||||||
type artistMBIDInput struct {
|
|
||||||
ID string `json:"id"`
|
|
||||||
Name string `json:"name"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type artistMBIDOutput struct {
|
|
||||||
MBID string `json:"mbid"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type artistInput struct {
|
|
||||||
ID string `json:"id"`
|
|
||||||
Name string `json:"name"`
|
|
||||||
MBID string `json:"mbid,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type artistURLOutput struct {
|
|
||||||
URL string `json:"url"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type artistBiographyOutput struct {
|
|
||||||
Biography string `json:"biography"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type similarArtistsInput struct {
|
|
||||||
ID string `json:"id"`
|
|
||||||
Name string `json:"name"`
|
|
||||||
MBID string `json:"mbid,omitempty"`
|
|
||||||
Limit int `json:"limit"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type similarArtistsOutput struct {
|
|
||||||
Artists []struct {
|
|
||||||
Name string `json:"name"`
|
|
||||||
MBID string `json:"mbid,omitempty"`
|
|
||||||
} `json:"artists"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type artistImagesOutput struct {
|
|
||||||
Images []struct {
|
|
||||||
URL string `json:"url"`
|
|
||||||
Size int `json:"size"`
|
|
||||||
} `json:"images"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type topSongsInput struct {
|
|
||||||
ID string `json:"id"`
|
|
||||||
Name string `json:"name"`
|
|
||||||
MBID string `json:"mbid,omitempty"`
|
|
||||||
Count int `json:"count"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type topSongsOutput struct {
|
|
||||||
Songs []struct {
|
|
||||||
Name string `json:"name"`
|
|
||||||
MBID string `json:"mbid,omitempty"`
|
|
||||||
} `json:"songs"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type albumInput struct {
|
|
||||||
Name string `json:"name"`
|
|
||||||
Artist string `json:"artist"`
|
|
||||||
MBID string `json:"mbid,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type albumInfoOutput struct {
|
|
||||||
Name string `json:"name"`
|
|
||||||
MBID string `json:"mbid"`
|
|
||||||
Description string `json:"description"`
|
|
||||||
URL string `json:"url"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type albumImagesOutput struct {
|
|
||||||
Images []struct {
|
|
||||||
URL string `json:"url"`
|
|
||||||
Size int `json:"size"`
|
|
||||||
} `json:"images"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Interface implementations ---
|
// --- Interface implementations ---
|
||||||
|
|
||||||
// GetArtistMBID retrieves the MusicBrainz ID for an artist
|
// GetArtistMBID retrieves the MusicBrainz ID for an artist
|
||||||
func (a *MetadataAgent) GetArtistMBID(ctx context.Context, id string, name string) (string, error) {
|
func (a *MetadataAgent) GetArtistMBID(ctx context.Context, id string, name string) (string, error) {
|
||||||
if !a.plugin.FunctionExists(FuncGetArtistMBID) {
|
|
||||||
return "", agents.ErrNotFound
|
|
||||||
}
|
|
||||||
|
|
||||||
input := artistMBIDInput{ID: id, Name: name}
|
input := artistMBIDInput{ID: id, Name: name}
|
||||||
inputBytes, err := json.Marshal(input)
|
result, err := callPluginFunction[artistMBIDInput, artistMBIDOutput](ctx, a.plugin, FuncGetArtistMBID, input)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", errors.Join(agents.ErrNotFound, err)
|
||||||
}
|
|
||||||
|
|
||||||
exit, output, err := a.plugin.Call(FuncGetArtistMBID, inputBytes)
|
|
||||||
if err != nil {
|
|
||||||
log.Debug(ctx, "Plugin call failed", "plugin", a.name, "function", FuncGetArtistMBID, err)
|
|
||||||
return "", agents.ErrNotFound
|
|
||||||
}
|
|
||||||
if exit != 0 {
|
|
||||||
return "", agents.ErrNotFound
|
|
||||||
}
|
|
||||||
|
|
||||||
var result artistMBIDOutput
|
|
||||||
if err := json.Unmarshal(output, &result); err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if result.MBID == "" {
|
if result.MBID == "" {
|
||||||
@ -166,61 +50,23 @@ func (a *MetadataAgent) GetArtistMBID(ctx context.Context, id string, name strin
|
|||||||
|
|
||||||
// GetArtistURL retrieves the external URL for an artist
|
// GetArtistURL retrieves the external URL for an artist
|
||||||
func (a *MetadataAgent) GetArtistURL(ctx context.Context, id, name, mbid string) (string, error) {
|
func (a *MetadataAgent) GetArtistURL(ctx context.Context, id, name, mbid string) (string, error) {
|
||||||
if !a.plugin.FunctionExists(FuncGetArtistURL) {
|
|
||||||
return "", agents.ErrNotFound
|
|
||||||
}
|
|
||||||
|
|
||||||
input := artistInput{ID: id, Name: name, MBID: mbid}
|
input := artistInput{ID: id, Name: name, MBID: mbid}
|
||||||
inputBytes, err := json.Marshal(input)
|
result, err := callPluginFunction[artistInput, artistURLOutput](ctx, a.plugin, FuncGetArtistURL, input)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", errors.Join(agents.ErrNotFound, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
exit, output, err := a.plugin.Call(FuncGetArtistURL, inputBytes)
|
|
||||||
if err != nil {
|
|
||||||
log.Debug(ctx, "Plugin call failed", "plugin", a.name, "function", FuncGetArtistURL, err)
|
|
||||||
return "", agents.ErrNotFound
|
|
||||||
}
|
|
||||||
if exit != 0 {
|
|
||||||
return "", agents.ErrNotFound
|
|
||||||
}
|
|
||||||
|
|
||||||
var result artistURLOutput
|
|
||||||
if err := json.Unmarshal(output, &result); err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
|
|
||||||
if result.URL == "" {
|
if result.URL == "" {
|
||||||
return "", agents.ErrNotFound
|
return "", agents.ErrNotFound
|
||||||
}
|
}
|
||||||
|
|
||||||
return result.URL, nil
|
return result.URL, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetArtistBiography retrieves the biography for an artist
|
// GetArtistBiography retrieves the biography for an artist
|
||||||
func (a *MetadataAgent) GetArtistBiography(ctx context.Context, id, name, mbid string) (string, error) {
|
func (a *MetadataAgent) GetArtistBiography(ctx context.Context, id, name, mbid string) (string, error) {
|
||||||
if !a.plugin.FunctionExists(FuncGetArtistBiography) {
|
|
||||||
return "", agents.ErrNotFound
|
|
||||||
}
|
|
||||||
|
|
||||||
input := artistInput{ID: id, Name: name, MBID: mbid}
|
input := artistInput{ID: id, Name: name, MBID: mbid}
|
||||||
inputBytes, err := json.Marshal(input)
|
result, err := callPluginFunction[artistInput, artistBiographyOutput](ctx, a.plugin, FuncGetArtistBiography, input)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", errors.Join(agents.ErrNotFound, err)
|
||||||
}
|
|
||||||
|
|
||||||
exit, output, err := a.plugin.Call(FuncGetArtistBiography, inputBytes)
|
|
||||||
if err != nil {
|
|
||||||
log.Debug(ctx, "Plugin call failed", "plugin", a.name, "function", FuncGetArtistBiography, err)
|
|
||||||
return "", agents.ErrNotFound
|
|
||||||
}
|
|
||||||
if exit != 0 {
|
|
||||||
return "", agents.ErrNotFound
|
|
||||||
}
|
|
||||||
|
|
||||||
var result artistBiographyOutput
|
|
||||||
if err := json.Unmarshal(output, &result); err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if result.Biography == "" {
|
if result.Biography == "" {
|
||||||
@ -232,28 +78,10 @@ func (a *MetadataAgent) GetArtistBiography(ctx context.Context, id, name, mbid s
|
|||||||
|
|
||||||
// GetSimilarArtists retrieves similar artists
|
// GetSimilarArtists retrieves similar artists
|
||||||
func (a *MetadataAgent) GetSimilarArtists(ctx context.Context, id, name, mbid string, limit int) ([]agents.Artist, error) {
|
func (a *MetadataAgent) GetSimilarArtists(ctx context.Context, id, name, mbid string, limit int) ([]agents.Artist, error) {
|
||||||
if !a.plugin.FunctionExists(FuncGetSimilarArtists) {
|
|
||||||
return nil, agents.ErrNotFound
|
|
||||||
}
|
|
||||||
|
|
||||||
input := similarArtistsInput{ID: id, Name: name, MBID: mbid, Limit: limit}
|
input := similarArtistsInput{ID: id, Name: name, MBID: mbid, Limit: limit}
|
||||||
inputBytes, err := json.Marshal(input)
|
result, err := callPluginFunction[similarArtistsInput, similarArtistsOutput](ctx, a.plugin, FuncGetSimilarArtists, input)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, errors.Join(agents.ErrNotFound, err)
|
||||||
}
|
|
||||||
|
|
||||||
exit, output, err := a.plugin.Call(FuncGetSimilarArtists, inputBytes)
|
|
||||||
if err != nil {
|
|
||||||
log.Debug(ctx, "Plugin call failed", "plugin", a.name, "function", FuncGetSimilarArtists, err)
|
|
||||||
return nil, agents.ErrNotFound
|
|
||||||
}
|
|
||||||
if exit != 0 {
|
|
||||||
return nil, agents.ErrNotFound
|
|
||||||
}
|
|
||||||
|
|
||||||
var result similarArtistsOutput
|
|
||||||
if err := json.Unmarshal(output, &result); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(result.Artists) == 0 {
|
if len(result.Artists) == 0 {
|
||||||
@ -270,28 +98,10 @@ func (a *MetadataAgent) GetSimilarArtists(ctx context.Context, id, name, mbid st
|
|||||||
|
|
||||||
// GetArtistImages retrieves images for an artist
|
// GetArtistImages retrieves images for an artist
|
||||||
func (a *MetadataAgent) GetArtistImages(ctx context.Context, id, name, mbid string) ([]agents.ExternalImage, error) {
|
func (a *MetadataAgent) GetArtistImages(ctx context.Context, id, name, mbid string) ([]agents.ExternalImage, error) {
|
||||||
if !a.plugin.FunctionExists(FuncGetArtistImages) {
|
|
||||||
return nil, agents.ErrNotFound
|
|
||||||
}
|
|
||||||
|
|
||||||
input := artistInput{ID: id, Name: name, MBID: mbid}
|
input := artistInput{ID: id, Name: name, MBID: mbid}
|
||||||
inputBytes, err := json.Marshal(input)
|
result, err := callPluginFunction[artistInput, artistImagesOutput](ctx, a.plugin, FuncGetArtistImages, input)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, errors.Join(agents.ErrNotFound, err)
|
||||||
}
|
|
||||||
|
|
||||||
exit, output, err := a.plugin.Call(FuncGetArtistImages, inputBytes)
|
|
||||||
if err != nil {
|
|
||||||
log.Debug(ctx, "Plugin call failed", "plugin", a.name, "function", FuncGetArtistImages, err)
|
|
||||||
return nil, agents.ErrNotFound
|
|
||||||
}
|
|
||||||
if exit != 0 {
|
|
||||||
return nil, agents.ErrNotFound
|
|
||||||
}
|
|
||||||
|
|
||||||
var result artistImagesOutput
|
|
||||||
if err := json.Unmarshal(output, &result); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(result.Images) == 0 {
|
if len(result.Images) == 0 {
|
||||||
@ -308,28 +118,10 @@ func (a *MetadataAgent) GetArtistImages(ctx context.Context, id, name, mbid stri
|
|||||||
|
|
||||||
// GetArtistTopSongs retrieves top songs for an artist
|
// GetArtistTopSongs retrieves top songs for an artist
|
||||||
func (a *MetadataAgent) GetArtistTopSongs(ctx context.Context, id, artistName, mbid string, count int) ([]agents.Song, error) {
|
func (a *MetadataAgent) GetArtistTopSongs(ctx context.Context, id, artistName, mbid string, count int) ([]agents.Song, error) {
|
||||||
if !a.plugin.FunctionExists(FuncGetArtistTopSongs) {
|
|
||||||
return nil, agents.ErrNotFound
|
|
||||||
}
|
|
||||||
|
|
||||||
input := topSongsInput{ID: id, Name: artistName, MBID: mbid, Count: count}
|
input := topSongsInput{ID: id, Name: artistName, MBID: mbid, Count: count}
|
||||||
inputBytes, err := json.Marshal(input)
|
result, err := callPluginFunction[topSongsInput, topSongsOutput](ctx, a.plugin, FuncGetArtistTopSongs, input)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, errors.Join(agents.ErrNotFound, err)
|
||||||
}
|
|
||||||
|
|
||||||
exit, output, err := a.plugin.Call(FuncGetArtistTopSongs, inputBytes)
|
|
||||||
if err != nil {
|
|
||||||
log.Debug(ctx, "Plugin call failed", "plugin", a.name, "function", FuncGetArtistTopSongs, err)
|
|
||||||
return nil, agents.ErrNotFound
|
|
||||||
}
|
|
||||||
if exit != 0 {
|
|
||||||
return nil, agents.ErrNotFound
|
|
||||||
}
|
|
||||||
|
|
||||||
var result topSongsOutput
|
|
||||||
if err := json.Unmarshal(output, &result); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(result.Songs) == 0 {
|
if len(result.Songs) == 0 {
|
||||||
@ -346,28 +138,10 @@ func (a *MetadataAgent) GetArtistTopSongs(ctx context.Context, id, artistName, m
|
|||||||
|
|
||||||
// GetAlbumInfo retrieves album information
|
// GetAlbumInfo retrieves album information
|
||||||
func (a *MetadataAgent) GetAlbumInfo(ctx context.Context, name, artist, mbid string) (*agents.AlbumInfo, error) {
|
func (a *MetadataAgent) GetAlbumInfo(ctx context.Context, name, artist, mbid string) (*agents.AlbumInfo, error) {
|
||||||
if !a.plugin.FunctionExists(FuncGetAlbumInfo) {
|
|
||||||
return nil, agents.ErrNotFound
|
|
||||||
}
|
|
||||||
|
|
||||||
input := albumInput{Name: name, Artist: artist, MBID: mbid}
|
input := albumInput{Name: name, Artist: artist, MBID: mbid}
|
||||||
inputBytes, err := json.Marshal(input)
|
result, err := callPluginFunction[albumInput, albumInfoOutput](ctx, a.plugin, FuncGetAlbumInfo, input)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, errors.Join(agents.ErrNotFound, err)
|
||||||
}
|
|
||||||
|
|
||||||
exit, output, err := a.plugin.Call(FuncGetAlbumInfo, inputBytes)
|
|
||||||
if err != nil {
|
|
||||||
log.Debug(ctx, "Plugin call failed", "plugin", a.name, "function", FuncGetAlbumInfo, err)
|
|
||||||
return nil, agents.ErrNotFound
|
|
||||||
}
|
|
||||||
if exit != 0 {
|
|
||||||
return nil, agents.ErrNotFound
|
|
||||||
}
|
|
||||||
|
|
||||||
var result albumInfoOutput
|
|
||||||
if err := json.Unmarshal(output, &result); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return &agents.AlbumInfo{
|
return &agents.AlbumInfo{
|
||||||
@ -380,28 +154,10 @@ func (a *MetadataAgent) GetAlbumInfo(ctx context.Context, name, artist, mbid str
|
|||||||
|
|
||||||
// GetAlbumImages retrieves images for an album
|
// GetAlbumImages retrieves images for an album
|
||||||
func (a *MetadataAgent) GetAlbumImages(ctx context.Context, name, artist, mbid string) ([]agents.ExternalImage, error) {
|
func (a *MetadataAgent) GetAlbumImages(ctx context.Context, name, artist, mbid string) ([]agents.ExternalImage, error) {
|
||||||
if !a.plugin.FunctionExists(FuncGetAlbumImages) {
|
|
||||||
return nil, agents.ErrNotFound
|
|
||||||
}
|
|
||||||
|
|
||||||
input := albumInput{Name: name, Artist: artist, MBID: mbid}
|
input := albumInput{Name: name, Artist: artist, MBID: mbid}
|
||||||
inputBytes, err := json.Marshal(input)
|
result, err := callPluginFunction[albumInput, albumImagesOutput](ctx, a.plugin, FuncGetAlbumImages, input)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, errors.Join(agents.ErrNotFound, err)
|
||||||
}
|
|
||||||
|
|
||||||
exit, output, err := a.plugin.Call(FuncGetAlbumImages, inputBytes)
|
|
||||||
if err != nil {
|
|
||||||
log.Debug(ctx, "Plugin call failed", "plugin", a.name, "function", FuncGetAlbumImages, err)
|
|
||||||
return nil, agents.ErrNotFound
|
|
||||||
}
|
|
||||||
if exit != 0 {
|
|
||||||
return nil, agents.ErrNotFound
|
|
||||||
}
|
|
||||||
|
|
||||||
var result albumImagesOutput
|
|
||||||
if err := json.Unmarshal(output, &result); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(result.Images) == 0 {
|
if len(result.Images) == 0 {
|
||||||
|
|||||||
@ -2,48 +2,69 @@ package plugins
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"runtime"
|
"runtime"
|
||||||
|
|
||||||
extism "github.com/extism/go-sdk"
|
"github.com/navidrome/navidrome/conf"
|
||||||
|
"github.com/navidrome/navidrome/conf/configtest"
|
||||||
|
"github.com/navidrome/navidrome/core/agents"
|
||||||
. "github.com/onsi/ginkgo/v2"
|
. "github.com/onsi/ginkgo/v2"
|
||||||
. "github.com/onsi/gomega"
|
. "github.com/onsi/gomega"
|
||||||
)
|
)
|
||||||
|
|
||||||
var _ = Describe("MetadataAgent", func() {
|
var _ = Describe("MetadataAgent", func() {
|
||||||
var (
|
var (
|
||||||
agent *MetadataAgent
|
manager *Manager
|
||||||
ctx context.Context
|
agent agents.Interface
|
||||||
|
ctx context.Context
|
||||||
|
testdataDir string
|
||||||
|
tmpDir string
|
||||||
)
|
)
|
||||||
|
|
||||||
BeforeEach(func() {
|
BeforeEach(func() {
|
||||||
ctx = GinkgoT().Context()
|
ctx = GinkgoT().Context()
|
||||||
|
|
||||||
// Load the test plugin
|
// Get testdata directory
|
||||||
_, currentFile, _, ok := runtime.Caller(0)
|
_, currentFile, _, ok := runtime.Caller(0)
|
||||||
Expect(ok).To(BeTrue())
|
Expect(ok).To(BeTrue())
|
||||||
testdataDir := filepath.Join(filepath.Dir(currentFile), "testdata")
|
testdataDir = filepath.Join(filepath.Dir(currentFile), "testdata")
|
||||||
wasmPath := filepath.Join(testdataDir, "test-plugin.wasm")
|
|
||||||
|
|
||||||
manifest := extism.Manifest{
|
// Create temp dir for plugins
|
||||||
Wasm: []extism.Wasm{
|
var err error
|
||||||
extism.WasmFile{Path: wasmPath},
|
tmpDir, err = os.MkdirTemp("", "metadata-agent-test-*")
|
||||||
},
|
|
||||||
AllowedHosts: []string{"test.example.com"},
|
|
||||||
}
|
|
||||||
|
|
||||||
plugin, err := extism.NewPlugin(ctx, manifest, extism.PluginConfig{
|
|
||||||
EnableWasi: true,
|
|
||||||
}, nil)
|
|
||||||
Expect(err).ToNot(HaveOccurred())
|
Expect(err).ToNot(HaveOccurred())
|
||||||
|
|
||||||
agent = NewMetadataAgent("test-plugin", plugin)
|
// Copy test plugin to temp dir
|
||||||
})
|
srcPath := filepath.Join(testdataDir, "test-plugin.wasm")
|
||||||
|
destPath := filepath.Join(tmpDir, "test-plugin.wasm")
|
||||||
|
data, err := os.ReadFile(srcPath)
|
||||||
|
Expect(err).ToNot(HaveOccurred())
|
||||||
|
err = os.WriteFile(destPath, data, 0600)
|
||||||
|
Expect(err).ToNot(HaveOccurred())
|
||||||
|
|
||||||
AfterEach(func() {
|
// Setup config
|
||||||
if agent != nil {
|
DeferCleanup(configtest.SetupConfig())
|
||||||
_ = agent.Close()
|
conf.Server.Plugins.Enabled = true
|
||||||
|
conf.Server.Plugins.Folder = tmpDir
|
||||||
|
conf.Server.CacheFolder = filepath.Join(tmpDir, "cache")
|
||||||
|
|
||||||
|
// Create and start the manager
|
||||||
|
manager = &Manager{
|
||||||
|
plugins: make(map[string]*pluginInstance),
|
||||||
}
|
}
|
||||||
|
err = manager.Start(ctx)
|
||||||
|
Expect(err).ToNot(HaveOccurred())
|
||||||
|
|
||||||
|
// Load the agent via manager
|
||||||
|
var ok2 bool
|
||||||
|
agent, ok2 = manager.LoadMediaAgent("test-plugin")
|
||||||
|
Expect(ok2).To(BeTrue())
|
||||||
|
|
||||||
|
DeferCleanup(func() {
|
||||||
|
_ = manager.Stop()
|
||||||
|
_ = os.RemoveAll(tmpDir)
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
Describe("AgentName", func() {
|
Describe("AgentName", func() {
|
||||||
@ -54,7 +75,8 @@ var _ = Describe("MetadataAgent", func() {
|
|||||||
|
|
||||||
Describe("GetArtistMBID", func() {
|
Describe("GetArtistMBID", func() {
|
||||||
It("returns the MBID from the plugin", func() {
|
It("returns the MBID from the plugin", func() {
|
||||||
mbid, err := agent.GetArtistMBID(ctx, "artist-1", "The Beatles")
|
retriever := agent.(agents.ArtistMBIDRetriever)
|
||||||
|
mbid, err := retriever.GetArtistMBID(ctx, "artist-1", "The Beatles")
|
||||||
Expect(err).ToNot(HaveOccurred())
|
Expect(err).ToNot(HaveOccurred())
|
||||||
Expect(mbid).To(Equal("test-mbid-The Beatles"))
|
Expect(mbid).To(Equal("test-mbid-The Beatles"))
|
||||||
})
|
})
|
||||||
@ -62,7 +84,8 @@ var _ = Describe("MetadataAgent", func() {
|
|||||||
|
|
||||||
Describe("GetArtistURL", func() {
|
Describe("GetArtistURL", func() {
|
||||||
It("returns the URL from the plugin", func() {
|
It("returns the URL from the plugin", func() {
|
||||||
url, err := agent.GetArtistURL(ctx, "artist-1", "The Beatles", "some-mbid")
|
retriever := agent.(agents.ArtistURLRetriever)
|
||||||
|
url, err := retriever.GetArtistURL(ctx, "artist-1", "The Beatles", "some-mbid")
|
||||||
Expect(err).ToNot(HaveOccurred())
|
Expect(err).ToNot(HaveOccurred())
|
||||||
Expect(url).To(Equal("https://test.example.com/artist/The Beatles"))
|
Expect(url).To(Equal("https://test.example.com/artist/The Beatles"))
|
||||||
})
|
})
|
||||||
@ -70,7 +93,8 @@ var _ = Describe("MetadataAgent", func() {
|
|||||||
|
|
||||||
Describe("GetArtistBiography", func() {
|
Describe("GetArtistBiography", func() {
|
||||||
It("returns the biography from the plugin", func() {
|
It("returns the biography from the plugin", func() {
|
||||||
bio, err := agent.GetArtistBiography(ctx, "artist-1", "The Beatles", "some-mbid")
|
retriever := agent.(agents.ArtistBiographyRetriever)
|
||||||
|
bio, err := retriever.GetArtistBiography(ctx, "artist-1", "The Beatles", "some-mbid")
|
||||||
Expect(err).ToNot(HaveOccurred())
|
Expect(err).ToNot(HaveOccurred())
|
||||||
Expect(bio).To(Equal("Biography for The Beatles"))
|
Expect(bio).To(Equal("Biography for The Beatles"))
|
||||||
})
|
})
|
||||||
@ -78,7 +102,8 @@ var _ = Describe("MetadataAgent", func() {
|
|||||||
|
|
||||||
Describe("GetArtistImages", func() {
|
Describe("GetArtistImages", func() {
|
||||||
It("returns images from the plugin", func() {
|
It("returns images from the plugin", func() {
|
||||||
images, err := agent.GetArtistImages(ctx, "artist-1", "The Beatles", "some-mbid")
|
retriever := agent.(agents.ArtistImageRetriever)
|
||||||
|
images, err := retriever.GetArtistImages(ctx, "artist-1", "The Beatles", "some-mbid")
|
||||||
Expect(err).ToNot(HaveOccurred())
|
Expect(err).ToNot(HaveOccurred())
|
||||||
Expect(images).To(HaveLen(2))
|
Expect(images).To(HaveLen(2))
|
||||||
Expect(images[0].URL).To(Equal("https://test.example.com/images/The Beatles/large.jpg"))
|
Expect(images[0].URL).To(Equal("https://test.example.com/images/The Beatles/large.jpg"))
|
||||||
@ -90,7 +115,8 @@ var _ = Describe("MetadataAgent", func() {
|
|||||||
|
|
||||||
Describe("GetSimilarArtists", func() {
|
Describe("GetSimilarArtists", func() {
|
||||||
It("returns similar artists from the plugin", func() {
|
It("returns similar artists from the plugin", func() {
|
||||||
artists, err := agent.GetSimilarArtists(ctx, "artist-1", "The Beatles", "some-mbid", 3)
|
retriever := agent.(agents.ArtistSimilarRetriever)
|
||||||
|
artists, err := retriever.GetSimilarArtists(ctx, "artist-1", "The Beatles", "some-mbid", 3)
|
||||||
Expect(err).ToNot(HaveOccurred())
|
Expect(err).ToNot(HaveOccurred())
|
||||||
Expect(artists).To(HaveLen(3))
|
Expect(artists).To(HaveLen(3))
|
||||||
Expect(artists[0].Name).To(Equal("The Beatles Similar A"))
|
Expect(artists[0].Name).To(Equal("The Beatles Similar A"))
|
||||||
@ -101,7 +127,8 @@ var _ = Describe("MetadataAgent", func() {
|
|||||||
|
|
||||||
Describe("GetArtistTopSongs", func() {
|
Describe("GetArtistTopSongs", func() {
|
||||||
It("returns top songs from the plugin", func() {
|
It("returns top songs from the plugin", func() {
|
||||||
songs, err := agent.GetArtistTopSongs(ctx, "artist-1", "The Beatles", "some-mbid", 3)
|
retriever := agent.(agents.ArtistTopSongsRetriever)
|
||||||
|
songs, err := retriever.GetArtistTopSongs(ctx, "artist-1", "The Beatles", "some-mbid", 3)
|
||||||
Expect(err).ToNot(HaveOccurred())
|
Expect(err).ToNot(HaveOccurred())
|
||||||
Expect(songs).To(HaveLen(3))
|
Expect(songs).To(HaveLen(3))
|
||||||
Expect(songs[0].Name).To(Equal("The Beatles Song 1"))
|
Expect(songs[0].Name).To(Equal("The Beatles Song 1"))
|
||||||
@ -112,7 +139,8 @@ var _ = Describe("MetadataAgent", func() {
|
|||||||
|
|
||||||
Describe("GetAlbumInfo", func() {
|
Describe("GetAlbumInfo", func() {
|
||||||
It("returns album info from the plugin", func() {
|
It("returns album info from the plugin", func() {
|
||||||
info, err := agent.GetAlbumInfo(ctx, "Abbey Road", "The Beatles", "album-mbid")
|
retriever := agent.(agents.AlbumInfoRetriever)
|
||||||
|
info, err := retriever.GetAlbumInfo(ctx, "Abbey Road", "The Beatles", "album-mbid")
|
||||||
Expect(err).ToNot(HaveOccurred())
|
Expect(err).ToNot(HaveOccurred())
|
||||||
Expect(info.Name).To(Equal("Abbey Road"))
|
Expect(info.Name).To(Equal("Abbey Road"))
|
||||||
Expect(info.MBID).To(Equal("test-album-mbid-Abbey Road"))
|
Expect(info.MBID).To(Equal("test-album-mbid-Abbey Road"))
|
||||||
@ -123,7 +151,8 @@ var _ = Describe("MetadataAgent", func() {
|
|||||||
|
|
||||||
Describe("GetAlbumImages", func() {
|
Describe("GetAlbumImages", func() {
|
||||||
It("returns album images from the plugin", func() {
|
It("returns album images from the plugin", func() {
|
||||||
images, err := agent.GetAlbumImages(ctx, "Abbey Road", "The Beatles", "album-mbid")
|
retriever := agent.(agents.AlbumImageRetriever)
|
||||||
|
images, err := retriever.GetAlbumImages(ctx, "Abbey Road", "The Beatles", "album-mbid")
|
||||||
Expect(err).ToNot(HaveOccurred())
|
Expect(err).ToNot(HaveOccurred())
|
||||||
Expect(images).To(HaveLen(1))
|
Expect(images).To(HaveLen(1))
|
||||||
Expect(images[0].URL).To(Equal("https://test.example.com/albums/Abbey Road/cover.jpg"))
|
Expect(images[0].URL).To(Equal("https://test.example.com/albums/Abbey Road/cover.jpg"))
|
||||||
|
|||||||
100
plugins/metadata_types.go
Normal file
100
plugins/metadata_types.go
Normal file
@ -0,0 +1,100 @@
|
|||||||
|
package plugins
|
||||||
|
|
||||||
|
// --- Input/Output JSON structures for MetadataAgent plugin calls ---
|
||||||
|
|
||||||
|
// artistMBIDInput is the input for GetArtistMBID
|
||||||
|
type artistMBIDInput struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// artistMBIDOutput is the output for GetArtistMBID
|
||||||
|
type artistMBIDOutput struct {
|
||||||
|
MBID string `json:"mbid"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// artistInput is the common input for artist-related functions
|
||||||
|
type artistInput struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
MBID string `json:"mbid,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// artistURLOutput is the output for GetArtistURL
|
||||||
|
type artistURLOutput struct {
|
||||||
|
URL string `json:"url"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// artistBiographyOutput is the output for GetArtistBiography
|
||||||
|
type artistBiographyOutput struct {
|
||||||
|
Biography string `json:"biography"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// similarArtistsInput is the input for GetSimilarArtists
|
||||||
|
type similarArtistsInput struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
MBID string `json:"mbid,omitempty"`
|
||||||
|
Limit int `json:"limit"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// artistRef is a reference to an artist with name and optional MBID
|
||||||
|
type artistRef struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
MBID string `json:"mbid,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// similarArtistsOutput is the output for GetSimilarArtists
|
||||||
|
type similarArtistsOutput struct {
|
||||||
|
Artists []artistRef `json:"artists"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// imageInfo represents an image with URL and size
|
||||||
|
type imageInfo struct {
|
||||||
|
URL string `json:"url"`
|
||||||
|
Size int `json:"size"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// artistImagesOutput is the output for GetArtistImages
|
||||||
|
type artistImagesOutput struct {
|
||||||
|
Images []imageInfo `json:"images"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// topSongsInput is the input for GetArtistTopSongs
|
||||||
|
type topSongsInput struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
MBID string `json:"mbid,omitempty"`
|
||||||
|
Count int `json:"count"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// songRef is a reference to a song with name and optional MBID
|
||||||
|
type songRef struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
MBID string `json:"mbid,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// topSongsOutput is the output for GetArtistTopSongs
|
||||||
|
type topSongsOutput struct {
|
||||||
|
Songs []songRef `json:"songs"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// albumInput is the common input for album-related functions
|
||||||
|
type albumInput struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Artist string `json:"artist"`
|
||||||
|
MBID string `json:"mbid,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// albumInfoOutput is the output for GetAlbumInfo
|
||||||
|
type albumInfoOutput struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
MBID string `json:"mbid"`
|
||||||
|
Description string `json:"description"`
|
||||||
|
URL string `json:"url"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// albumImagesOutput is the output for GetAlbumImages
|
||||||
|
type albumImagesOutput struct {
|
||||||
|
Images []imageInfo `json:"images"`
|
||||||
|
}
|
||||||
@ -44,6 +44,7 @@ var _ = Describe("Watcher", func() {
|
|||||||
conf.Server.Plugins.Enabled = true
|
conf.Server.Plugins.Enabled = true
|
||||||
conf.Server.Plugins.Folder = tmpDir
|
conf.Server.Plugins.Folder = tmpDir
|
||||||
conf.Server.Plugins.AutoReload = true
|
conf.Server.Plugins.AutoReload = true
|
||||||
|
conf.Server.CacheFolder = filepath.Join(tmpDir, "cache")
|
||||||
|
|
||||||
// Create a fresh manager for each test
|
// Create a fresh manager for each test
|
||||||
manager = &Manager{
|
manager = &Manager{
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user