feat(plugins): implement new plugin system with using Extism

Signed-off-by: Deluan <deluan@navidrome.org>
This commit is contained in:
Deluan
2025-12-21 19:59:56 -05:00
parent 2b2560ef16
commit b58bf3148c
23 changed files with 2016 additions and 70 deletions

View File

@@ -15,6 +15,7 @@ import (
"github.com/navidrome/navidrome/db"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/plugins"
"github.com/navidrome/navidrome/resources"
"github.com/navidrome/navidrome/scanner"
"github.com/navidrome/navidrome/scheduler"
@@ -327,11 +328,16 @@ func startPlaybackServer(ctx context.Context) func() error {
}
}
// TODO(PLUGINS): Implement startPluginManager with new plugin system
// startPluginManager starts the plugin manager, if configured.
func startPluginManager(_ context.Context) func() error {
func startPluginManager(ctx context.Context) func() error {
return func() error {
return nil
if !conf.Server.Plugins.Enabled {
log.Debug("Plugin system is DISABLED")
return nil
}
log.Info(ctx, "Starting plugin manager")
manager := plugins.GetManager()
return manager.Start(ctx)
}
}

View File

@@ -22,6 +22,7 @@ import (
"github.com/navidrome/navidrome/db"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/persistence"
"github.com/navidrome/navidrome/plugins"
"github.com/navidrome/navidrome/scanner"
"github.com/navidrome/navidrome/server"
"github.com/navidrome/navidrome/server/events"
@@ -59,8 +60,8 @@ func CreateNativeAPIRouter(ctx context.Context) *nativeapi.Router {
insights := metrics.GetInstance(dataStore)
fileCache := artwork.GetImageCache()
fFmpeg := ffmpeg.New()
noopPluginLoader := core.GetNoopPluginLoader()
agentsAgents := agents.GetAgents(dataStore, noopPluginLoader)
manager := plugins.GetManager()
agentsAgents := agents.GetAgents(dataStore, manager)
provider := external.NewProvider(dataStore, agentsAgents)
artworkArtwork := artwork.NewArtwork(dataStore, fileCache, fFmpeg, provider)
cacheWarmer := artwork.NewCacheWarmer(artworkArtwork, fileCache)
@@ -79,8 +80,8 @@ func CreateSubsonicAPIRouter(ctx context.Context) *subsonic.Router {
dataStore := persistence.New(sqlDB)
fileCache := artwork.GetImageCache()
fFmpeg := ffmpeg.New()
noopPluginLoader := core.GetNoopPluginLoader()
agentsAgents := agents.GetAgents(dataStore, noopPluginLoader)
manager := plugins.GetManager()
agentsAgents := agents.GetAgents(dataStore, manager)
provider := external.NewProvider(dataStore, agentsAgents)
artworkArtwork := artwork.NewArtwork(dataStore, fileCache, fFmpeg, provider)
transcodingCache := core.GetTranscodingCache()
@@ -93,7 +94,7 @@ func CreateSubsonicAPIRouter(ctx context.Context) *subsonic.Router {
playlists := core.NewPlaylists(dataStore)
metricsMetrics := metrics.GetPrometheusInstance(dataStore)
modelScanner := scanner.New(ctx, dataStore, cacheWarmer, broker, playlists, metricsMetrics)
playTracker := scrobbler.GetPlayTracker(dataStore, broker, noopPluginLoader)
playTracker := scrobbler.GetPlayTracker(dataStore, broker, manager)
playbackServer := playback.GetInstance(dataStore)
router := subsonic.New(dataStore, artworkArtwork, mediaStreamer, archiver, players, provider, modelScanner, broker, playlists, playTracker, share, playbackServer, metricsMetrics)
return router
@@ -104,8 +105,8 @@ func CreatePublicRouter() *public.Router {
dataStore := persistence.New(sqlDB)
fileCache := artwork.GetImageCache()
fFmpeg := ffmpeg.New()
noopPluginLoader := core.GetNoopPluginLoader()
agentsAgents := agents.GetAgents(dataStore, noopPluginLoader)
manager := plugins.GetManager()
agentsAgents := agents.GetAgents(dataStore, manager)
provider := external.NewProvider(dataStore, agentsAgents)
artworkArtwork := artwork.NewArtwork(dataStore, fileCache, fFmpeg, provider)
transcodingCache := core.GetTranscodingCache()
@@ -149,8 +150,8 @@ func CreateScanner(ctx context.Context) model.Scanner {
dataStore := persistence.New(sqlDB)
fileCache := artwork.GetImageCache()
fFmpeg := ffmpeg.New()
noopPluginLoader := core.GetNoopPluginLoader()
agentsAgents := agents.GetAgents(dataStore, noopPluginLoader)
manager := plugins.GetManager()
agentsAgents := agents.GetAgents(dataStore, manager)
provider := external.NewProvider(dataStore, agentsAgents)
artworkArtwork := artwork.NewArtwork(dataStore, fileCache, fFmpeg, provider)
cacheWarmer := artwork.NewCacheWarmer(artworkArtwork, fileCache)
@@ -166,8 +167,8 @@ func CreateScanWatcher(ctx context.Context) scanner.Watcher {
dataStore := persistence.New(sqlDB)
fileCache := artwork.GetImageCache()
fFmpeg := ffmpeg.New()
noopPluginLoader := core.GetNoopPluginLoader()
agentsAgents := agents.GetAgents(dataStore, noopPluginLoader)
manager := plugins.GetManager()
agentsAgents := agents.GetAgents(dataStore, manager)
provider := external.NewProvider(dataStore, agentsAgents)
artworkArtwork := artwork.NewArtwork(dataStore, fileCache, fFmpeg, provider)
cacheWarmer := artwork.NewCacheWarmer(artworkArtwork, fileCache)
@@ -188,4 +189,4 @@ func GetPlaybackServer() playback.PlaybackServer {
// 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, core.GetNoopPluginLoader, wire.Bind(new(agents.PluginLoader), new(*core.NoopPluginLoader)), wire.Bind(new(scrobbler.PluginLoader), new(*core.NoopPluginLoader)), 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(core.Watcher), new(scanner.Watcher)))

View File

@@ -17,6 +17,7 @@ import (
"github.com/navidrome/navidrome/db"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/persistence"
"github.com/navidrome/navidrome/plugins"
"github.com/navidrome/navidrome/scanner"
"github.com/navidrome/navidrome/server"
"github.com/navidrome/navidrome/server/events"
@@ -40,10 +41,9 @@ var allProviders = wire.NewSet(
scanner.GetWatcher,
metrics.GetPrometheusInstance,
db.Db,
// TODO(PLUGINS): Replace NoopPluginLoader with actual plugin manager
core.GetNoopPluginLoader,
wire.Bind(new(agents.PluginLoader), new(*core.NoopPluginLoader)),
wire.Bind(new(scrobbler.PluginLoader), new(*core.NoopPluginLoader)),
plugins.GetManager,
wire.Bind(new(agents.PluginLoader), new(*plugins.Manager)),
wire.Bind(new(scrobbler.PluginLoader), new(*plugins.Manager)),
wire.Bind(new(core.Watcher), new(scanner.Watcher)),
)

View File

@@ -14,7 +14,6 @@ import (
"github.com/navidrome/navidrome/utils/singleton"
)
// TODO(PLUGINS): Implement PluginLoader with new plugin system
// PluginLoader defines an interface for loading plugins
type PluginLoader interface {
// PluginNames returns the names of all plugins that implement a particular service

View File

@@ -23,6 +23,7 @@ import (
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/model/request"
"github.com/navidrome/navidrome/plugins"
"github.com/navidrome/navidrome/utils/singleton"
)
@@ -312,7 +313,15 @@ 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 {
plugins := make(map[string]insights.PluginInfo)
// TODO(PLUGINS): Get the list from the new plugin system
return plugins
manager := plugins.GetManager()
info := manager.GetPluginInfo()
result := make(map[string]insights.PluginInfo, len(info))
for name, p := range info {
result[name] = insights.PluginInfo{
Name: p.Name,
Version: p.Version,
}
}
return result
}

View File

@@ -1,38 +0,0 @@
package core
import (
"github.com/navidrome/navidrome/core/agents"
"github.com/navidrome/navidrome/core/scrobbler"
)
// TODO(PLUGINS): Remove NoopPluginLoader when real plugin system is implemented
// NoopPluginLoader is a stub implementation of plugin loaders that does nothing.
// This is used as a placeholder until the new plugin system is implemented.
type NoopPluginLoader struct{}
// GetNoopPluginLoader returns a singleton noop plugin loader instance.
func GetNoopPluginLoader() *NoopPluginLoader {
return &NoopPluginLoader{}
}
// PluginNames returns an empty slice (no plugins available)
func (n *NoopPluginLoader) PluginNames(_ string) []string {
return nil
}
// LoadMediaAgent returns false (no plugin available)
func (n *NoopPluginLoader) LoadMediaAgent(_ string) (agents.Interface, bool) {
return nil, false
}
// LoadScrobbler returns false (no plugin available)
func (n *NoopPluginLoader) LoadScrobbler(_ string) (scrobbler.Scrobbler, bool) {
return nil, false
}
// Verify interface implementations at compile time
var (
_ agents.PluginLoader = (*NoopPluginLoader)(nil)
_ scrobbler.PluginLoader = (*NoopPluginLoader)(nil)
)

View File

@@ -44,7 +44,6 @@ type PlayTracker interface {
Submit(ctx context.Context, submissions []Submission) error
}
// TODO(PLUGINS): Implement PluginLoader with new plugin system
// PluginLoader is a minimal interface for plugin manager usage in PlayTracker
// (avoids import cycles)
type PluginLoader interface {
@@ -130,7 +129,6 @@ func pluginNamesMatchScrobblers(pluginNames []string, scrobblers map[string]Scro
return true
}
// TODO(PLUGINS): Implement refreshPluginScrobblers with new plugin system
// refreshPluginScrobblers updates the pluginScrobblers map to match the current set of plugin scrobblers
func (p *playTracker) refreshPluginScrobblers() {
p.mu.Lock()

10
go.mod
View File

@@ -21,6 +21,7 @@ require (
github.com/djherbis/stream v1.4.0
github.com/djherbis/times v1.6.0
github.com/dustin/go-humanize v1.0.1
github.com/extism/go-sdk v1.7.1
github.com/fatih/structs v1.1.0
github.com/go-chi/chi/v5 v5.2.3
github.com/go-chi/cors v1.2.2
@@ -31,12 +32,10 @@ require (
github.com/google/go-pipeline v0.0.0-20230411140531-6cbedfc1d3fc
github.com/google/uuid v1.6.0
github.com/google/wire v0.7.0
github.com/gorilla/websocket v1.5.3
github.com/hashicorp/go-multierror v1.1.1
github.com/jellydator/ttlcache/v3 v3.4.0
github.com/kardianos/service v1.2.4
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51
github.com/knqyf263/go-plugin v0.9.0
github.com/kr/pretty v0.3.1
github.com/lestrrat-go/jwx/v2 v2.1.6
github.com/maruel/natural v1.3.0
@@ -69,7 +68,6 @@ require (
golang.org/x/term v0.38.0
golang.org/x/text v0.32.0
golang.org/x/time v0.14.0
google.golang.org/protobuf v1.36.11
gopkg.in/yaml.v3 v3.0.1
)
@@ -84,10 +82,12 @@ require (
github.com/creack/pty v1.1.11 // indirect
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0 // indirect
github.com/dylibso/observe-sdk/go v0.0.0-20240819160327-2d926c5d788a // indirect
github.com/fsnotify/fsnotify v1.9.0 // indirect
github.com/go-logr/logr v1.4.3 // indirect
github.com/go-task/slim-sprig/v3 v3.0.0 // indirect
github.com/go-viper/mapstructure/v2 v2.4.0 // indirect
github.com/gobwas/glob v0.2.3 // indirect
github.com/goccy/go-json v0.10.5 // indirect
github.com/goccy/go-yaml v1.18.0 // indirect
github.com/google/go-cmp v0.7.0 // indirect
@@ -95,6 +95,7 @@ require (
github.com/google/subcommands v1.2.0 // indirect
github.com/gorilla/css v1.0.1 // indirect
github.com/hashicorp/errwrap v1.1.0 // indirect
github.com/ianlancetaylor/demangle v0.0.0-20250417193237-f615e6bd150b // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
github.com/kr/text v0.2.0 // indirect
@@ -125,7 +126,9 @@ require (
github.com/spf13/pflag v1.0.10 // indirect
github.com/stretchr/objx v0.5.2 // indirect
github.com/subosito/gotenv v1.6.0 // indirect
github.com/tetratelabs/wabin v0.0.0-20230304001439-f6f874872834 // indirect
github.com/zeebo/xxh3 v1.0.2 // indirect
go.opentelemetry.io/proto/otlp v1.3.1 // indirect
go.uber.org/multierr v1.11.0 // indirect
go.yaml.in/yaml/v2 v2.4.2 // indirect
go.yaml.in/yaml/v3 v3.0.4 // indirect
@@ -133,6 +136,7 @@ require (
golang.org/x/mod v0.31.0 // indirect
golang.org/x/telemetry v0.0.0-20251203150158-8fff8a5912fc // indirect
golang.org/x/tools v0.40.0 // indirect
google.golang.org/protobuf v1.36.11 // indirect
gopkg.in/ini.v1 v1.67.0 // indirect
gopkg.in/natefinch/npipe.v2 v2.0.0-20160621034901-c1b8fa8bdcce // indirect
)

16
go.sum
View File

@@ -55,6 +55,10 @@ github.com/djherbis/times v1.6.0 h1:w2ctJ92J8fBvWPxugmXIv7Nz7Q3iDMKNx9v5ocVH20c=
github.com/djherbis/times v1.6.0/go.mod h1:gOHeRAz2h+VJNZ5Gmc/o7iD9k4wW7NMVqieYCY99oc0=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/dylibso/observe-sdk/go v0.0.0-20240819160327-2d926c5d788a h1:UwSIFv5g5lIvbGgtf3tVwC7Ky9rmMFBp0RMs+6f6YqE=
github.com/dylibso/observe-sdk/go v0.0.0-20240819160327-2d926c5d788a/go.mod h1:C8DzXehI4zAbrdlbtOByKX6pfivJTBiV9Jjqv56Yd9Q=
github.com/extism/go-sdk v1.7.1 h1:lWJos6uY+tRFdlIHR+SJjwFDApY7OypS/2nMhiVQ9Sw=
github.com/extism/go-sdk v1.7.1/go.mod h1:IT+Xdg5AZM9hVtpFUA+uZCJMge/hbvshl8bwzLtFyKA=
github.com/fatih/structs v1.1.0 h1:Q7juDM0QtcnhCpeyLGQKyg4TOIghuNXrkL32pHAUMxo=
github.com/fatih/structs v1.1.0/go.mod h1:9NiDSp5zOcgEDl+j00MP/WkGVPOlPRLejGD8Ga6PJ7M=
github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
@@ -87,6 +91,8 @@ github.com/go-viper/encoding/ini v0.1.1 h1:MVWY7B2XNw7lnOqHutGRc97bF3rP7omOdgjdM
github.com/go-viper/encoding/ini v0.1.1/go.mod h1:Pfi4M2V1eAGJVZ5q6FrkHPhtHED2YgLlXhvgMVrB+YQ=
github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs=
github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
github.com/gobwas/glob v0.2.3 h1:A4xDbljILXROh+kObIiy5kIaPYD8e96x1tgBhUI5J+Y=
github.com/gobwas/glob v0.2.3/go.mod h1:d3Ez4x06l9bZtSvzIay5+Yzi0fmZzPgnTbPcKjJAkT8=
github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
github.com/goccy/go-yaml v1.18.0 h1:8W7wMFS12Pcas7KU+VVkaiCng+kG8QiFeFwzFb+rwuw=
@@ -111,13 +117,13 @@ github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1 h1:EGx4pi6eqNxGa
github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY=
github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8=
github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0=
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4=
github.com/hashicorp/errwrap v1.1.0 h1:OxrOeh75EUXMY8TBjag2fzXGZ40LB6IKw45YeGUDY2I=
github.com/hashicorp/errwrap v1.1.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4=
github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+lD48awMYo=
github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM=
github.com/ianlancetaylor/demangle v0.0.0-20250417193237-f615e6bd150b h1:ogbOPx86mIhFy764gGkqnkFC8m5PJA7sPzlk9ppLVQA=
github.com/ianlancetaylor/demangle v0.0.0-20250417193237-f615e6bd150b/go.mod h1:gx7rwoVhcfuVKG5uya9Hs3Sxj7EIvldVofAWIUtGouw=
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
github.com/jellydator/ttlcache/v3 v3.4.0 h1:YS4P125qQS0tNhtL6aeYkheEaB/m8HCqdMMP4mnWdTY=
@@ -134,8 +140,6 @@ github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zt
github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ=
github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
github.com/knqyf263/go-plugin v0.9.0 h1:CQs2+lOPIlkZVtcb835ZYDEoyyWJWLbSTWeCs0EwTwI=
github.com/knqyf263/go-plugin v0.9.0/go.mod h1:2z5lCO1/pez6qGo8CvCxSlBFSEat4MEp1DrnA+f7w8Q=
github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ=
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
@@ -265,6 +269,8 @@ github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8=
github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU=
github.com/tetratelabs/wabin v0.0.0-20230304001439-f6f874872834 h1:ZF+QBjOI+tILZjBaFj3HgFonKXUcwgJ4djLb6i42S3Q=
github.com/tetratelabs/wabin v0.0.0-20230304001439-f6f874872834/go.mod h1:m9ymHTgNSEjuxvw8E7WWe4Pl4hZQHXONY8wE6dMLaRk=
github.com/tetratelabs/wazero v1.11.0 h1:+gKemEuKCTevU4d7ZTzlsvgd1uaToIDtlQlmNbwqYhA=
github.com/tetratelabs/wazero v1.11.0/go.mod h1:eV28rsN8Q+xwjogd7f4/Pp4xFxO7uOGbLcD/LzB1wiU=
github.com/tidwall/gjson v1.18.0 h1:FIDeeyB800efLX89e5a8Y0BNH+LOngJyGrIWxG2FKQY=
@@ -284,6 +290,8 @@ github.com/zeebo/assert v1.3.0 h1:g7C04CbJuIDKNPFHmsk4hwZDO5O+kntRxzaUoNXj+IQ=
github.com/zeebo/assert v1.3.0/go.mod h1:Pq9JiuJQpG8JLJdtkwrJESF0Foym2/D9XMU5ciN/wJ0=
github.com/zeebo/xxh3 v1.0.2 h1:xZmwmqxHZA8AI603jOQ0tMqmBr9lPeFwGg6d+xy9DC0=
github.com/zeebo/xxh3 v1.0.2/go.mod h1:5NWz9Sef7zIDm2JHfFlcQvNekmcEl9ekUZQQKCYaDcA=
go.opentelemetry.io/proto/otlp v1.3.1 h1:TrMUixzpM0yuc/znrFTP9MMRh8trP93mkCiDVeXrui0=
go.opentelemetry.io/proto/otlp v1.3.1/go.mod h1:0X1WI4de4ZsLrrJNLAQbFeLCm3T7yBkR0XqQ7niQU+8=
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0=

194
plugins/README.md Normal file
View File

@@ -0,0 +1,194 @@
# Navidrome Plugin System
Navidrome supports WebAssembly (Wasm) plugins for extending functionality. Plugins are loaded from the configured plugins folder and can provide additional metadata agents for fetching artist/album information.
## Configuration
Enable plugins in your `navidrome.toml`:
```toml
[Plugins]
Enabled = true
Folder = "/path/to/plugins" # Default: DataFolder/plugins
# Plugin-specific configuration (passed to plugins via Extism Config)
[PluginConfig.my-plugin]
api_key = "your-api-key"
custom_option = "value"
```
## Plugin Structure
A Navidrome plugin is a WebAssembly (`.wasm`) file that:
1. **Exports `nd_manifest`**: Returns a JSON manifest describing the plugin
2. **Exports capability functions**: Implements the functions for its declared capabilities
### Plugin Naming
Plugins are identified by their **filename** (without `.wasm` extension), not the manifest `name` field. This allows:
- Users to resolve name conflicts by renaming files
- Multiple instances of the same plugin with different names/configs
- Simple, predictable naming
Example: `my-musicbrainz.wasm` → plugin name is `my-musicbrainz`
### Plugin Manifest
Plugins must export an `nd_manifest` function that returns JSON:
```json
{
"name": "My Plugin",
"author": "Author Name",
"version": "1.0.0",
"description": "Plugin description",
"website": "https://example.com",
"capabilities": ["MetadataAgent"],
"permissions": {
"http": {
"reason": "Fetch metadata from external API",
"allowedUrls": {
"https://api.example.com/*": ["GET"]
}
}
}
}
```
## Capabilities
### MetadataAgent
Provides artist and album metadata. Implement one or more of these functions:
| Function | Input | Output | Description |
|----------|-------|--------|-------------|
| `nd_get_artist_mbid` | `{id, name}` | `{mbid}` | Get MusicBrainz ID |
| `nd_get_artist_url` | `{id, name, mbid?}` | `{url}` | Get artist URL |
| `nd_get_artist_biography` | `{id, name, mbid?}` | `{biography}` | Get artist biography |
| `nd_get_similar_artists` | `{id, name, mbid?, limit}` | `{artists: [{name, mbid?}]}` | Get similar artists |
| `nd_get_artist_images` | `{id, name, mbid?}` | `{images: [{url, size}]}` | Get artist images |
| `nd_get_artist_top_songs` | `{id, name, mbid?, count}` | `{songs: [{name, mbid?}]}` | Get top songs |
| `nd_get_album_info` | `{name, artist, mbid?}` | `{name, mbid, description, url}` | Get album info |
| `nd_get_album_images` | `{name, artist, mbid?}` | `{images: [{url, size}]}` | Get album images |
## Developing Plugins
Plugins can be written in any language that compiles to WebAssembly. We recommend using the [Extism PDK](https://extism.org/docs/category/write-a-plug-in) for your language.
### Go Example
```go
package main
import (
"encoding/json"
"github.com/extism/go-pdk"
)
type Manifest struct {
Name string `json:"name"`
Author string `json:"author"`
Version string `json:"version"`
Capabilities []string `json:"capabilities"`
}
//go:wasmexport nd_manifest
func ndManifest() int32 {
manifest := Manifest{
Name: "My Plugin",
Author: "Me",
Version: "1.0.0",
Capabilities: []string{"MetadataAgent"},
}
out, _ := json.Marshal(manifest)
pdk.Output(out)
return 0
}
type ArtistInput struct {
ID string `json:"id"`
Name string `json:"name"`
}
type BiographyOutput struct {
Biography string `json:"biography"`
}
//go:wasmexport nd_get_artist_biography
func ndGetArtistBiography() int32 {
var input ArtistInput
if err := pdk.InputJSON(&input); err != nil {
pdk.SetError(err)
return 1
}
// Fetch biography from your data source...
output := BiographyOutput{Biography: "Artist biography..."}
if err := pdk.OutputJSON(output); err != nil {
pdk.SetError(err)
return 1
}
return 0
}
func main() {}
```
Build with TinyGo:
```bash
tinygo build -o my-plugin.wasm -target wasip1 -buildmode=c-shared ./main.go
```
### Using HTTP
Plugins can make HTTP requests using the Extism PDK. The host controls which URLs are allowed via the `permissions.http.allowedUrls` manifest field.
```go
//go:wasmexport nd_get_artist_biography
func ndGetArtistBiography() int32 {
var input ArtistInput
pdk.InputJSON(&input)
req := pdk.NewHTTPRequest(pdk.MethodGet,
"https://api.example.com/artist/" + input.Name)
resp := req.Send()
// Process response...
pdk.Output(resp.Body())
return 0
}
```
### Using Configuration
Plugins can read configuration values passed from `navidrome.toml`:
```go
apiKey, ok := pdk.GetConfig("api_key")
if !ok {
pdk.SetErrorString("api_key configuration is required")
return 1
}
```
## Security
Plugins run in a secure WebAssembly sandbox with these restrictions:
1. **URL Allowlisting**: Only URLs listed in `permissions.http.allowedUrls` are accessible
2. **No File System Access**: Plugins cannot access the file system
3. **No Network Listeners**: Plugins cannot bind ports or create servers
4. **Config Isolation**: Plugins receive only their own config section
5. **Memory Limits**: Configurable via Extism
## Using Plugins with Agents
To use a plugin as a metadata agent, add it to the `Agents` configuration:
```toml
Agents = "lastfm,spotify,my-plugin" # my-plugin.wasm must be in the plugins folder
```
Plugins are tried in the order specified, just like built-in agents.

View File

@@ -0,0 +1,48 @@
# Minimal Navidrome Plugin Example
This is a minimal example demonstrating how to create a Navidrome plugin using Go and the Extism PDK.
## Building
1. Install [TinyGo](https://tinygo.org/getting-started/install/)
2. Build the plugin:
```bash
go mod tidy
tinygo build -o minimal.wasm -target wasip1 -buildmode=c-shared ./main.go
```
## Installing
Copy `minimal.wasm` to your Navidrome plugins folder (default: `<data-folder>/plugins/`).
## Configuration
Enable plugins in your `navidrome.toml`:
```toml
[Plugins]
Enabled = true
# Add the plugin to your agents list
Agents = "lastfm,spotify,minimal"
```
## What This Example Demonstrates
- Exporting the required `nd_manifest` function
- Implementing `nd_get_artist_biography` as a MetadataAgent capability
- Basic JSON input/output handling with the Extism PDK
## Extending the Example
To add more capabilities, implement additional exported functions:
- `nd_get_artist_mbid` - Get MusicBrainz ID for an artist
- `nd_get_artist_url` - Get external URL for an artist
- `nd_get_similar_artists` - Get similar artists
- `nd_get_artist_images` - Get artist images
- `nd_get_artist_top_songs` - Get top songs for an artist
- `nd_get_album_info` - Get album information
- `nd_get_album_images` - Get album images
See the full documentation in `/plugins/README.md` for input/output formats.

View File

@@ -0,0 +1,5 @@
module minimal-plugin
go 1.23
require github.com/extism/go-pdk v1.1.3

View File

@@ -0,0 +1,2 @@
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=

View File

@@ -0,0 +1,71 @@
// Minimal example Navidrome plugin demonstrating the MetadataAgent capability.
//
// Build with:
//
// tinygo build -o minimal.wasm -target wasip1 -buildmode=c-shared ./main.go
//
// Install by copying minimal.wasm to your Navidrome plugins folder.
package main
import (
"encoding/json"
"github.com/extism/go-pdk"
)
type Manifest struct {
Name string `json:"name"`
Author string `json:"author"`
Version string `json:"version"`
Description string `json:"description"`
Capabilities []string `json:"capabilities"`
}
type ArtistInput struct {
ID string `json:"id"`
Name string `json:"name"`
MBID string `json:"mbid,omitempty"`
}
type BiographyOutput struct {
Biography string `json:"biography"`
}
//go:wasmexport nd_manifest
func ndManifest() int32 {
manifest := Manifest{
Name: "Minimal Example",
Author: "Navidrome",
Version: "1.0.0",
Description: "A minimal example plugin",
Capabilities: []string{"MetadataAgent"},
}
out, err := json.Marshal(manifest)
if err != nil {
pdk.SetError(err)
return 1
}
pdk.Output(out)
return 0
}
//go:wasmexport nd_get_artist_biography
func ndGetArtistBiography() int32 {
var input ArtistInput
if err := pdk.InputJSON(&input); err != nil {
pdk.SetError(err)
return 1
}
output := BiographyOutput{
Biography: "This is a placeholder biography for " + input.Name + ".",
}
if err := pdk.OutputJSON(output); err != nil {
pdk.SetError(err)
return 1
}
return 0
}
func main() {}

344
plugins/manager.go Normal file
View File

@@ -0,0 +1,344 @@
package plugins
import (
"context"
"os"
"path/filepath"
"strings"
"sync"
"time"
extism "github.com/extism/go-sdk"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/core/agents"
"github.com/navidrome/navidrome/core/scrobbler"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/utils/singleton"
"github.com/tetratelabs/wazero"
)
const (
// ManifestFunction is the name of the function that plugins must export
// to provide their manifest.
ManifestFunction = "nd_manifest"
// DefaultTimeout is the default timeout for plugin function calls
DefaultTimeout = 30 * time.Second
)
// Manager manages loading and lifecycle of WebAssembly plugins.
// It implements both agents.PluginLoader and scrobbler.PluginLoader interfaces.
type Manager struct {
mu sync.RWMutex
plugins map[string]*pluginInstance
ctx context.Context
cancel context.CancelFunc
cache wazero.CompilationCache
}
// pluginInstance represents a loaded plugin
type pluginInstance struct {
name string // Plugin name (from filename)
path string // Path to the wasm file
manifest *Manifest
compiled *extism.CompiledPlugin
}
// GetManager returns a singleton instance of the plugin manager.
// The manager is not started automatically; call Start() to begin loading plugins.
func GetManager() *Manager {
return singleton.GetInstance(func() *Manager {
return &Manager{
plugins: make(map[string]*pluginInstance),
}
})
}
// Start initializes the plugin manager and loads plugins from the configured folder.
// It should be called once during application startup when plugins are enabled.
func (m *Manager) Start(ctx context.Context) error {
if !conf.Server.Plugins.Enabled {
log.Debug("Plugin system is disabled")
return nil
}
m.ctx, m.cancel = context.WithCancel(ctx)
// Initialize wazero compilation cache for better performance
m.cache = wazero.NewCompilationCache()
folder := m.pluginsFolder()
if folder == "" {
log.Debug("No plugins folder configured")
return nil
}
// Create plugins folder if it doesn't exist
if err := os.MkdirAll(folder, 0755); err != nil {
log.Error("Failed to create plugins folder", "folder", folder, err)
return err
}
log.Info(ctx, "Starting plugin manager", "folder", folder)
// Discover and load plugins
if err := m.discoverPlugins(folder); err != nil {
log.Error(ctx, "Error discovering plugins", err)
return err
}
return nil
}
// Stop shuts down the plugin manager and releases all resources.
func (m *Manager) Stop() error {
if m.cancel != nil {
m.cancel()
}
m.mu.Lock()
defer m.mu.Unlock()
// Close all plugins
for name, instance := range m.plugins {
if instance.compiled != nil {
if err := instance.compiled.Close(context.Background()); err != nil {
log.Error("Error closing plugin", "plugin", name, err)
}
}
}
m.plugins = make(map[string]*pluginInstance)
// Close compilation cache
if m.cache != nil {
if err := m.cache.Close(context.Background()); err != nil {
log.Error("Error closing wazero cache", err)
}
}
return nil
}
// PluginNames returns the names of all plugins that implement a particular capability.
// This is used by both agents and scrobbler systems to discover available plugins.
func (m *Manager) PluginNames(capability string) []string {
m.mu.RLock()
defer m.mu.RUnlock()
var names []string
cap := Capability(capability)
for name, instance := range m.plugins {
if instance.manifest.HasCapability(cap) {
names = append(names, name)
}
}
return names
}
// LoadMediaAgent loads and returns a media agent plugin by name.
// Returns false if the plugin is not found or doesn't have the MetadataAgent capability.
func (m *Manager) LoadMediaAgent(name string) (agents.Interface, bool) {
m.mu.RLock()
instance, ok := m.plugins[name]
m.mu.RUnlock()
if !ok || !instance.manifest.HasCapability(CapabilityMetadataAgent) {
return nil, false
}
// Create a new plugin instance for this agent
agent, err := m.createMetadataAgent(instance)
if err != nil {
log.Error("Failed to create metadata agent from plugin", "plugin", name, err)
return nil, false
}
return agent, true
}
// LoadScrobbler loads and returns a scrobbler plugin by name.
// Returns false if the plugin is not found or doesn't have the Scrobbler capability.
func (m *Manager) LoadScrobbler(name string) (scrobbler.Scrobbler, bool) {
// Scrobbler capability is not yet implemented
return nil, false
}
// PluginInfo contains basic information about a plugin for metrics/insights.
type PluginInfo struct {
Name string
Version string
}
// GetPluginInfo returns information about all loaded plugins.
// This is used by the metrics/insights system.
func (m *Manager) GetPluginInfo() map[string]PluginInfo {
m.mu.RLock()
defer m.mu.RUnlock()
info := make(map[string]PluginInfo, len(m.plugins))
for name, instance := range m.plugins {
info[name] = PluginInfo{
Name: instance.manifest.Name,
Version: instance.manifest.Version,
}
}
return info
}
// pluginsFolder returns the configured plugins folder path
func (m *Manager) pluginsFolder() string {
if conf.Server.Plugins.Folder != "" {
return conf.Server.Plugins.Folder
}
// Default to DataFolder/plugins
if conf.Server.DataFolder != "" {
return filepath.Join(conf.Server.DataFolder, "plugins")
}
return ""
}
// discoverPlugins scans the plugins folder and loads all .wasm files
func (m *Manager) discoverPlugins(folder string) error {
entries, err := os.ReadDir(folder)
if err != nil {
if os.IsNotExist(err) {
log.Debug("Plugins folder does not exist", "folder", folder)
return nil
}
return err
}
for _, entry := range entries {
if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".wasm") {
continue
}
wasmPath := filepath.Join(folder, entry.Name())
pluginName := strings.TrimSuffix(entry.Name(), ".wasm")
if err := m.loadPlugin(pluginName, wasmPath); err != nil {
log.Error(m.ctx, "Failed to load plugin", "plugin", pluginName, "path", wasmPath, err)
continue
}
log.Info(m.ctx, "Loaded plugin", "plugin", pluginName, "manifest", m.plugins[pluginName].manifest.Name)
}
return nil
}
// loadPlugin loads a single plugin from a wasm file
func (m *Manager) loadPlugin(name, wasmPath string) error {
// Read wasm file
wasmBytes, err := os.ReadFile(wasmPath)
if err != nil {
return err
}
// Get plugin-specific config from conf.Server.PluginConfig
pluginConfig := m.getPluginConfig(name)
// Create Extism manifest for this plugin
// Note: We create a temporary plugin first to get the manifest,
// then we'll create the final one with proper AllowedHosts
tempManifest := extism.Manifest{
Wasm: []extism.Wasm{
extism.WasmData{
Data: wasmBytes,
Name: "main",
},
},
Config: pluginConfig,
}
tempConfig := extism.PluginConfig{
EnableWasi: true,
RuntimeConfig: wazero.NewRuntimeConfig().WithCompilationCache(m.cache),
}
// Create temporary plugin to read manifest
tempPlugin, err := extism.NewPlugin(m.ctx, tempManifest, tempConfig, nil)
if err != nil {
return err
}
defer tempPlugin.Close(m.ctx)
// Call nd_manifest to get plugin manifest
exit, manifestBytes, err := tempPlugin.Call(ManifestFunction, nil)
if err != nil {
return err
}
if exit != 0 {
return err
}
// Parse and validate manifest
manifest, err := ParseManifest(manifestBytes)
if err != nil {
return err
}
if err := manifest.Validate(); err != nil {
return err
}
// Now create the final compiled plugin with proper AllowedHosts
finalManifest := extism.Manifest{
Wasm: []extism.Wasm{
extism.WasmData{
Data: wasmBytes,
Name: "main",
},
},
Config: pluginConfig,
AllowedHosts: manifest.AllowedHosts(),
Timeout: uint64(DefaultTimeout.Milliseconds()),
}
finalConfig := extism.PluginConfig{
EnableWasi: true,
RuntimeConfig: wazero.NewRuntimeConfig().WithCompilationCache(m.cache),
}
compiled, err := extism.NewCompiledPlugin(m.ctx, finalManifest, finalConfig, nil)
if err != nil {
return err
}
m.mu.Lock()
m.plugins[name] = &pluginInstance{
name: name,
path: wasmPath,
manifest: manifest,
compiled: compiled,
}
m.mu.Unlock()
return nil
}
// getPluginConfig returns the configuration for a specific plugin
func (m *Manager) getPluginConfig(name string) map[string]string {
if conf.Server.PluginConfig == nil {
return nil
}
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(),
})
if err != nil {
return nil, err
}
return NewMetadataAgent(instance.name, plugin), nil
}
// Verify interface implementations at compile time
var (
_ agents.PluginLoader = (*Manager)(nil)
_ scrobbler.PluginLoader = (*Manager)(nil)
)

182
plugins/manifest.go Normal file
View File

@@ -0,0 +1,182 @@
package plugins
import (
"encoding/json"
"errors"
"fmt"
"net/url"
"strings"
)
// Capability represents a plugin capability type
type Capability string
const (
CapabilityMetadataAgent Capability = "MetadataAgent"
// Future capabilities:
// CapabilityScrobbler Capability = "Scrobbler"
)
// Manifest represents the plugin manifest exported by the nd_manifest function.
// The manifest describes the plugin's metadata, capabilities, and permissions.
type Manifest struct {
Name string `json:"name"`
Author string `json:"author"`
Version string `json:"version"`
Description string `json:"description,omitempty"`
Website string `json:"website,omitempty"`
Capabilities []Capability `json:"capabilities"`
Permissions Permissions `json:"permissions,omitempty"`
}
// Permissions defines the plugin's required permissions
type Permissions struct {
HTTP *HTTPPermission `json:"http,omitempty"`
Config *ConfigPermission `json:"config,omitempty"`
}
// HTTPPermission defines HTTP access permissions for a plugin
type HTTPPermission struct {
Reason string `json:"reason,omitempty"`
AllowedURLs map[string][]string `json:"allowedUrls,omitempty"`
}
// ConfigPermission defines config access permissions for a plugin
type ConfigPermission struct {
Reason string `json:"reason,omitempty"`
}
// Validate checks if the manifest is valid
func (m *Manifest) Validate() error {
if m.Name == "" {
return errors.New("plugin manifest: name is required")
}
if m.Author == "" {
return errors.New("plugin manifest: author is required")
}
if m.Version == "" {
return errors.New("plugin manifest: version is required")
}
if len(m.Capabilities) == 0 {
return errors.New("plugin manifest: at least one capability is required")
}
// Validate capabilities
for _, cap := range m.Capabilities {
if !isValidCapability(cap) {
return fmt.Errorf("plugin manifest: unknown capability %q", cap)
}
}
// Validate HTTP permissions if present
if m.Permissions.HTTP != nil {
if err := m.validateHTTPPermissions(); err != nil {
return err
}
}
return nil
}
// HasCapability checks if the plugin has a specific capability
func (m *Manifest) HasCapability(cap Capability) bool {
for _, c := range m.Capabilities {
if c == cap {
return true
}
}
return false
}
// AllowedHosts returns a list of allowed hosts for HTTP requests.
// This extracts hostnames from the AllowedURLs patterns.
func (m *Manifest) AllowedHosts() []string {
if m.Permissions.HTTP == nil || len(m.Permissions.HTTP.AllowedURLs) == 0 {
return nil
}
hosts := make([]string, 0, len(m.Permissions.HTTP.AllowedURLs))
for urlPattern := range m.Permissions.HTTP.AllowedURLs {
host := extractHost(urlPattern)
if host != "" {
hosts = append(hosts, host)
}
}
return hosts
}
// ParseManifest parses JSON data into a Manifest struct
func ParseManifest(data []byte) (*Manifest, error) {
var m Manifest
if err := json.Unmarshal(data, &m); err != nil {
return nil, fmt.Errorf("plugin manifest: invalid JSON: %w", err)
}
return &m, nil
}
// validateHTTPPermissions validates the HTTP permission configuration
func (m *Manifest) validateHTTPPermissions() error {
for urlPattern := range m.Permissions.HTTP.AllowedURLs {
if !isValidURLPattern(urlPattern) {
return fmt.Errorf("plugin manifest: invalid URL pattern %q", urlPattern)
}
}
return nil
}
// isValidCapability checks if a capability is known
func isValidCapability(cap Capability) bool {
switch cap {
case CapabilityMetadataAgent:
return true
default:
return false
}
}
// isValidURLPattern checks if a URL pattern is valid.
// Valid patterns are URLs that may contain wildcards (*).
// Examples:
// - https://api.example.com/*
// - https://*.example.com/api/*
// - https://example.com/v1/endpoint
func isValidURLPattern(pattern string) bool {
// Remove wildcards temporarily to validate the base URL
testURL := strings.ReplaceAll(pattern, "*", "wildcard")
u, err := url.Parse(testURL)
if err != nil {
return false
}
// Must have a scheme (http or https)
if u.Scheme != "http" && u.Scheme != "https" {
return false
}
// Must have a host
if u.Host == "" {
return false
}
return true
}
// extractHost extracts the hostname from a URL pattern.
// For patterns with wildcards in the host, it returns the pattern as-is for glob matching.
// Examples:
// - https://api.example.com/* -> api.example.com
// - https://*.example.com/api/* -> *.example.com
func extractHost(pattern string) string {
// Remove wildcards temporarily to parse the URL
testURL := strings.ReplaceAll(pattern, "*", "wildcard")
u, err := url.Parse(testURL)
if err != nil {
return ""
}
// Restore wildcards in the host
host := strings.ReplaceAll(u.Hostname(), "wildcard", "*")
return host
}

230
plugins/manifest_test.go Normal file
View File

@@ -0,0 +1,230 @@
package plugins
import (
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
var _ = Describe("Manifest", func() {
Describe("ParseManifest", func() {
It("parses a valid manifest", func() {
data := []byte(`{
"name": "Test Plugin",
"author": "Test Author",
"version": "1.0.0",
"description": "A test plugin",
"website": "https://example.com",
"capabilities": ["MetadataAgent"],
"permissions": {
"http": {
"reason": "Fetch metadata",
"allowedUrls": {
"https://api.example.com/*": ["GET"]
}
}
}
}`)
m, err := ParseManifest(data)
Expect(err).ToNot(HaveOccurred())
Expect(m.Name).To(Equal("Test Plugin"))
Expect(m.Author).To(Equal("Test Author"))
Expect(m.Version).To(Equal("1.0.0"))
Expect(m.Description).To(Equal("A test plugin"))
Expect(m.Website).To(Equal("https://example.com"))
Expect(m.Capabilities).To(ContainElement(CapabilityMetadataAgent))
Expect(m.Permissions.HTTP).ToNot(BeNil())
Expect(m.Permissions.HTTP.Reason).To(Equal("Fetch metadata"))
Expect(m.Permissions.HTTP.AllowedURLs).To(HaveKey("https://api.example.com/*"))
})
It("returns an error for invalid JSON", func() {
data := []byte(`{invalid json}`)
_, err := ParseManifest(data)
Expect(err).To(HaveOccurred())
Expect(err.Error()).To(ContainSubstring("invalid JSON"))
})
})
Describe("Validate", func() {
It("returns an error when name is missing", func() {
m := &Manifest{
Author: "Test Author",
Version: "1.0.0",
Capabilities: []Capability{CapabilityMetadataAgent},
}
err := m.Validate()
Expect(err).To(HaveOccurred())
Expect(err.Error()).To(ContainSubstring("name is required"))
})
It("returns an error when author is missing", func() {
m := &Manifest{
Name: "Test Plugin",
Version: "1.0.0",
Capabilities: []Capability{CapabilityMetadataAgent},
}
err := m.Validate()
Expect(err).To(HaveOccurred())
Expect(err.Error()).To(ContainSubstring("author is required"))
})
It("returns an error when version is missing", func() {
m := &Manifest{
Name: "Test Plugin",
Author: "Test Author",
Capabilities: []Capability{CapabilityMetadataAgent},
}
err := m.Validate()
Expect(err).To(HaveOccurred())
Expect(err.Error()).To(ContainSubstring("version is required"))
})
It("returns an error when capabilities are missing", func() {
m := &Manifest{
Name: "Test Plugin",
Author: "Test Author",
Version: "1.0.0",
}
err := m.Validate()
Expect(err).To(HaveOccurred())
Expect(err.Error()).To(ContainSubstring("at least one capability is required"))
})
It("returns an error for unknown capability", func() {
m := &Manifest{
Name: "Test Plugin",
Author: "Test Author",
Version: "1.0.0",
Capabilities: []Capability{"UnknownCapability"},
}
err := m.Validate()
Expect(err).To(HaveOccurred())
Expect(err.Error()).To(ContainSubstring("unknown capability"))
})
It("returns an error for invalid URL pattern", func() {
m := &Manifest{
Name: "Test Plugin",
Author: "Test Author",
Version: "1.0.0",
Capabilities: []Capability{CapabilityMetadataAgent},
Permissions: Permissions{
HTTP: &HTTPPermission{
AllowedURLs: map[string][]string{
"not-a-valid-url": {"GET"},
},
},
},
}
err := m.Validate()
Expect(err).To(HaveOccurred())
Expect(err.Error()).To(ContainSubstring("invalid URL pattern"))
})
It("validates a valid manifest", func() {
m := &Manifest{
Name: "Test Plugin",
Author: "Test Author",
Version: "1.0.0",
Capabilities: []Capability{CapabilityMetadataAgent},
Permissions: Permissions{
HTTP: &HTTPPermission{
AllowedURLs: map[string][]string{
"https://api.example.com/*": {"GET"},
},
},
},
}
err := m.Validate()
Expect(err).ToNot(HaveOccurred())
})
})
Describe("HasCapability", func() {
It("returns true when capability exists", func() {
m := &Manifest{
Capabilities: []Capability{CapabilityMetadataAgent},
}
Expect(m.HasCapability(CapabilityMetadataAgent)).To(BeTrue())
})
It("returns false when capability does not exist", func() {
m := &Manifest{
Capabilities: []Capability{},
}
Expect(m.HasCapability(CapabilityMetadataAgent)).To(BeFalse())
})
})
Describe("AllowedHosts", func() {
It("returns nil when no HTTP permissions", func() {
m := &Manifest{}
Expect(m.AllowedHosts()).To(BeNil())
})
It("returns nil when no allowed URLs", func() {
m := &Manifest{
Permissions: Permissions{
HTTP: &HTTPPermission{},
},
}
Expect(m.AllowedHosts()).To(BeNil())
})
It("extracts hosts from URL patterns", func() {
m := &Manifest{
Permissions: Permissions{
HTTP: &HTTPPermission{
AllowedURLs: map[string][]string{
"https://api.example.com/*": {"GET"},
"https://*.spotify.com/api/*": {"GET"},
},
},
},
}
hosts := m.AllowedHosts()
Expect(hosts).To(ContainElements("api.example.com", "*.spotify.com"))
})
})
Describe("isValidURLPattern", func() {
DescribeTable("validates URL patterns",
func(pattern string, expected bool) {
Expect(isValidURLPattern(pattern)).To(Equal(expected))
},
Entry("valid HTTPS URL", "https://api.example.com/path", true),
Entry("valid HTTP URL", "http://api.example.com/path", true),
Entry("URL with wildcard in path", "https://api.example.com/*", true),
Entry("URL with wildcard in host", "https://*.example.com/api/*", true),
Entry("missing scheme", "api.example.com/path", false),
Entry("invalid scheme", "ftp://api.example.com/path", false),
Entry("missing host", "https:///path", false),
)
})
Describe("extractHost", func() {
DescribeTable("extracts hosts from URL patterns",
func(pattern string, expected string) {
Expect(extractHost(pattern)).To(Equal(expected))
},
Entry("simple host", "https://api.example.com/path", "api.example.com"),
Entry("host with wildcard", "https://*.example.com/api/*", "*.example.com"),
Entry("host with port", "https://api.example.com:8080/path", "api.example.com"),
Entry("invalid URL", "not-a-url", ""),
)
})
})

430
plugins/metadata_agent.go Normal file
View File

@@ -0,0 +1,430 @@
package plugins
import (
"context"
"encoding/json"
extism "github.com/extism/go-sdk"
"github.com/navidrome/navidrome/core/agents"
"github.com/navidrome/navidrome/log"
)
// Export function names (snake_case as per design)
const (
FuncGetArtistMBID = "nd_get_artist_mbid"
FuncGetArtistURL = "nd_get_artist_url"
FuncGetArtistBiography = "nd_get_artist_biography"
FuncGetSimilarArtists = "nd_get_similar_artists"
FuncGetArtistImages = "nd_get_artist_images"
FuncGetArtistTopSongs = "nd_get_artist_top_songs"
FuncGetAlbumInfo = "nd_get_album_info"
FuncGetAlbumImages = "nd_get_album_images"
)
// MetadataAgent is an adapter that wraps an Extism plugin and implements
// the agents interfaces for metadata retrieval.
type MetadataAgent struct {
name string
plugin *extism.Plugin
}
// 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
func (a *MetadataAgent) AgentName() string {
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 ---
// GetArtistMBID retrieves the MusicBrainz ID for an artist
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}
inputBytes, err := json.Marshal(input)
if err != nil {
return "", 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 == "" {
return "", agents.ErrNotFound
}
return result.MBID, nil
}
// GetArtistURL retrieves the external URL for an artist
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}
inputBytes, err := json.Marshal(input)
if err != nil {
return "", 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 == "" {
return "", agents.ErrNotFound
}
return result.URL, nil
}
// GetArtistBiography retrieves the biography for an artist
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}
inputBytes, err := json.Marshal(input)
if err != nil {
return "", 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 == "" {
return "", agents.ErrNotFound
}
return result.Biography, nil
}
// GetSimilarArtists retrieves similar artists
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}
inputBytes, err := json.Marshal(input)
if err != nil {
return nil, 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 {
return nil, agents.ErrNotFound
}
artists := make([]agents.Artist, len(result.Artists))
for i, a := range result.Artists {
artists[i] = agents.Artist{Name: a.Name, MBID: a.MBID}
}
return artists, nil
}
// GetArtistImages retrieves images for an artist
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}
inputBytes, err := json.Marshal(input)
if err != nil {
return nil, 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 {
return nil, agents.ErrNotFound
}
images := make([]agents.ExternalImage, len(result.Images))
for i, img := range result.Images {
images[i] = agents.ExternalImage{URL: img.URL, Size: img.Size}
}
return images, nil
}
// GetArtistTopSongs retrieves top songs for an artist
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}
inputBytes, err := json.Marshal(input)
if err != nil {
return nil, 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 {
return nil, agents.ErrNotFound
}
songs := make([]agents.Song, len(result.Songs))
for i, s := range result.Songs {
songs[i] = agents.Song{Name: s.Name, MBID: s.MBID}
}
return songs, nil
}
// GetAlbumInfo retrieves album information
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}
inputBytes, err := json.Marshal(input)
if err != nil {
return nil, 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{
Name: result.Name,
MBID: result.MBID,
Description: result.Description,
URL: result.URL,
}, nil
}
// GetAlbumImages retrieves images for an album
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}
inputBytes, err := json.Marshal(input)
if err != nil {
return nil, 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 {
return nil, agents.ErrNotFound
}
images := make([]agents.ExternalImage, len(result.Images))
for i, img := range result.Images {
images[i] = agents.ExternalImage{URL: img.URL, Size: img.Size}
}
return images, nil
}
// Verify interface implementations at compile time
var (
_ agents.Interface = (*MetadataAgent)(nil)
_ agents.ArtistMBIDRetriever = (*MetadataAgent)(nil)
_ agents.ArtistURLRetriever = (*MetadataAgent)(nil)
_ agents.ArtistBiographyRetriever = (*MetadataAgent)(nil)
_ agents.ArtistSimilarRetriever = (*MetadataAgent)(nil)
_ agents.ArtistImageRetriever = (*MetadataAgent)(nil)
_ agents.ArtistTopSongsRetriever = (*MetadataAgent)(nil)
_ agents.AlbumInfoRetriever = (*MetadataAgent)(nil)
_ agents.AlbumImageRetriever = (*MetadataAgent)(nil)
)

View File

@@ -0,0 +1,148 @@
package plugins
import (
"context"
"path/filepath"
"runtime"
extism "github.com/extism/go-sdk"
"github.com/navidrome/navidrome/core/agents"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
var _ = Describe("MetadataAgent", func() {
var (
agent *MetadataAgent
ctx context.Context
)
BeforeEach(func() {
ctx = context.Background()
// Load the test plugin
_, currentFile, _, ok := runtime.Caller(0)
Expect(ok).To(BeTrue())
testdataDir := filepath.Join(filepath.Dir(currentFile), "testdata")
wasmPath := filepath.Join(testdataDir, "test-plugin.wasm")
manifest := extism.Manifest{
Wasm: []extism.Wasm{
extism.WasmFile{Path: wasmPath},
},
AllowedHosts: []string{"test.example.com"},
}
plugin, err := extism.NewPlugin(ctx, manifest, extism.PluginConfig{
EnableWasi: true,
}, nil)
Expect(err).ToNot(HaveOccurred())
agent = NewMetadataAgent("test-plugin", plugin)
})
AfterEach(func() {
if agent != nil {
_ = agent.Close()
}
})
Describe("AgentName", func() {
It("returns the plugin name", func() {
Expect(agent.AgentName()).To(Equal("test-plugin"))
})
})
Describe("GetArtistMBID", func() {
It("returns the MBID from the plugin", func() {
mbid, err := agent.GetArtistMBID(ctx, "artist-1", "The Beatles")
Expect(err).ToNot(HaveOccurred())
Expect(mbid).To(Equal("test-mbid-The Beatles"))
})
})
Describe("GetArtistURL", func() {
It("returns the URL from the plugin", func() {
url, err := agent.GetArtistURL(ctx, "artist-1", "The Beatles", "some-mbid")
Expect(err).ToNot(HaveOccurred())
Expect(url).To(Equal("https://test.example.com/artist/The Beatles"))
})
})
Describe("GetArtistBiography", func() {
It("returns the biography from the plugin", func() {
bio, err := agent.GetArtistBiography(ctx, "artist-1", "The Beatles", "some-mbid")
Expect(err).ToNot(HaveOccurred())
Expect(bio).To(Equal("Biography for The Beatles"))
})
})
Describe("GetArtistImages", func() {
It("returns images from the plugin", func() {
images, err := agent.GetArtistImages(ctx, "artist-1", "The Beatles", "some-mbid")
Expect(err).ToNot(HaveOccurred())
Expect(images).To(HaveLen(2))
Expect(images[0].URL).To(Equal("https://test.example.com/images/The Beatles/large.jpg"))
Expect(images[0].Size).To(Equal(500))
Expect(images[1].URL).To(Equal("https://test.example.com/images/The Beatles/small.jpg"))
Expect(images[1].Size).To(Equal(100))
})
})
Describe("GetSimilarArtists", func() {
It("returns similar artists from the plugin", func() {
artists, err := agent.GetSimilarArtists(ctx, "artist-1", "The Beatles", "some-mbid", 3)
Expect(err).ToNot(HaveOccurred())
Expect(artists).To(HaveLen(3))
Expect(artists[0].Name).To(Equal("The Beatles Similar A"))
Expect(artists[1].Name).To(Equal("The Beatles Similar B"))
Expect(artists[2].Name).To(Equal("The Beatles Similar C"))
})
})
Describe("GetArtistTopSongs", func() {
It("returns top songs from the plugin", func() {
songs, err := agent.GetArtistTopSongs(ctx, "artist-1", "The Beatles", "some-mbid", 3)
Expect(err).ToNot(HaveOccurred())
Expect(songs).To(HaveLen(3))
Expect(songs[0].Name).To(Equal("The Beatles Song 1"))
Expect(songs[1].Name).To(Equal("The Beatles Song 2"))
Expect(songs[2].Name).To(Equal("The Beatles Song 3"))
})
})
Describe("GetAlbumInfo", func() {
It("returns album info from the plugin", func() {
info, err := agent.GetAlbumInfo(ctx, "Abbey Road", "The Beatles", "album-mbid")
Expect(err).ToNot(HaveOccurred())
Expect(info.Name).To(Equal("Abbey Road"))
Expect(info.MBID).To(Equal("test-album-mbid-Abbey Road"))
Expect(info.Description).To(Equal("Description for Abbey Road by The Beatles"))
Expect(info.URL).To(Equal("https://test.example.com/album/Abbey Road"))
})
})
Describe("GetAlbumImages", func() {
It("returns album images from the plugin", func() {
images, err := agent.GetAlbumImages(ctx, "Abbey Road", "The Beatles", "album-mbid")
Expect(err).ToNot(HaveOccurred())
Expect(images).To(HaveLen(1))
Expect(images[0].URL).To(Equal("https://test.example.com/albums/Abbey Road/cover.jpg"))
Expect(images[0].Size).To(Equal(500))
})
})
Describe("interface assertions", func() {
It("implements all required interfaces", func() {
var _ agents.Interface = agent
var _ agents.ArtistMBIDRetriever = agent
var _ agents.ArtistURLRetriever = agent
var _ agents.ArtistBiographyRetriever = agent
var _ agents.ArtistSimilarRetriever = agent
var _ agents.ArtistImageRetriever = agent
var _ agents.ArtistTopSongsRetriever = agent
var _ agents.AlbumInfoRetriever = agent
var _ agents.AlbumImageRetriever = agent
})
})
})

View File

@@ -0,0 +1,13 @@
package plugins
import (
"testing"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
func TestPlugins(t *testing.T) {
RegisterFailHandler(Fail)
RunSpecs(t, "Plugins Suite")
}

5
plugins/testdata/testplugin/go.mod vendored Normal file
View File

@@ -0,0 +1,5 @@
module test-plugin
go 1.23
require github.com/extism/go-pdk v1.1.3

2
plugins/testdata/testplugin/go.sum vendored Normal file
View File

@@ -0,0 +1,2 @@
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=

285
plugins/testdata/testplugin/main.go vendored Normal file
View File

@@ -0,0 +1,285 @@
// Test plugin for Navidrome plugin system integration tests.
// Build with: tinygo build -o ../test-plugin.wasm -target wasip1 -buildmode=c-shared ./main.go
package main
import (
"encoding/json"
"github.com/extism/go-pdk"
)
type Manifest struct {
Name string `json:"name"`
Author string `json:"author"`
Version string `json:"version"`
Description string `json:"description"`
Capabilities []string `json:"capabilities"`
Permissions *Permissions `json:"permissions,omitempty"`
}
type Permissions struct {
HTTP *HTTPPermission `json:"http,omitempty"`
}
type HTTPPermission struct {
Reason string `json:"reason,omitempty"`
AllowedURLs map[string][]string `json:"allowedUrls,omitempty"`
}
type ArtistInput struct {
ID string `json:"id"`
Name string `json:"name"`
MBID string `json:"mbid,omitempty"`
}
type ArtistInputWithLimit struct {
ID string `json:"id"`
Name string `json:"name"`
MBID string `json:"mbid,omitempty"`
Limit int `json:"limit,omitempty"`
}
type ArtistInputWithCount struct {
ID string `json:"id"`
Name string `json:"name"`
MBID string `json:"mbid,omitempty"`
Count int `json:"count,omitempty"`
}
type AlbumInput struct {
Name string `json:"name"`
Artist string `json:"artist"`
MBID string `json:"mbid,omitempty"`
}
type MBIDOutput struct {
MBID string `json:"mbid"`
}
type URLOutput struct {
URL string `json:"url"`
}
type BiographyOutput struct {
Biography string `json:"biography"`
}
type ArtistImage struct {
URL string `json:"url"`
Size int `json:"size"`
}
type ImagesOutput struct {
Images []ArtistImage `json:"images"`
}
type SimilarArtist struct {
Name string `json:"name"`
MBID string `json:"mbid,omitempty"`
}
type SimilarArtistsOutput struct {
Artists []SimilarArtist `json:"artists"`
}
type TopSong struct {
Name string `json:"name"`
MBID string `json:"mbid,omitempty"`
}
type TopSongsOutput struct {
Songs []TopSong `json:"songs"`
}
type AlbumInfoOutput struct {
Name string `json:"name"`
MBID string `json:"mbid,omitempty"`
Description string `json:"description,omitempty"`
URL string `json:"url,omitempty"`
}
type AlbumImagesOutput struct {
Images []ArtistImage `json:"images"`
}
//go:wasmexport nd_manifest
func ndManifest() int32 {
manifest := Manifest{
Name: "Test Plugin",
Author: "Navidrome Test",
Version: "1.0.0",
Description: "A test plugin for integration testing",
Capabilities: []string{"MetadataAgent"},
Permissions: &Permissions{
HTTP: &HTTPPermission{
Reason: "Test HTTP access",
AllowedURLs: map[string][]string{
"https://test.example.com/*": {"GET"},
},
},
},
}
out, err := json.Marshal(manifest)
if err != nil {
pdk.SetError(err)
return 1
}
pdk.Output(out)
return 0
}
//go:wasmexport nd_get_artist_mbid
func ndGetArtistMBID() int32 {
var input ArtistInput
if err := pdk.InputJSON(&input); err != nil {
pdk.SetError(err)
return 1
}
output := MBIDOutput{MBID: "test-mbid-" + input.Name}
if err := pdk.OutputJSON(output); err != nil {
pdk.SetError(err)
return 1
}
return 0
}
//go:wasmexport nd_get_artist_url
func ndGetArtistURL() int32 {
var input ArtistInput
if err := pdk.InputJSON(&input); err != nil {
pdk.SetError(err)
return 1
}
output := URLOutput{URL: "https://test.example.com/artist/" + input.Name}
if err := pdk.OutputJSON(output); err != nil {
pdk.SetError(err)
return 1
}
return 0
}
//go:wasmexport nd_get_artist_biography
func ndGetArtistBiography() int32 {
var input ArtistInput
if err := pdk.InputJSON(&input); err != nil {
pdk.SetError(err)
return 1
}
output := BiographyOutput{Biography: "Biography for " + input.Name}
if err := pdk.OutputJSON(output); err != nil {
pdk.SetError(err)
return 1
}
return 0
}
//go:wasmexport nd_get_artist_images
func ndGetArtistImages() int32 {
var input ArtistInput
if err := pdk.InputJSON(&input); err != nil {
pdk.SetError(err)
return 1
}
output := ImagesOutput{
Images: []ArtistImage{
{URL: "https://test.example.com/images/" + input.Name + "/large.jpg", Size: 500},
{URL: "https://test.example.com/images/" + input.Name + "/small.jpg", Size: 100},
},
}
if err := pdk.OutputJSON(output); err != nil {
pdk.SetError(err)
return 1
}
return 0
}
//go:wasmexport nd_get_similar_artists
func ndGetSimilarArtists() int32 {
var input ArtistInputWithLimit
if err := pdk.InputJSON(&input); err != nil {
pdk.SetError(err)
return 1
}
limit := input.Limit
if limit == 0 {
limit = 5
}
artists := make([]SimilarArtist, 0, limit)
for i := range limit {
artists = append(artists, SimilarArtist{
Name: input.Name + " Similar " + string(rune('A'+i)),
})
}
output := SimilarArtistsOutput{Artists: artists}
if err := pdk.OutputJSON(output); err != nil {
pdk.SetError(err)
return 1
}
return 0
}
//go:wasmexport nd_get_artist_top_songs
func ndGetArtistTopSongs() int32 {
var input ArtistInputWithCount
if err := pdk.InputJSON(&input); err != nil {
pdk.SetError(err)
return 1
}
count := input.Count
if count == 0 {
count = 5
}
songs := make([]TopSong, 0, count)
for i := range count {
songs = append(songs, TopSong{
Name: input.Name + " Song " + string(rune('1'+i)),
})
}
output := TopSongsOutput{Songs: songs}
if err := pdk.OutputJSON(output); err != nil {
pdk.SetError(err)
return 1
}
return 0
}
//go:wasmexport nd_get_album_info
func ndGetAlbumInfo() int32 {
var input AlbumInput
if err := pdk.InputJSON(&input); err != nil {
pdk.SetError(err)
return 1
}
output := AlbumInfoOutput{
Name: input.Name,
MBID: "test-album-mbid-" + input.Name,
Description: "Description for " + input.Name + " by " + input.Artist,
URL: "https://test.example.com/album/" + input.Name,
}
if err := pdk.OutputJSON(output); err != nil {
pdk.SetError(err)
return 1
}
return 0
}
//go:wasmexport nd_get_album_images
func ndGetAlbumImages() int32 {
var input AlbumInput
if err := pdk.InputJSON(&input); err != nil {
pdk.SetError(err)
return 1
}
output := AlbumImagesOutput{
Images: []ArtistImage{
{URL: "https://test.example.com/albums/" + input.Name + "/cover.jpg", Size: 500},
},
}
if err := pdk.OutputJSON(output); err != nil {
pdk.SetError(err)
return 1
}
return 0
}
func main() {}