feat: implement ConfigService for plugin configuration management

Signed-off-by: Deluan <deluan@navidrome.org>
This commit is contained in:
Deluan 2026-01-01 19:54:11 -05:00
parent 4b66560f50
commit b90ecb9754
16 changed files with 1259 additions and 0 deletions

View File

@ -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

44
plugins/host/config.go Normal file
View File

@ -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)
}

169
plugins/host/config_gen.go Normal file
View File

@ -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
}

69
plugins/host_config.go Normal file
View File

@ -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)

312
plugins/host_config_test.go Normal file
View File

@ -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"))
})
})
})

View File

@ -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) {

View File

@ -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.

View File

@ -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
}

View File

@ -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)
}

View File

@ -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)

View File

@ -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.

View File

@ -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<String>,
}
#[host_fn]
extern "ExtismHost" {
fn config_get(input: Json<ConfigGetRequest>) -> Json<ConfigGetResponse>;
fn config_getint(input: Json<ConfigGetIntRequest>) -> Json<ConfigGetIntResponse>;
fn config_list(input: Json<ConfigListRequest>) -> Json<ConfigListResponse>;
}
/// 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<Vec<String>, Error> {
let response = unsafe {
config_list(Json(ConfigListRequest {
prefix: prefix.to_owned(),
}))?
};
Ok(response.0.keys)
}

16
plugins/testdata/test-config/go.mod vendored Normal file
View File

@ -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

14
plugins/testdata/test-config/go.sum vendored Normal file
View File

@ -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=

60
plugins/testdata/test-config/main.go vendored Normal file
View File

@ -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() {}

View File

@ -0,0 +1,6 @@
{
"name": "Test Config Plugin",
"author": "Navidrome Test",
"version": "1.0.0",
"description": "A test plugin for config service integration testing"
}