diff --git a/plugins/README.md b/plugins/README.md index 1b4df460d..477b5cf34 100644 --- a/plugins/README.md +++ b/plugins/README.md @@ -29,6 +29,7 @@ The plugin system is built on **[Extism](https://extism.org/)**, a cross-languag - [Library](#library) - [Artwork](#artwork) - [SubsonicAPI](#subsonicapi) + - [Config](#config) - [Configuration](#configuration) - [Building Plugins](#building-plugins) - [Examples](#examples) @@ -670,6 +671,44 @@ Call Navidrome's Subsonic API internally (no network round-trip). response, err := SubsonicAPICall("getAlbumList2?type=random&size=10&u=username") ``` +### Config + +Access plugin configuration values programmatically. Unlike `pdk.GetConfig()` which only retrieves individual values, this service can list all available configuration keys—useful for discovering dynamic configuration (e.g., user-to-token mappings). + +> **Note:** This service is always available and does not require a manifest permission. + +**Host functions:** + +| Function | Parameters | Returns | +|-----------------|------------|-----------------------------| +| `config_get` | `key` | `value, exists` | +| `config_getint` | `key` | `value, exists` | +| `config_list` | `prefix` | Array of matching key names | + +**Usage (with generated SDK):** + +```go +import "github.com/navidrome/navidrome/plugins/pdk/go/host" + +// Get a string configuration value +value, exists := host.ConfigGet("api_key") +if exists { + // Use the value +} + +// Get an integer configuration value +count, exists := host.ConfigGetInt("max_retries") + +// List all keys with a prefix (useful for user-specific config) +keys := host.ConfigList("user:") +for _, key := range keys { + // key might be "user:john", "user:jane", etc. +} + +// List all configuration keys +allKeys := host.ConfigList("") +``` + --- ## Configuration diff --git a/plugins/host/config.go b/plugins/host/config.go new file mode 100644 index 000000000..fca730642 --- /dev/null +++ b/plugins/host/config.go @@ -0,0 +1,44 @@ +package host + +import "context" + +// ConfigService provides access to plugin configuration values. +// +// This service allows plugins to retrieve configuration values and enumerate +// available configuration keys. Unlike the built-in pdk.GetConfig(key) which +// only retrieves individual values, this service provides methods to list all +// available keys, making it useful for plugins that need to discover dynamic +// configuration (e.g., user-to-token mappings). +// +// This service is always available and does not require a permission in the manifest. +// +//nd:hostservice name=Config +type ConfigService interface { + // Get retrieves a configuration value as a string. + // + // Parameters: + // - key: The configuration key + // + // Returns the value and whether the key exists. + //nd:hostfunc + Get(ctx context.Context, key string) (value string, exists bool) + + // GetInt retrieves a configuration value as an integer. + // + // Parameters: + // - key: The configuration key + // + // Returns the value and whether the key exists. If the key exists but the + // value cannot be parsed as an integer, exists will be false. + //nd:hostfunc + GetInt(ctx context.Context, key string) (value int64, exists bool) + + // List returns configuration keys matching the given prefix. + // + // Parameters: + // - prefix: Key prefix to filter by. If empty, returns all keys. + // + // Returns a sorted slice of matching configuration keys. + //nd:hostfunc + List(ctx context.Context, prefix string) (keys []string) +} diff --git a/plugins/host/config_gen.go b/plugins/host/config_gen.go new file mode 100644 index 000000000..936c45d67 --- /dev/null +++ b/plugins/host/config_gen.go @@ -0,0 +1,169 @@ +// Code generated by ndpgen. DO NOT EDIT. + +package host + +import ( + "context" + "encoding/json" + + extism "github.com/extism/go-sdk" +) + +// ConfigGetRequest is the request type for Config.Get. +type ConfigGetRequest struct { + Key string `json:"key"` +} + +// ConfigGetResponse is the response type for Config.Get. +type ConfigGetResponse struct { + Value string `json:"value,omitempty"` + Exists bool `json:"exists,omitempty"` +} + +// ConfigGetIntRequest is the request type for Config.GetInt. +type ConfigGetIntRequest struct { + Key string `json:"key"` +} + +// ConfigGetIntResponse is the response type for Config.GetInt. +type ConfigGetIntResponse struct { + Value int64 `json:"value,omitempty"` + Exists bool `json:"exists,omitempty"` +} + +// ConfigListRequest is the request type for Config.List. +type ConfigListRequest struct { + Prefix string `json:"prefix"` +} + +// ConfigListResponse is the response type for Config.List. +type ConfigListResponse struct { + Keys []string `json:"keys,omitempty"` +} + +// RegisterConfigHostFunctions registers Config service host functions. +// The returned host functions should be added to the plugin's configuration. +func RegisterConfigHostFunctions(service ConfigService) []extism.HostFunction { + return []extism.HostFunction{ + newConfigGetHostFunction(service), + newConfigGetIntHostFunction(service), + newConfigListHostFunction(service), + } +} + +func newConfigGetHostFunction(service ConfigService) extism.HostFunction { + return extism.NewHostFunctionWithStack( + "config_get", + func(ctx context.Context, p *extism.CurrentPlugin, stack []uint64) { + // Read JSON request from plugin memory + reqBytes, err := p.ReadBytes(stack[0]) + if err != nil { + configWriteError(p, stack, err) + return + } + var req ConfigGetRequest + if err := json.Unmarshal(reqBytes, &req); err != nil { + configWriteError(p, stack, err) + return + } + + // Call the service method + value, exists := service.Get(ctx, req.Key) + + // Write JSON response to plugin memory + resp := ConfigGetResponse{ + Value: value, + Exists: exists, + } + configWriteResponse(p, stack, resp) + }, + []extism.ValueType{extism.ValueTypePTR}, + []extism.ValueType{extism.ValueTypePTR}, + ) +} + +func newConfigGetIntHostFunction(service ConfigService) extism.HostFunction { + return extism.NewHostFunctionWithStack( + "config_getint", + func(ctx context.Context, p *extism.CurrentPlugin, stack []uint64) { + // Read JSON request from plugin memory + reqBytes, err := p.ReadBytes(stack[0]) + if err != nil { + configWriteError(p, stack, err) + return + } + var req ConfigGetIntRequest + if err := json.Unmarshal(reqBytes, &req); err != nil { + configWriteError(p, stack, err) + return + } + + // Call the service method + value, exists := service.GetInt(ctx, req.Key) + + // Write JSON response to plugin memory + resp := ConfigGetIntResponse{ + Value: value, + Exists: exists, + } + configWriteResponse(p, stack, resp) + }, + []extism.ValueType{extism.ValueTypePTR}, + []extism.ValueType{extism.ValueTypePTR}, + ) +} + +func newConfigListHostFunction(service ConfigService) extism.HostFunction { + return extism.NewHostFunctionWithStack( + "config_list", + func(ctx context.Context, p *extism.CurrentPlugin, stack []uint64) { + // Read JSON request from plugin memory + reqBytes, err := p.ReadBytes(stack[0]) + if err != nil { + configWriteError(p, stack, err) + return + } + var req ConfigListRequest + if err := json.Unmarshal(reqBytes, &req); err != nil { + configWriteError(p, stack, err) + return + } + + // Call the service method + keys := service.List(ctx, req.Prefix) + + // Write JSON response to plugin memory + resp := ConfigListResponse{ + Keys: keys, + } + configWriteResponse(p, stack, resp) + }, + []extism.ValueType{extism.ValueTypePTR}, + []extism.ValueType{extism.ValueTypePTR}, + ) +} + +// configWriteResponse writes a JSON response to plugin memory. +func configWriteResponse(p *extism.CurrentPlugin, stack []uint64, resp any) { + respBytes, err := json.Marshal(resp) + if err != nil { + configWriteError(p, stack, err) + return + } + respPtr, err := p.WriteBytes(respBytes) + if err != nil { + stack[0] = 0 + return + } + stack[0] = respPtr +} + +// configWriteError writes an error response to plugin memory. +func configWriteError(p *extism.CurrentPlugin, stack []uint64, err error) { + errResp := struct { + Error string `json:"error"` + }{Error: err.Error()} + respBytes, _ := json.Marshal(errResp) + respPtr, _ := p.WriteBytes(respBytes) + stack[0] = respPtr +} diff --git a/plugins/host_config.go b/plugins/host_config.go new file mode 100644 index 000000000..d2d94880d --- /dev/null +++ b/plugins/host_config.go @@ -0,0 +1,69 @@ +package plugins + +import ( + "context" + "sort" + "strconv" + "strings" + + "github.com/navidrome/navidrome/log" + "github.com/navidrome/navidrome/plugins/host" +) + +// configServiceImpl implements the host.ConfigService interface. +// It provides access to plugin configuration values set in the Navidrome config file. +type configServiceImpl struct { + pluginName string + config map[string]string +} + +// newConfigService creates a new configServiceImpl instance. +func newConfigService(pluginName string, config map[string]string) *configServiceImpl { + if config == nil { + config = make(map[string]string) + } + return &configServiceImpl{ + pluginName: pluginName, + config: config, + } +} + +// Get retrieves a configuration value as a string. +func (s *configServiceImpl) Get(ctx context.Context, key string) (string, bool) { + value, exists := s.config[key] + log.Trace(ctx, "Config.Get", "plugin", s.pluginName, "key", key, "exists", exists) + return value, exists +} + +// GetInt retrieves a configuration value as an integer. +func (s *configServiceImpl) GetInt(ctx context.Context, key string) (int64, bool) { + value, exists := s.config[key] + if !exists { + log.Trace(ctx, "Config.GetInt", "plugin", s.pluginName, "key", key, "exists", false) + return 0, false + } + + intValue, err := strconv.ParseInt(value, 10, 64) + if err != nil { + log.Trace(ctx, "Config.GetInt parse error", "plugin", s.pluginName, "key", key, "value", value, "error", err) + return 0, false + } + + log.Trace(ctx, "Config.GetInt", "plugin", s.pluginName, "key", key, "value", intValue) + return intValue, true +} + +// List returns configuration keys matching the given prefix. +func (s *configServiceImpl) List(ctx context.Context, prefix string) []string { + keys := make([]string, 0, len(s.config)) + for k := range s.config { + if prefix == "" || strings.HasPrefix(k, prefix) { + keys = append(keys, k) + } + } + sort.Strings(keys) + log.Trace(ctx, "Config.List", "plugin", s.pluginName, "prefix", prefix, "keyCount", len(keys)) + return keys +} + +var _ host.ConfigService = (*configServiceImpl)(nil) diff --git a/plugins/host_config_test.go b/plugins/host_config_test.go new file mode 100644 index 000000000..9261e899e --- /dev/null +++ b/plugins/host_config_test.go @@ -0,0 +1,312 @@ +//go:build !windows + +package plugins + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "errors" + "net/http" + "os" + "path/filepath" + + "github.com/navidrome/navidrome/conf" + "github.com/navidrome/navidrome/conf/configtest" + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/tests" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("ConfigService", func() { + var service *configServiceImpl + var ctx context.Context + + BeforeEach(func() { + ctx = context.Background() + }) + + Describe("newConfigService", func() { + It("creates service with provided config", func() { + config := map[string]string{"key1": "value1", "key2": "value2"} + service = newConfigService("test_plugin", config) + Expect(service.pluginName).To(Equal("test_plugin")) + Expect(service.config).To(Equal(config)) + }) + + It("creates service with empty config when nil", func() { + service = newConfigService("test_plugin", nil) + Expect(service.config).ToNot(BeNil()) + Expect(service.config).To(BeEmpty()) + }) + }) + + Describe("Get", func() { + BeforeEach(func() { + service = newConfigService("test_plugin", map[string]string{ + "api_key": "secret123", + "debug_mode": "true", + "max_items": "100", + }) + }) + + It("returns value for existing key", func() { + value, exists := service.Get(ctx, "api_key") + Expect(exists).To(BeTrue()) + Expect(value).To(Equal("secret123")) + }) + + It("returns not exists for missing key", func() { + value, exists := service.Get(ctx, "missing_key") + Expect(exists).To(BeFalse()) + Expect(value).To(Equal("")) + }) + }) + + Describe("GetInt", func() { + BeforeEach(func() { + service = newConfigService("test_plugin", map[string]string{ + "max_items": "100", + "timeout": "30", + "negative": "-50", + "not_a_number": "abc", + "float": "3.14", + }) + }) + + It("returns integer for valid numeric value", func() { + value, exists := service.GetInt(ctx, "max_items") + Expect(exists).To(BeTrue()) + Expect(value).To(Equal(int64(100))) + }) + + It("returns negative integer", func() { + value, exists := service.GetInt(ctx, "negative") + Expect(exists).To(BeTrue()) + Expect(value).To(Equal(int64(-50))) + }) + + It("returns not exists for non-numeric value", func() { + value, exists := service.GetInt(ctx, "not_a_number") + Expect(exists).To(BeFalse()) + Expect(value).To(Equal(int64(0))) + }) + + It("returns not exists for float value", func() { + value, exists := service.GetInt(ctx, "float") + Expect(exists).To(BeFalse()) + Expect(value).To(Equal(int64(0))) + }) + + It("returns not exists for missing key", func() { + value, exists := service.GetInt(ctx, "missing_key") + Expect(exists).To(BeFalse()) + Expect(value).To(Equal(int64(0))) + }) + }) + + Describe("List", func() { + BeforeEach(func() { + service = newConfigService("test_plugin", map[string]string{ + "zebra": "z", + "apple": "a", + "banana": "b", + "user_alice": "token1", + "user_bob": "token2", + "user_charlie": "token3", + }) + }) + + It("returns all keys in sorted order when prefix is empty", func() { + keys := service.List(ctx, "") + Expect(keys).To(Equal([]string{"apple", "banana", "user_alice", "user_bob", "user_charlie", "zebra"})) + }) + + It("returns only keys matching prefix", func() { + keys := service.List(ctx, "user_") + Expect(keys).To(Equal([]string{"user_alice", "user_bob", "user_charlie"})) + }) + + It("returns empty slice when no keys match prefix", func() { + keys := service.List(ctx, "nonexistent_") + Expect(keys).To(BeEmpty()) + }) + + It("returns empty slice for empty config", func() { + service = newConfigService("test_plugin", nil) + keys := service.List(ctx, "") + Expect(keys).To(BeEmpty()) + }) + }) +}) + +var _ = Describe("ConfigService Integration", Ordered, func() { + var ( + manager *Manager + tmpDir string + ) + + BeforeAll(func() { + var err error + tmpDir, err = os.MkdirTemp("", "config-test-*") + Expect(err).ToNot(HaveOccurred()) + + // Copy the test-config plugin + srcPath := filepath.Join(testdataDir, "test-config"+PackageExtension) + destPath := filepath.Join(tmpDir, "test-config"+PackageExtension) + data, err := os.ReadFile(srcPath) + Expect(err).ToNot(HaveOccurred()) + err = os.WriteFile(destPath, data, 0600) + Expect(err).ToNot(HaveOccurred()) + + // Compute SHA256 for the plugin + hash := sha256.Sum256(data) + hashHex := hex.EncodeToString(hash[:]) + + // Setup config + DeferCleanup(configtest.SetupConfig()) + conf.Server.Plugins.Enabled = true + conf.Server.Plugins.Folder = tmpDir + conf.Server.Plugins.AutoReload = false + conf.Server.CacheFolder = filepath.Join(tmpDir, "cache") + + // Setup mock DataStore with pre-enabled plugin and config + mockPluginRepo := tests.CreateMockPluginRepo() + mockPluginRepo.Permitted = true + mockPluginRepo.SetData(model.Plugins{{ + ID: "test-config", + Path: destPath, + SHA256: hashHex, + Enabled: true, + Config: `{"api_key":"test_secret","max_retries":"5","timeout":"30"}`, + }}) + dataStore := &tests.MockDataStore{MockedPlugin: mockPluginRepo} + + // Create and start manager + manager = &Manager{ + plugins: make(map[string]*plugin), + ds: dataStore, + subsonicRouter: http.NotFoundHandler(), + } + err = manager.Start(GinkgoT().Context()) + Expect(err).ToNot(HaveOccurred()) + + DeferCleanup(func() { + _ = manager.Stop() + _ = os.RemoveAll(tmpDir) + }) + }) + + Describe("Plugin Loading", func() { + It("should load plugin without config permission", func() { + manager.mu.RLock() + p, ok := manager.plugins["test-config"] + manager.mu.RUnlock() + Expect(ok).To(BeTrue()) + // Config service doesn't require permission, so Permissions can be nil + // Just verify the plugin loaded + Expect(p.manifest.Name).To(Equal("Test Config Plugin")) + }) + }) + + Describe("Config Operations via Plugin", func() { + type testConfigInput struct { + Operation string `json:"operation"` + Key string `json:"key,omitempty"` + Prefix string `json:"prefix,omitempty"` + } + type testConfigOutput struct { + StringVal string `json:"string_val,omitempty"` + IntVal int64 `json:"int_val,omitempty"` + Keys []string `json:"keys,omitempty"` + Exists bool `json:"exists,omitempty"` + Error *string `json:"error,omitempty"` + } + + // Helper to call test plugin's exported function + callTestConfig := func(ctx context.Context, input testConfigInput) (*testConfigOutput, error) { + manager.mu.RLock() + p := manager.plugins["test-config"] + manager.mu.RUnlock() + + instance, err := p.instance(ctx) + if err != nil { + return nil, err + } + defer instance.Close(ctx) + + inputBytes, _ := json.Marshal(input) + _, outputBytes, err := instance.Call("nd_test_config", inputBytes) + if err != nil { + return nil, err + } + + var output testConfigOutput + if err := json.Unmarshal(outputBytes, &output); err != nil { + return nil, err + } + if output.Error != nil { + return nil, errors.New(*output.Error) + } + return &output, nil + } + + It("should get string value", func() { + output, err := callTestConfig(GinkgoT().Context(), testConfigInput{ + Operation: "get", + Key: "api_key", + }) + Expect(err).ToNot(HaveOccurred()) + Expect(output.StringVal).To(Equal("test_secret")) + Expect(output.Exists).To(BeTrue()) + }) + + It("should return not exists for missing key", func() { + output, err := callTestConfig(GinkgoT().Context(), testConfigInput{ + Operation: "get", + Key: "nonexistent", + }) + Expect(err).ToNot(HaveOccurred()) + Expect(output.Exists).To(BeFalse()) + }) + + It("should get integer value", func() { + output, err := callTestConfig(GinkgoT().Context(), testConfigInput{ + Operation: "get_int", + Key: "max_retries", + }) + Expect(err).ToNot(HaveOccurred()) + Expect(output.IntVal).To(Equal(int64(5))) + Expect(output.Exists).To(BeTrue()) + }) + + It("should return not exists for non-integer value", func() { + output, err := callTestConfig(GinkgoT().Context(), testConfigInput{ + Operation: "get_int", + Key: "api_key", // This is a string, not an integer + }) + Expect(err).ToNot(HaveOccurred()) + Expect(output.Exists).To(BeFalse()) + }) + + It("should list all config keys with empty prefix", func() { + output, err := callTestConfig(GinkgoT().Context(), testConfigInput{ + Operation: "list", + Prefix: "", + }) + Expect(err).ToNot(HaveOccurred()) + Expect(output.Keys).To(ConsistOf("api_key", "max_retries", "timeout")) + }) + + It("should list config keys with prefix filter", func() { + output, err := callTestConfig(GinkgoT().Context(), testConfigInput{ + Operation: "list", + Prefix: "max", + }) + Expect(err).ToNot(HaveOccurred()) + Expect(output.Keys).To(ConsistOf("max_retries")) + }) + }) +}) diff --git a/plugins/manager_loader.go b/plugins/manager_loader.go index 70eddf617..da48a33de 100644 --- a/plugins/manager_loader.go +++ b/plugins/manager_loader.go @@ -22,6 +22,7 @@ type serviceContext struct { pluginName string manager *Manager permissions *Permissions + config map[string]string } // hostServiceEntry defines a host service for table-driven registration. @@ -34,6 +35,14 @@ type hostServiceEntry struct { // hostServices defines all available host services. // Adding a new host service only requires adding an entry here. var hostServices = []hostServiceEntry{ + { + name: "Config", + hasPermission: func(p *Permissions) bool { return true }, // Always available, no permission required + create: func(ctx *serviceContext) ([]extism.HostFunction, io.Closer) { + service := newConfigService(ctx.pluginName, ctx.config) + return host.RegisterConfigHostFunctions(service), nil + }, + }, { name: "SubsonicAPI", hasPermission: func(p *Permissions) bool { return p != nil && p.Subsonicapi != nil }, @@ -258,6 +267,7 @@ func (m *Manager) loadPluginWithConfig(name, ndpPath, configJSON string) error { pluginName: name, manager: m, permissions: pkg.Manifest.Permissions, + config: pluginConfig, } for _, entry := range hostServices { if entry.hasPermission(pkg.Manifest.Permissions) { diff --git a/plugins/pdk/go/host/doc.go b/plugins/pdk/go/host/doc.go index efb4cd4a0..7f318e0f1 100644 --- a/plugins/pdk/go/host/doc.go +++ b/plugins/pdk/go/host/doc.go @@ -37,6 +37,7 @@ The following host services are available: - Artwork: provides artwork public URL generation capabilities for plugins. - Cache: provides in-memory TTL-based caching capabilities for plugins. + - Config: provides access to plugin configuration values. - KVStore: provides persistent key-value storage for plugins. - Library: provides access to music library metadata for plugins. - Scheduler: provides task scheduling capabilities for plugins. diff --git a/plugins/pdk/go/host/nd_host_config.go b/plugins/pdk/go/host/nd_host_config.go new file mode 100644 index 000000000..f435cd1c6 --- /dev/null +++ b/plugins/pdk/go/host/nd_host_config.go @@ -0,0 +1,161 @@ +// Code generated by ndpgen. DO NOT EDIT. +// +// This file contains client wrappers for the Config host service. +// It is intended for use in Navidrome plugins built with TinyGo. +// +//go:build wasip1 + +package host + +import ( + "encoding/json" + + "github.com/navidrome/navidrome/plugins/pdk/go/pdk" +) + +// config_get is the host function provided by Navidrome. +// +//go:wasmimport extism:host/user config_get +func config_get(uint64) uint64 + +// config_getint is the host function provided by Navidrome. +// +//go:wasmimport extism:host/user config_getint +func config_getint(uint64) uint64 + +// config_list is the host function provided by Navidrome. +// +//go:wasmimport extism:host/user config_list +func config_list(uint64) uint64 + +type configGetRequest struct { + Key string `json:"key"` +} + +type configGetResponse struct { + Value string `json:"value,omitempty"` + Exists bool `json:"exists,omitempty"` +} + +type configGetIntRequest struct { + Key string `json:"key"` +} + +type configGetIntResponse struct { + Value int64 `json:"value,omitempty"` + Exists bool `json:"exists,omitempty"` +} + +type configListRequest struct { + Prefix string `json:"prefix"` +} + +type configListResponse struct { + Keys []string `json:"keys,omitempty"` +} + +// ConfigGet calls the config_get host function. +// Get retrieves a configuration value as a string. +// +// Parameters: +// - key: The configuration key +// +// Returns the value and whether the key exists. +func ConfigGet(key string) (string, bool) { + // Marshal request to JSON + req := configGetRequest{ + Key: key, + } + reqBytes, err := json.Marshal(req) + if err != nil { + return "", false + } + reqMem := pdk.AllocateBytes(reqBytes) + defer reqMem.Free() + + // Call the host function + responsePtr := config_get(reqMem.Offset()) + + // Read the response from memory + responseMem := pdk.FindMemory(responsePtr) + responseBytes := responseMem.ReadBytes() + + // Parse the response + var response configGetResponse + if err := json.Unmarshal(responseBytes, &response); err != nil { + return "", false + } + + return response.Value, response.Exists +} + +// ConfigGetInt calls the config_getint host function. +// GetInt retrieves a configuration value as an integer. +// +// Parameters: +// - key: The configuration key +// +// Returns the value and whether the key exists. If the key exists but the +// value cannot be parsed as an integer, exists will be false. +func ConfigGetInt(key string) (int64, bool) { + // Marshal request to JSON + req := configGetIntRequest{ + Key: key, + } + reqBytes, err := json.Marshal(req) + if err != nil { + return 0, false + } + reqMem := pdk.AllocateBytes(reqBytes) + defer reqMem.Free() + + // Call the host function + responsePtr := config_getint(reqMem.Offset()) + + // Read the response from memory + responseMem := pdk.FindMemory(responsePtr) + responseBytes := responseMem.ReadBytes() + + // Parse the response + var response configGetIntResponse + if err := json.Unmarshal(responseBytes, &response); err != nil { + return 0, false + } + + return response.Value, response.Exists +} + +// ConfigList calls the config_list host function. +// List returns configuration keys matching the given prefix. +// +// Parameters: +// - prefix: Key prefix to filter by. If empty, returns all keys. +// +// Returns a sorted slice of matching configuration keys. +func ConfigList(prefix string) []string { + // Marshal request to JSON + req := configListRequest{ + Prefix: prefix, + } + reqBytes, err := json.Marshal(req) + if err != nil { + return nil + } + reqMem := pdk.AllocateBytes(reqBytes) + defer reqMem.Free() + + // Call the host function + responsePtr := config_list(reqMem.Offset()) + + // Read the response from memory + responseMem := pdk.FindMemory(responsePtr) + responseBytes := responseMem.ReadBytes() + + // Parse the response + var response configListResponse + if err := json.Unmarshal(responseBytes, &response); err != nil { + return nil + } + + return response.Keys +} diff --git a/plugins/pdk/go/host/nd_host_config_stub.go b/plugins/pdk/go/host/nd_host_config_stub.go new file mode 100644 index 000000000..df9ae1a6d --- /dev/null +++ b/plugins/pdk/go/host/nd_host_config_stub.go @@ -0,0 +1,72 @@ +// Code generated by ndpgen. DO NOT EDIT. +// +// This file contains mock implementations for non-WASM builds. +// These mocks allow IDE support, compilation, and unit testing on non-WASM platforms. +// Plugin authors can use the exported mock instances to set expectations in tests. +// +//go:build !wasip1 + +package host + +import "github.com/stretchr/testify/mock" + +// mockConfigService is the mock implementation for testing. +type mockConfigService struct { + mock.Mock +} + +// ConfigMock is the auto-instantiated mock instance for testing. +// Use this to set expectations: host.ConfigMock.On("MethodName", args...).Return(values...) +var ConfigMock = &mockConfigService{} + +// Get is the mock method for ConfigGet. +func (m *mockConfigService) Get(key string) (string, bool) { + args := m.Called(key) + return args.String(0), args.Bool(1) +} + +// ConfigGet delegates to the mock instance. +// Get retrieves a configuration value as a string. +// +// Parameters: +// - key: The configuration key +// +// Returns the value and whether the key exists. +func ConfigGet(key string) (string, bool) { + return ConfigMock.Get(key) +} + +// GetInt is the mock method for ConfigGetInt. +func (m *mockConfigService) GetInt(key string) (int64, bool) { + args := m.Called(key) + return args.Get(0).(int64), args.Bool(1) +} + +// ConfigGetInt delegates to the mock instance. +// GetInt retrieves a configuration value as an integer. +// +// Parameters: +// - key: The configuration key +// +// Returns the value and whether the key exists. If the key exists but the +// value cannot be parsed as an integer, exists will be false. +func ConfigGetInt(key string) (int64, bool) { + return ConfigMock.GetInt(key) +} + +// List is the mock method for ConfigList. +func (m *mockConfigService) List(prefix string) []string { + args := m.Called(prefix) + return args.Get(0).([]string) +} + +// ConfigList delegates to the mock instance. +// List returns configuration keys matching the given prefix. +// +// Parameters: +// - prefix: Key prefix to filter by. If empty, returns all keys. +// +// Returns a sorted slice of matching configuration keys. +func ConfigList(prefix string) []string { + return ConfigMock.List(prefix) +} diff --git a/plugins/pdk/python/host/nd_host_config.py b/plugins/pdk/python/host/nd_host_config.py new file mode 100644 index 000000000..ca5bf89de --- /dev/null +++ b/plugins/pdk/python/host/nd_host_config.py @@ -0,0 +1,145 @@ +# Code generated by ndpgen. DO NOT EDIT. +# +# This file contains client wrappers for the Config host service. +# It is intended for use in Navidrome plugins built with extism-py. +# +# IMPORTANT: Due to a limitation in extism-py, you cannot import this file directly. +# The @extism.import_fn decorators are only detected when defined in the plugin's +# main __init__.py file. Copy the needed functions from this file into your plugin. + +from dataclasses import dataclass +from typing import Any + +import extism +import json + + +class HostFunctionError(Exception): + """Raised when a host function returns an error.""" + pass + + +@extism.import_fn("extism:host/user", "config_get") +def _config_get(offset: int) -> int: + """Raw host function - do not call directly.""" + ... + + +@extism.import_fn("extism:host/user", "config_getint") +def _config_getint(offset: int) -> int: + """Raw host function - do not call directly.""" + ... + + +@extism.import_fn("extism:host/user", "config_list") +def _config_list(offset: int) -> int: + """Raw host function - do not call directly.""" + ... + + +@dataclass +class ConfigGetResult: + """Result type for config_get.""" + value: str + exists: bool + + +@dataclass +class ConfigGetIntResult: + """Result type for config_get_int.""" + value: int + exists: bool + + +def config_get(key: str) -> ConfigGetResult: + """Get retrieves a configuration value as a string. + +Parameters: + - key: The configuration key + +Returns the value and whether the key exists. + + Args: + key: str parameter. + + Returns: + ConfigGetResult containing value, exists,. + + Raises: + HostFunctionError: If the host function returns an error. + """ + request = { + "key": key, + } + request_bytes = json.dumps(request).encode("utf-8") + request_mem = extism.memory.alloc(request_bytes) + response_offset = _config_get(request_mem.offset) + response_mem = extism.memory.find(response_offset) + response = json.loads(extism.memory.string(response_mem)) + + return ConfigGetResult( + value=response.get("value", ""), + exists=response.get("exists", False), + ) + + +def config_get_int(key: str) -> ConfigGetIntResult: + """GetInt retrieves a configuration value as an integer. + +Parameters: + - key: The configuration key + +Returns the value and whether the key exists. If the key exists but the +value cannot be parsed as an integer, exists will be false. + + Args: + key: str parameter. + + Returns: + ConfigGetIntResult containing value, exists,. + + Raises: + HostFunctionError: If the host function returns an error. + """ + request = { + "key": key, + } + request_bytes = json.dumps(request).encode("utf-8") + request_mem = extism.memory.alloc(request_bytes) + response_offset = _config_getint(request_mem.offset) + response_mem = extism.memory.find(response_offset) + response = json.loads(extism.memory.string(response_mem)) + + return ConfigGetIntResult( + value=response.get("value", 0), + exists=response.get("exists", False), + ) + + +def config_list(prefix: str) -> Any: + """List returns configuration keys matching the given prefix. + +Parameters: + - prefix: Key prefix to filter by. If empty, returns all keys. + +Returns a sorted slice of matching configuration keys. + + Args: + prefix: str parameter. + + Returns: + Any: The result value. + + Raises: + HostFunctionError: If the host function returns an error. + """ + request = { + "prefix": prefix, + } + request_bytes = json.dumps(request).encode("utf-8") + request_mem = extism.memory.alloc(request_bytes) + response_offset = _config_list(request_mem.offset) + response_mem = extism.memory.find(response_offset) + response = json.loads(extism.memory.string(response_mem)) + + return response.get("keys", None) diff --git a/plugins/pdk/rust/nd-pdk-host/src/lib.rs b/plugins/pdk/rust/nd-pdk-host/src/lib.rs index f362d989e..243f32a2e 100644 --- a/plugins/pdk/rust/nd-pdk-host/src/lib.rs +++ b/plugins/pdk/rust/nd-pdk-host/src/lib.rs @@ -34,6 +34,7 @@ //! //! - [`artwork`] - provides artwork public URL generation capabilities for plugins. //! - [`cache`] - provides in-memory TTL-based caching capabilities for plugins. +//! - [`config`] - provides access to plugin configuration values. //! - [`kvstore`] - provides persistent key-value storage for plugins. //! - [`library`] - provides access to music library metadata for plugins. //! - [`scheduler`] - provides task scheduling capabilities for plugins. @@ -54,6 +55,13 @@ pub mod cache { pub use super::nd_host_cache::*; } +#[doc(hidden)] +mod nd_host_config; +/// provides access to plugin configuration values. +pub mod config { + pub use super::nd_host_config::*; +} + #[doc(hidden)] mod nd_host_kvstore; /// provides persistent key-value storage for plugins. diff --git a/plugins/pdk/rust/nd-pdk-host/src/nd_host_config.rs b/plugins/pdk/rust/nd-pdk-host/src/nd_host_config.rs new file mode 100644 index 000000000..99a302968 --- /dev/null +++ b/plugins/pdk/rust/nd-pdk-host/src/nd_host_config.rs @@ -0,0 +1,133 @@ +// Code generated by ndpgen. DO NOT EDIT. +// +// This file contains client wrappers for the Config host service. +// It is intended for use in Navidrome plugins built with extism-pdk. + +use extism_pdk::*; +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +struct ConfigGetRequest { + key: String, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +struct ConfigGetResponse { + #[serde(default)] + value: String, + #[serde(default)] + exists: bool, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +struct ConfigGetIntRequest { + key: String, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +struct ConfigGetIntResponse { + #[serde(default)] + value: i64, + #[serde(default)] + exists: bool, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +struct ConfigListRequest { + prefix: String, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +struct ConfigListResponse { + #[serde(default)] + keys: Vec, +} + +#[host_fn] +extern "ExtismHost" { + fn config_get(input: Json) -> Json; + fn config_getint(input: Json) -> Json; + fn config_list(input: Json) -> Json; +} + +/// Get retrieves a configuration value as a string. +/// +/// Parameters: +/// - key: The configuration key +/// +/// Returns the value and whether the key exists. +/// +/// # Arguments +/// * `key` - String parameter. +/// +/// # Returns +/// A tuple of (value, exists). +/// +/// # Errors +/// Returns an error if the host function call fails. +pub fn get(key: &str) -> Result<(String, bool), Error> { + let response = unsafe { + config_get(Json(ConfigGetRequest { + key: key.to_owned(), + }))? + }; + + Ok((response.0.value, response.0.exists)) +} + +/// GetInt retrieves a configuration value as an integer. +/// +/// Parameters: +/// - key: The configuration key +/// +/// Returns the value and whether the key exists. If the key exists but the +/// value cannot be parsed as an integer, exists will be false. +/// +/// # Arguments +/// * `key` - String parameter. +/// +/// # Returns +/// A tuple of (value, exists). +/// +/// # Errors +/// Returns an error if the host function call fails. +pub fn get_int(key: &str) -> Result<(i64, bool), Error> { + let response = unsafe { + config_getint(Json(ConfigGetIntRequest { + key: key.to_owned(), + }))? + }; + + Ok((response.0.value, response.0.exists)) +} + +/// List returns configuration keys matching the given prefix. +/// +/// Parameters: +/// - prefix: Key prefix to filter by. If empty, returns all keys. +/// +/// Returns a sorted slice of matching configuration keys. +/// +/// # Arguments +/// * `prefix` - String parameter. +/// +/// # Returns +/// The keys value. +/// +/// # Errors +/// Returns an error if the host function call fails. +pub fn list(prefix: &str) -> Result, Error> { + let response = unsafe { + config_list(Json(ConfigListRequest { + prefix: prefix.to_owned(), + }))? + }; + + Ok(response.0.keys) +} diff --git a/plugins/testdata/test-config/go.mod b/plugins/testdata/test-config/go.mod new file mode 100644 index 000000000..7fa19b41e --- /dev/null +++ b/plugins/testdata/test-config/go.mod @@ -0,0 +1,16 @@ +module test-config + +go 1.25 + +require github.com/navidrome/navidrome/plugins/pdk/go v0.0.0 + +require ( + github.com/davecgh/go-spew v1.1.1 // indirect + github.com/extism/go-pdk v1.1.3 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect + github.com/stretchr/objx v0.5.2 // indirect + github.com/stretchr/testify v1.11.1 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect +) + +replace github.com/navidrome/navidrome/plugins/pdk/go => ../../pdk/go diff --git a/plugins/testdata/test-config/go.sum b/plugins/testdata/test-config/go.sum new file mode 100644 index 000000000..af880eb51 --- /dev/null +++ b/plugins/testdata/test-config/go.sum @@ -0,0 +1,14 @@ +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/extism/go-pdk v1.1.3 h1:hfViMPWrqjN6u67cIYRALZTZLk/enSPpNKa+rZ9X2SQ= +github.com/extism/go-pdk v1.1.3/go.mod h1:Gz+LIU/YCKnKXhgge8yo5Yu1F/lbv7KtKFkiCSzW/P4= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= +github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/plugins/testdata/test-config/main.go b/plugins/testdata/test-config/main.go new file mode 100644 index 000000000..24a4e59f2 --- /dev/null +++ b/plugins/testdata/test-config/main.go @@ -0,0 +1,60 @@ +// Test Config plugin for Navidrome plugin system integration tests. +// Build with: tinygo build -o ../test-config.wasm -target wasip1 -buildmode=c-shared . +package main + +import ( + "github.com/navidrome/navidrome/plugins/pdk/go/host" + "github.com/navidrome/navidrome/plugins/pdk/go/pdk" +) + +// TestConfigInput is the input for nd_test_config callback. +type TestConfigInput struct { + Operation string `json:"operation"` // "get", "get_int", "list" + Key string `json:"key"` // For get/get_int operations + Prefix string `json:"prefix"` // For list operation +} + +// TestConfigOutput is the output from nd_test_config callback. +type TestConfigOutput struct { + StringVal string `json:"string_val,omitempty"` + IntVal int64 `json:"int_val,omitempty"` + Keys []string `json:"keys,omitempty"` + Exists bool `json:"exists,omitempty"` + Error *string `json:"error,omitempty"` +} + +// nd_test_config is the test callback that tests the config host functions. +// +//go:wasmexport nd_test_config +func ndTestConfig() int32 { + var input TestConfigInput + if err := pdk.InputJSON(&input); err != nil { + errStr := err.Error() + pdk.OutputJSON(TestConfigOutput{Error: &errStr}) + return 0 + } + + switch input.Operation { + case "get": + value, exists := host.ConfigGet(input.Key) + pdk.OutputJSON(TestConfigOutput{StringVal: value, Exists: exists}) + return 0 + + case "get_int": + value, exists := host.ConfigGetInt(input.Key) + pdk.OutputJSON(TestConfigOutput{IntVal: value, Exists: exists}) + return 0 + + case "list": + keys := host.ConfigList(input.Prefix) + pdk.OutputJSON(TestConfigOutput{Keys: keys}) + return 0 + + default: + errStr := "unknown operation: " + input.Operation + pdk.OutputJSON(TestConfigOutput{Error: &errStr}) + return 0 + } +} + +func main() {} diff --git a/plugins/testdata/test-config/manifest.json b/plugins/testdata/test-config/manifest.json new file mode 100644 index 000000000..fc606b1fc --- /dev/null +++ b/plugins/testdata/test-config/manifest.json @@ -0,0 +1,6 @@ +{ + "name": "Test Config Plugin", + "author": "Navidrome Test", + "version": "1.0.0", + "description": "A test plugin for config service integration testing" +}