mirror of
https://github.com/navidrome/navidrome.git
synced 2025-12-23 23:18:05 -05:00
feat(plugins): implement new plugin system with using Extism
Signed-off-by: Deluan <deluan@navidrome.org>
This commit is contained in:
12
cmd/root.go
12
cmd/root.go
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)))
|
||||
|
||||
@@ -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)),
|
||||
)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
)
|
||||
@@ -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
10
go.mod
@@ -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
16
go.sum
@@ -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
194
plugins/README.md
Normal 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.
|
||||
48
plugins/examples/minimal/README.md
Normal file
48
plugins/examples/minimal/README.md
Normal 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.
|
||||
5
plugins/examples/minimal/go.mod
Normal file
5
plugins/examples/minimal/go.mod
Normal file
@@ -0,0 +1,5 @@
|
||||
module minimal-plugin
|
||||
|
||||
go 1.23
|
||||
|
||||
require github.com/extism/go-pdk v1.1.3
|
||||
2
plugins/examples/minimal/go.sum
Normal file
2
plugins/examples/minimal/go.sum
Normal 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=
|
||||
71
plugins/examples/minimal/main.go
Normal file
71
plugins/examples/minimal/main.go
Normal 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
344
plugins/manager.go
Normal 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
182
plugins/manifest.go
Normal 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
230
plugins/manifest_test.go
Normal 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
430
plugins/metadata_agent.go
Normal 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)
|
||||
)
|
||||
148
plugins/metadata_agent_test.go
Normal file
148
plugins/metadata_agent_test.go
Normal 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
|
||||
})
|
||||
})
|
||||
})
|
||||
13
plugins/plugins_suite_test.go
Normal file
13
plugins/plugins_suite_test.go
Normal 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
5
plugins/testdata/testplugin/go.mod
vendored
Normal 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
2
plugins/testdata/testplugin/go.sum
vendored
Normal 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
285
plugins/testdata/testplugin/main.go
vendored
Normal 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() {}
|
||||
Reference in New Issue
Block a user