feat: add Cache service for in-memory TTL-based caching in plugins

Signed-off-by: Deluan <deluan@navidrome.org>
This commit is contained in:
Deluan 2025-12-26 01:18:29 -05:00
parent 66c396413c
commit f0d6fd4bc8
17 changed files with 2299 additions and 13 deletions

View File

@ -300,6 +300,96 @@ func main() {}
To schedule a task from your plugin, use the generated SDK functions (see `plugins/host/go/nd_host_scheduler.go`).
### Cache
Allows plugins to store and retrieve data in an in-memory TTL-based cache. This is useful for caching API responses, storing session tokens, or persisting state across plugin invocations.
**Important:** The cache is in-memory only and will be lost on server restart. Plugins should handle cache misses gracefully.
#### Using the Cache Host Service
To use the cache, plugins call these host functions (provided by Navidrome):
| Host Function | Parameters | Description |
|----------------------|--------------------------------|------------------------------------------------|
| `cache_setstring` | `key, value, ttl_seconds` | Store a string value |
| `cache_getstring` | `key` | Retrieve a string value |
| `cache_setint` | `key, value, ttl_seconds` | Store an integer value |
| `cache_getint` | `key` | Retrieve an integer value |
| `cache_setfloat` | `key, value, ttl_seconds` | Store a float value |
| `cache_getfloat` | `key` | Retrieve a float value |
| `cache_setbytes` | `key, value, ttl_seconds` | Store a byte slice |
| `cache_getbytes` | `key` | Retrieve a byte slice |
| `cache_has` | `key` | Check if a key exists |
| `cache_remove` | `key` | Delete a cached value |
**TTL (Time-to-Live):** Pass `0` to use the default TTL of 24 hours, or specify seconds.
**Key Isolation:** Each plugin's cache keys are automatically namespaced, so different plugins can use the same key names without conflicts.
#### Get Response Format
Get operations return a JSON response:
```json
{
"value": "...",
"exists": true,
"error": ""
}
```
- `value`: The cached value (type matches the operation: string, int64, float64, or base64-encoded bytes)
- `exists`: `true` if the key was found and the type matched, `false` otherwise
- `error`: Error message if something went wrong
#### Manifest Permissions
Plugins using the cache must declare the permission in their manifest:
```json
{
"permissions": {
"cache": {
"reason": "Cache API responses to reduce external requests"
}
}
}
```
#### Example Cache Usage
```go
package main
import (
"github.com/extism/go-pdk"
)
// Import the generated cache SDK (from plugins/host/go/nd_host_cache.go)
func fetchWithCache(key string) (string, error) {
// Try to get from cache first
resp, err := CacheGetString(key)
if err != nil {
return "", err
}
if resp.Exists {
return resp.Value, nil
}
// Cache miss - fetch from external API
value := fetchFromAPI()
// Cache for 1 hour (3600 seconds)
CacheSetString(key, value, 3600)
return value, nil
}
```
To use the cache from your plugin, copy the generated SDK file `plugins/host/go/nd_host_cache.go` to your plugin directory.
## 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.

117
plugins/host/cache.go Normal file
View File

@ -0,0 +1,117 @@
package host
import "context"
// CacheService provides in-memory TTL-based caching capabilities for plugins.
//
// This service allows plugins to store and retrieve typed values (strings, integers,
// floats, and byte slices) with configurable time-to-live expiration. Each plugin's
// cache keys are automatically namespaced to prevent collisions between plugins.
//
// The cache is in-memory only and will be lost on server restart. Plugins should
// handle cache misses gracefully.
//
//nd:hostservice name=Cache permission=cache
type CacheService interface {
// SetString stores a string value in the cache.
//
// Parameters:
// - key: The cache key (will be namespaced with plugin ID)
// - value: The string value to store
// - ttlSeconds: Time-to-live in seconds (0 uses default of 24 hours)
//
// Returns an error if the operation fails.
//nd:hostfunc
SetString(ctx context.Context, key string, value string, ttlSeconds int64) error
// GetString retrieves a string value from the cache.
//
// Parameters:
// - key: The cache key (will be namespaced with plugin ID)
//
// Returns the value and whether the key exists. If the key doesn't exist
// or the stored value is not a string, exists will be false.
//nd:hostfunc
GetString(ctx context.Context, key string) (value string, exists bool, err error)
// SetInt stores an integer value in the cache.
//
// Parameters:
// - key: The cache key (will be namespaced with plugin ID)
// - value: The integer value to store
// - ttlSeconds: Time-to-live in seconds (0 uses default of 24 hours)
//
// Returns an error if the operation fails.
//nd:hostfunc
SetInt(ctx context.Context, key string, value int64, ttlSeconds int64) error
// GetInt retrieves an integer value from the cache.
//
// Parameters:
// - key: The cache key (will be namespaced with plugin ID)
//
// Returns the value and whether the key exists. If the key doesn't exist
// or the stored value is not an integer, exists will be false.
//nd:hostfunc
GetInt(ctx context.Context, key string) (value int64, exists bool, err error)
// SetFloat stores a float value in the cache.
//
// Parameters:
// - key: The cache key (will be namespaced with plugin ID)
// - value: The float value to store
// - ttlSeconds: Time-to-live in seconds (0 uses default of 24 hours)
//
// Returns an error if the operation fails.
//nd:hostfunc
SetFloat(ctx context.Context, key string, value float64, ttlSeconds int64) error
// GetFloat retrieves a float value from the cache.
//
// Parameters:
// - key: The cache key (will be namespaced with plugin ID)
//
// Returns the value and whether the key exists. If the key doesn't exist
// or the stored value is not a float, exists will be false.
//nd:hostfunc
GetFloat(ctx context.Context, key string) (value float64, exists bool, err error)
// SetBytes stores a byte slice in the cache.
//
// Parameters:
// - key: The cache key (will be namespaced with plugin ID)
// - value: The byte slice to store
// - ttlSeconds: Time-to-live in seconds (0 uses default of 24 hours)
//
// Returns an error if the operation fails.
//nd:hostfunc
SetBytes(ctx context.Context, key string, value []byte, ttlSeconds int64) error
// GetBytes retrieves a byte slice from the cache.
//
// Parameters:
// - key: The cache key (will be namespaced with plugin ID)
//
// Returns the value and whether the key exists. If the key doesn't exist
// or the stored value is not a byte slice, exists will be false.
//nd:hostfunc
GetBytes(ctx context.Context, key string) (value []byte, exists bool, err error)
// Has checks if a key exists in the cache.
//
// Parameters:
// - key: The cache key (will be namespaced with plugin ID)
//
// Returns true if the key exists and has not expired.
//nd:hostfunc
Has(ctx context.Context, key string) (exists bool, err error)
// Remove deletes a value from the cache.
//
// Parameters:
// - key: The cache key (will be namespaced with plugin ID)
//
// Returns an error if the operation fails. Does not return an error if the key doesn't exist.
//nd:hostfunc
Remove(ctx context.Context, key string) error
}

384
plugins/host/cache_gen.go Normal file
View File

@ -0,0 +1,384 @@
// Code generated by hostgen. DO NOT EDIT.
package host
import (
"context"
"encoding/json"
extism "github.com/extism/go-sdk"
)
// CacheGetStringResponse is the response type for Cache.GetString.
type CacheGetStringResponse struct {
Value string `json:"value,omitempty"`
Exists bool `json:"exists,omitempty"`
Error string `json:"error,omitempty"`
}
// CacheGetIntResponse is the response type for Cache.GetInt.
type CacheGetIntResponse struct {
Value int64 `json:"value,omitempty"`
Exists bool `json:"exists,omitempty"`
Error string `json:"error,omitempty"`
}
// CacheGetFloatResponse is the response type for Cache.GetFloat.
type CacheGetFloatResponse struct {
Value float64 `json:"value,omitempty"`
Exists bool `json:"exists,omitempty"`
Error string `json:"error,omitempty"`
}
// CacheGetBytesResponse is the response type for Cache.GetBytes.
type CacheGetBytesResponse struct {
Value []byte `json:"value,omitempty"`
Exists bool `json:"exists,omitempty"`
Error string `json:"error,omitempty"`
}
// CacheHasResponse is the response type for Cache.Has.
type CacheHasResponse struct {
Exists bool `json:"exists,omitempty"`
Error string `json:"error,omitempty"`
}
// RegisterCacheHostFunctions registers Cache service host functions.
// The returned host functions should be added to the plugin's configuration.
func RegisterCacheHostFunctions(service CacheService) []extism.HostFunction {
return []extism.HostFunction{
newCacheSetStringHostFunction(service),
newCacheGetStringHostFunction(service),
newCacheSetIntHostFunction(service),
newCacheGetIntHostFunction(service),
newCacheSetFloatHostFunction(service),
newCacheGetFloatHostFunction(service),
newCacheSetBytesHostFunction(service),
newCacheGetBytesHostFunction(service),
newCacheHasHostFunction(service),
newCacheRemoveHostFunction(service),
}
}
func newCacheSetStringHostFunction(service CacheService) extism.HostFunction {
return extism.NewHostFunctionWithStack(
"cache_setstring",
func(ctx context.Context, p *extism.CurrentPlugin, stack []uint64) {
// Read parameters from stack
key, err := p.ReadString(stack[0])
if err != nil {
return
}
value, err := p.ReadString(stack[1])
if err != nil {
return
}
ttlSeconds := int64(stack[2])
// Call the service method
err = service.SetString(ctx, key, value, ttlSeconds)
if err != nil {
// Write error string to plugin memory
if ptr, err := p.WriteString(err.Error()); err == nil {
stack[0] = ptr
}
return
}
// Write empty string to indicate success
if ptr, err := p.WriteString(""); err == nil {
stack[0] = ptr
}
},
[]extism.ValueType{extism.ValueTypePTR, extism.ValueTypePTR, extism.ValueTypeI64},
[]extism.ValueType{extism.ValueTypePTR},
)
}
func newCacheGetStringHostFunction(service CacheService) extism.HostFunction {
return extism.NewHostFunctionWithStack(
"cache_getstring",
func(ctx context.Context, p *extism.CurrentPlugin, stack []uint64) {
// Read parameters from stack
key, err := p.ReadString(stack[0])
if err != nil {
return
}
// Call the service method
value, exists, err := service.GetString(ctx, key)
if err != nil {
cacheWriteError(p, stack, err)
return
}
// Write JSON response to plugin memory
resp := CacheGetStringResponse{
Value: value,
Exists: exists,
}
cacheWriteResponse(p, stack, resp)
},
[]extism.ValueType{extism.ValueTypePTR},
[]extism.ValueType{extism.ValueTypePTR},
)
}
func newCacheSetIntHostFunction(service CacheService) extism.HostFunction {
return extism.NewHostFunctionWithStack(
"cache_setint",
func(ctx context.Context, p *extism.CurrentPlugin, stack []uint64) {
// Read parameters from stack
key, err := p.ReadString(stack[0])
if err != nil {
return
}
value := int64(stack[1])
ttlSeconds := int64(stack[2])
// Call the service method
err = service.SetInt(ctx, key, value, ttlSeconds)
if err != nil {
// Write error string to plugin memory
if ptr, err := p.WriteString(err.Error()); err == nil {
stack[0] = ptr
}
return
}
// Write empty string to indicate success
if ptr, err := p.WriteString(""); err == nil {
stack[0] = ptr
}
},
[]extism.ValueType{extism.ValueTypePTR, extism.ValueTypeI64, extism.ValueTypeI64},
[]extism.ValueType{extism.ValueTypePTR},
)
}
func newCacheGetIntHostFunction(service CacheService) extism.HostFunction {
return extism.NewHostFunctionWithStack(
"cache_getint",
func(ctx context.Context, p *extism.CurrentPlugin, stack []uint64) {
// Read parameters from stack
key, err := p.ReadString(stack[0])
if err != nil {
return
}
// Call the service method
value, exists, err := service.GetInt(ctx, key)
if err != nil {
cacheWriteError(p, stack, err)
return
}
// Write JSON response to plugin memory
resp := CacheGetIntResponse{
Value: value,
Exists: exists,
}
cacheWriteResponse(p, stack, resp)
},
[]extism.ValueType{extism.ValueTypePTR},
[]extism.ValueType{extism.ValueTypePTR},
)
}
func newCacheSetFloatHostFunction(service CacheService) extism.HostFunction {
return extism.NewHostFunctionWithStack(
"cache_setfloat",
func(ctx context.Context, p *extism.CurrentPlugin, stack []uint64) {
// Read parameters from stack
key, err := p.ReadString(stack[0])
if err != nil {
return
}
value := extism.DecodeF64(stack[1])
ttlSeconds := int64(stack[2])
// Call the service method
err = service.SetFloat(ctx, key, value, ttlSeconds)
if err != nil {
// Write error string to plugin memory
if ptr, err := p.WriteString(err.Error()); err == nil {
stack[0] = ptr
}
return
}
// Write empty string to indicate success
if ptr, err := p.WriteString(""); err == nil {
stack[0] = ptr
}
},
[]extism.ValueType{extism.ValueTypePTR, extism.ValueTypeF64, extism.ValueTypeI64},
[]extism.ValueType{extism.ValueTypePTR},
)
}
func newCacheGetFloatHostFunction(service CacheService) extism.HostFunction {
return extism.NewHostFunctionWithStack(
"cache_getfloat",
func(ctx context.Context, p *extism.CurrentPlugin, stack []uint64) {
// Read parameters from stack
key, err := p.ReadString(stack[0])
if err != nil {
return
}
// Call the service method
value, exists, err := service.GetFloat(ctx, key)
if err != nil {
cacheWriteError(p, stack, err)
return
}
// Write JSON response to plugin memory
resp := CacheGetFloatResponse{
Value: value,
Exists: exists,
}
cacheWriteResponse(p, stack, resp)
},
[]extism.ValueType{extism.ValueTypePTR},
[]extism.ValueType{extism.ValueTypePTR},
)
}
func newCacheSetBytesHostFunction(service CacheService) extism.HostFunction {
return extism.NewHostFunctionWithStack(
"cache_setbytes",
func(ctx context.Context, p *extism.CurrentPlugin, stack []uint64) {
// Read parameters from stack
key, err := p.ReadString(stack[0])
if err != nil {
return
}
value, err := p.ReadBytes(stack[1])
if err != nil {
return
}
ttlSeconds := int64(stack[2])
// Call the service method
err = service.SetBytes(ctx, key, value, ttlSeconds)
if err != nil {
// Write error string to plugin memory
if ptr, err := p.WriteString(err.Error()); err == nil {
stack[0] = ptr
}
return
}
// Write empty string to indicate success
if ptr, err := p.WriteString(""); err == nil {
stack[0] = ptr
}
},
[]extism.ValueType{extism.ValueTypePTR, extism.ValueTypePTR, extism.ValueTypeI64},
[]extism.ValueType{extism.ValueTypePTR},
)
}
func newCacheGetBytesHostFunction(service CacheService) extism.HostFunction {
return extism.NewHostFunctionWithStack(
"cache_getbytes",
func(ctx context.Context, p *extism.CurrentPlugin, stack []uint64) {
// Read parameters from stack
key, err := p.ReadString(stack[0])
if err != nil {
return
}
// Call the service method
value, exists, err := service.GetBytes(ctx, key)
if err != nil {
cacheWriteError(p, stack, err)
return
}
// Write JSON response to plugin memory
resp := CacheGetBytesResponse{
Value: value,
Exists: exists,
}
cacheWriteResponse(p, stack, resp)
},
[]extism.ValueType{extism.ValueTypePTR},
[]extism.ValueType{extism.ValueTypePTR},
)
}
func newCacheHasHostFunction(service CacheService) extism.HostFunction {
return extism.NewHostFunctionWithStack(
"cache_has",
func(ctx context.Context, p *extism.CurrentPlugin, stack []uint64) {
// Read parameters from stack
key, err := p.ReadString(stack[0])
if err != nil {
return
}
// Call the service method
exists, err := service.Has(ctx, key)
if err != nil {
cacheWriteError(p, stack, err)
return
}
// Write JSON response to plugin memory
resp := CacheHasResponse{
Exists: exists,
}
cacheWriteResponse(p, stack, resp)
},
[]extism.ValueType{extism.ValueTypePTR},
[]extism.ValueType{extism.ValueTypePTR},
)
}
func newCacheRemoveHostFunction(service CacheService) extism.HostFunction {
return extism.NewHostFunctionWithStack(
"cache_remove",
func(ctx context.Context, p *extism.CurrentPlugin, stack []uint64) {
// Read parameters from stack
key, err := p.ReadString(stack[0])
if err != nil {
return
}
// Call the service method
err = service.Remove(ctx, key)
if err != nil {
// Write error string to plugin memory
if ptr, err := p.WriteString(err.Error()); err == nil {
stack[0] = ptr
}
return
}
// Write empty string to indicate success
if ptr, err := p.WriteString(""); err == nil {
stack[0] = ptr
}
},
[]extism.ValueType{extism.ValueTypePTR},
[]extism.ValueType{extism.ValueTypePTR},
)
}
// cacheWriteResponse writes a JSON response to plugin memory.
func cacheWriteResponse(p *extism.CurrentPlugin, stack []uint64, resp any) {
respBytes, err := json.Marshal(resp)
if err != nil {
cacheWriteError(p, stack, err)
return
}
respPtr, err := p.WriteBytes(respBytes)
if err != nil {
stack[0] = 0
return
}
stack[0] = respPtr
}
// cacheWriteError writes an error response to plugin memory.
func cacheWriteError(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
}

View File

@ -0,0 +1,375 @@
// Code generated by hostgen. DO NOT EDIT.
//
// This file contains client wrappers for the Cache host service.
// It is intended for use in Navidrome plugins built with TinyGo.
//
//go:build wasip1
package main
import (
"encoding/json"
"errors"
"github.com/extism/go-pdk"
)
// cache_setstring is the host function provided by Navidrome.
//
//go:wasmimport extism:host/user cache_setstring
func cache_setstring(uint64, uint64, int64) uint64
// cache_getstring is the host function provided by Navidrome.
//
//go:wasmimport extism:host/user cache_getstring
func cache_getstring(uint64) uint64
// cache_setint is the host function provided by Navidrome.
//
//go:wasmimport extism:host/user cache_setint
func cache_setint(uint64, int64, int64) uint64
// cache_getint is the host function provided by Navidrome.
//
//go:wasmimport extism:host/user cache_getint
func cache_getint(uint64) uint64
// cache_setfloat is the host function provided by Navidrome.
//
//go:wasmimport extism:host/user cache_setfloat
func cache_setfloat(uint64, float64, int64) uint64
// cache_getfloat is the host function provided by Navidrome.
//
//go:wasmimport extism:host/user cache_getfloat
func cache_getfloat(uint64) uint64
// cache_setbytes is the host function provided by Navidrome.
//
//go:wasmimport extism:host/user cache_setbytes
func cache_setbytes(uint64, uint64, int64) uint64
// cache_getbytes is the host function provided by Navidrome.
//
//go:wasmimport extism:host/user cache_getbytes
func cache_getbytes(uint64) uint64
// cache_has is the host function provided by Navidrome.
//
//go:wasmimport extism:host/user cache_has
func cache_has(uint64) uint64
// cache_remove is the host function provided by Navidrome.
//
//go:wasmimport extism:host/user cache_remove
func cache_remove(uint64) uint64
// CacheGetStringResponse is the response type for Cache.GetString.
type CacheGetStringResponse struct {
Value string `json:"value,omitempty"`
Exists bool `json:"exists,omitempty"`
Error string `json:"error,omitempty"`
}
// CacheGetIntResponse is the response type for Cache.GetInt.
type CacheGetIntResponse struct {
Value int64 `json:"value,omitempty"`
Exists bool `json:"exists,omitempty"`
Error string `json:"error,omitempty"`
}
// CacheGetFloatResponse is the response type for Cache.GetFloat.
type CacheGetFloatResponse struct {
Value float64 `json:"value,omitempty"`
Exists bool `json:"exists,omitempty"`
Error string `json:"error,omitempty"`
}
// CacheGetBytesResponse is the response type for Cache.GetBytes.
type CacheGetBytesResponse struct {
Value []byte `json:"value,omitempty"`
Exists bool `json:"exists,omitempty"`
Error string `json:"error,omitempty"`
}
// CacheHasResponse is the response type for Cache.Has.
type CacheHasResponse struct {
Exists bool `json:"exists,omitempty"`
Error string `json:"error,omitempty"`
}
// CacheSetString calls the cache_setstring host function.
// SetString stores a string value in the cache.
//
// Parameters:
// - key: The cache key (will be namespaced with plugin ID)
// - value: The string value to store
// - ttlSeconds: Time-to-live in seconds (0 uses default of 24 hours)
//
// Returns an error if the operation fails.
func CacheSetString(key string, value string, ttlSeconds int64) error {
keyMem := pdk.AllocateString(key)
defer keyMem.Free()
valueMem := pdk.AllocateString(value)
defer valueMem.Free()
// Call the host function
responsePtr := cache_setstring(keyMem.Offset(), valueMem.Offset(), ttlSeconds)
// Read the response from memory
responseMem := pdk.FindMemory(responsePtr)
errStr := string(responseMem.ReadBytes())
if errStr != "" {
return errors.New(errStr)
}
return nil
}
// CacheGetString calls the cache_getstring host function.
// GetString retrieves a string value from the cache.
//
// Parameters:
// - key: The cache key (will be namespaced with plugin ID)
//
// Returns the value and whether the key exists. If the key doesn't exist
// or the stored value is not a string, exists will be false.
func CacheGetString(key string) (*CacheGetStringResponse, error) {
keyMem := pdk.AllocateString(key)
defer keyMem.Free()
// Call the host function
responsePtr := cache_getstring(keyMem.Offset())
// Read the response from memory
responseMem := pdk.FindMemory(responsePtr)
responseBytes := responseMem.ReadBytes()
// Parse the response
var response CacheGetStringResponse
if err := json.Unmarshal(responseBytes, &response); err != nil {
return nil, err
}
return &response, nil
}
// CacheSetInt calls the cache_setint host function.
// SetInt stores an integer value in the cache.
//
// Parameters:
// - key: The cache key (will be namespaced with plugin ID)
// - value: The integer value to store
// - ttlSeconds: Time-to-live in seconds (0 uses default of 24 hours)
//
// Returns an error if the operation fails.
func CacheSetInt(key string, value int64, ttlSeconds int64) error {
keyMem := pdk.AllocateString(key)
defer keyMem.Free()
// Call the host function
responsePtr := cache_setint(keyMem.Offset(), value, ttlSeconds)
// Read the response from memory
responseMem := pdk.FindMemory(responsePtr)
errStr := string(responseMem.ReadBytes())
if errStr != "" {
return errors.New(errStr)
}
return nil
}
// CacheGetInt calls the cache_getint host function.
// GetInt retrieves an integer value from the cache.
//
// Parameters:
// - key: The cache key (will be namespaced with plugin ID)
//
// Returns the value and whether the key exists. If the key doesn't exist
// or the stored value is not an integer, exists will be false.
func CacheGetInt(key string) (*CacheGetIntResponse, error) {
keyMem := pdk.AllocateString(key)
defer keyMem.Free()
// Call the host function
responsePtr := cache_getint(keyMem.Offset())
// Read the response from memory
responseMem := pdk.FindMemory(responsePtr)
responseBytes := responseMem.ReadBytes()
// Parse the response
var response CacheGetIntResponse
if err := json.Unmarshal(responseBytes, &response); err != nil {
return nil, err
}
return &response, nil
}
// CacheSetFloat calls the cache_setfloat host function.
// SetFloat stores a float value in the cache.
//
// Parameters:
// - key: The cache key (will be namespaced with plugin ID)
// - value: The float value to store
// - ttlSeconds: Time-to-live in seconds (0 uses default of 24 hours)
//
// Returns an error if the operation fails.
func CacheSetFloat(key string, value float64, ttlSeconds int64) error {
keyMem := pdk.AllocateString(key)
defer keyMem.Free()
// Call the host function
responsePtr := cache_setfloat(keyMem.Offset(), value, ttlSeconds)
// Read the response from memory
responseMem := pdk.FindMemory(responsePtr)
errStr := string(responseMem.ReadBytes())
if errStr != "" {
return errors.New(errStr)
}
return nil
}
// CacheGetFloat calls the cache_getfloat host function.
// GetFloat retrieves a float value from the cache.
//
// Parameters:
// - key: The cache key (will be namespaced with plugin ID)
//
// Returns the value and whether the key exists. If the key doesn't exist
// or the stored value is not a float, exists will be false.
func CacheGetFloat(key string) (*CacheGetFloatResponse, error) {
keyMem := pdk.AllocateString(key)
defer keyMem.Free()
// Call the host function
responsePtr := cache_getfloat(keyMem.Offset())
// Read the response from memory
responseMem := pdk.FindMemory(responsePtr)
responseBytes := responseMem.ReadBytes()
// Parse the response
var response CacheGetFloatResponse
if err := json.Unmarshal(responseBytes, &response); err != nil {
return nil, err
}
return &response, nil
}
// CacheSetBytes calls the cache_setbytes host function.
// SetBytes stores a byte slice in the cache.
//
// Parameters:
// - key: The cache key (will be namespaced with plugin ID)
// - value: The byte slice to store
// - ttlSeconds: Time-to-live in seconds (0 uses default of 24 hours)
//
// Returns an error if the operation fails.
func CacheSetBytes(key string, value []byte, ttlSeconds int64) error {
keyMem := pdk.AllocateString(key)
defer keyMem.Free()
valueMem := pdk.AllocateBytes(value)
defer valueMem.Free()
// Call the host function
responsePtr := cache_setbytes(keyMem.Offset(), valueMem.Offset(), ttlSeconds)
// Read the response from memory
responseMem := pdk.FindMemory(responsePtr)
errStr := string(responseMem.ReadBytes())
if errStr != "" {
return errors.New(errStr)
}
return nil
}
// CacheGetBytes calls the cache_getbytes host function.
// GetBytes retrieves a byte slice from the cache.
//
// Parameters:
// - key: The cache key (will be namespaced with plugin ID)
//
// Returns the value and whether the key exists. If the key doesn't exist
// or the stored value is not a byte slice, exists will be false.
func CacheGetBytes(key string) (*CacheGetBytesResponse, error) {
keyMem := pdk.AllocateString(key)
defer keyMem.Free()
// Call the host function
responsePtr := cache_getbytes(keyMem.Offset())
// Read the response from memory
responseMem := pdk.FindMemory(responsePtr)
responseBytes := responseMem.ReadBytes()
// Parse the response
var response CacheGetBytesResponse
if err := json.Unmarshal(responseBytes, &response); err != nil {
return nil, err
}
return &response, nil
}
// CacheHas calls the cache_has host function.
// Has checks if a key exists in the cache.
//
// Parameters:
// - key: The cache key (will be namespaced with plugin ID)
//
// Returns true if the key exists and has not expired.
func CacheHas(key string) (*CacheHasResponse, error) {
keyMem := pdk.AllocateString(key)
defer keyMem.Free()
// Call the host function
responsePtr := cache_has(keyMem.Offset())
// Read the response from memory
responseMem := pdk.FindMemory(responsePtr)
responseBytes := responseMem.ReadBytes()
// Parse the response
var response CacheHasResponse
if err := json.Unmarshal(responseBytes, &response); err != nil {
return nil, err
}
return &response, nil
}
// CacheRemove calls the cache_remove host function.
// Remove deletes a value from the cache.
//
// Parameters:
// - key: The cache key (will be namespaced with plugin ID)
//
// Returns an error if the operation fails. Does not return an error if the key doesn't exist.
func CacheRemove(key string) error {
keyMem := pdk.AllocateString(key)
defer keyMem.Free()
// Call the host function
responsePtr := cache_remove(keyMem.Offset())
// Read the response from memory
responseMem := pdk.FindMemory(responsePtr)
errStr := string(responseMem.ReadBytes())
if errStr != "" {
return errors.New(errStr)
}
return nil
}

View File

@ -41,9 +41,4 @@ type SchedulerService interface {
// Returns an error if the schedule ID is not found or if cancellation fails.
//nd:hostfunc
CancelSchedule(ctx context.Context, scheduleID string) error
// Close cleans up any resources used by the SchedulerService.
//
// This should be called when the plugin is unloaded to ensure proper cleanup.
Close() error
}

View File

@ -56,10 +56,4 @@ type WebSocketService interface {
// Returns an error if the connection is not found or if closing fails.
//nd:hostfunc
CloseConnection(ctx context.Context, connectionID string, code int32, reason string) error
// Close cleans up any resources used by the WebSocketService.
//
// This should be called when the plugin is unloaded to ensure proper cleanup
// of all active WebSocket connections.
Close() error
}

153
plugins/host_cache.go Normal file
View File

@ -0,0 +1,153 @@
package plugins
import (
"context"
"time"
"github.com/jellydator/ttlcache/v3"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/plugins/host"
)
const (
defaultCacheTTL = 24 * time.Hour
)
// cacheServiceImpl implements the host.CacheService interface.
// Each plugin gets its own cache instance for isolation.
type cacheServiceImpl struct {
pluginName string
cache *ttlcache.Cache[string, any]
defaultTTL time.Duration
}
// newCacheService creates a new cacheServiceImpl instance with its own cache.
func newCacheService(pluginName string) *cacheServiceImpl {
cache := ttlcache.New[string, any](
ttlcache.WithTTL[string, any](defaultCacheTTL),
)
// Start the janitor goroutine to clean up expired entries
go cache.Start()
return &cacheServiceImpl{
pluginName: pluginName,
cache: cache,
defaultTTL: defaultCacheTTL,
}
}
// getTTL converts seconds to a duration, using default if 0 or negative
func (s *cacheServiceImpl) getTTL(seconds int64) time.Duration {
if seconds <= 0 {
return s.defaultTTL
}
return time.Duration(seconds) * time.Second
}
// SetString stores a string value in the cache.
func (s *cacheServiceImpl) SetString(ctx context.Context, key string, value string, ttlSeconds int64) error {
s.cache.Set(key, value, s.getTTL(ttlSeconds))
return nil
}
// GetString retrieves a string value from the cache.
func (s *cacheServiceImpl) GetString(ctx context.Context, key string) (string, bool, error) {
item := s.cache.Get(key)
if item == nil {
return "", false, nil
}
value, ok := item.Value().(string)
if !ok {
log.Debug(ctx, "Cache type mismatch", "plugin", s.pluginName, "key", key, "expected", "string")
return "", false, nil
}
return value, true, nil
}
// SetInt stores an integer value in the cache.
func (s *cacheServiceImpl) SetInt(ctx context.Context, key string, value int64, ttlSeconds int64) error {
s.cache.Set(key, value, s.getTTL(ttlSeconds))
return nil
}
// GetInt retrieves an integer value from the cache.
func (s *cacheServiceImpl) GetInt(ctx context.Context, key string) (int64, bool, error) {
item := s.cache.Get(key)
if item == nil {
return 0, false, nil
}
value, ok := item.Value().(int64)
if !ok {
log.Debug(ctx, "Cache type mismatch", "plugin", s.pluginName, "key", key, "expected", "int64")
return 0, false, nil
}
return value, true, nil
}
// SetFloat stores a float value in the cache.
func (s *cacheServiceImpl) SetFloat(ctx context.Context, key string, value float64, ttlSeconds int64) error {
s.cache.Set(key, value, s.getTTL(ttlSeconds))
return nil
}
// GetFloat retrieves a float value from the cache.
func (s *cacheServiceImpl) GetFloat(ctx context.Context, key string) (float64, bool, error) {
item := s.cache.Get(key)
if item == nil {
return 0, false, nil
}
value, ok := item.Value().(float64)
if !ok {
log.Debug(ctx, "Cache type mismatch", "plugin", s.pluginName, "key", key, "expected", "float64")
return 0, false, nil
}
return value, true, nil
}
// SetBytes stores a byte slice in the cache.
func (s *cacheServiceImpl) SetBytes(ctx context.Context, key string, value []byte, ttlSeconds int64) error {
s.cache.Set(key, value, s.getTTL(ttlSeconds))
return nil
}
// GetBytes retrieves a byte slice from the cache.
func (s *cacheServiceImpl) GetBytes(ctx context.Context, key string) ([]byte, bool, error) {
item := s.cache.Get(key)
if item == nil {
return nil, false, nil
}
value, ok := item.Value().([]byte)
if !ok {
log.Debug(ctx, "Cache type mismatch", "plugin", s.pluginName, "key", key, "expected", "[]byte")
return nil, false, nil
}
return value, true, nil
}
// Has checks if a key exists in the cache.
func (s *cacheServiceImpl) Has(ctx context.Context, key string) (bool, error) {
item := s.cache.Get(key)
return item != nil, nil
}
// Remove deletes a value from the cache.
func (s *cacheServiceImpl) Remove(ctx context.Context, key string) error {
s.cache.Delete(key)
return nil
}
// Close stops the cache's janitor goroutine and clears all entries.
// This is called when the plugin is unloaded.
func (s *cacheServiceImpl) Close() error {
s.cache.Stop()
s.cache.DeleteAll()
log.Debug("Closed plugin cache", "plugin", s.pluginName)
return nil
}
// Ensure cacheServiceImpl implements host.CacheService
var _ host.CacheService = (*cacheServiceImpl)(nil)

555
plugins/host_cache_test.go Normal file
View File

@ -0,0 +1,555 @@
//go:build !windows
package plugins
import (
"context"
"encoding/json"
"errors"
"os"
"path/filepath"
"time"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/conf/configtest"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
var _ = Describe("CacheService", func() {
var service *cacheServiceImpl
var ctx context.Context
BeforeEach(func() {
ctx = context.Background()
service = newCacheService("test_plugin")
})
AfterEach(func() {
if service != nil {
service.Close()
}
})
Describe("getTTL", func() {
It("returns default TTL when seconds is 0", func() {
ttl := service.getTTL(0)
Expect(ttl).To(Equal(defaultCacheTTL))
})
It("returns default TTL when seconds is negative", func() {
ttl := service.getTTL(-10)
Expect(ttl).To(Equal(defaultCacheTTL))
})
It("returns correct duration when seconds is positive", func() {
ttl := service.getTTL(60)
Expect(ttl).To(Equal(time.Minute))
})
})
Describe("Plugin Isolation", func() {
It("isolates keys between plugins", func() {
service1 := newCacheService("plugin1")
defer service1.Close()
service2 := newCacheService("plugin2")
defer service2.Close()
// Both plugins set same key
err := service1.SetString(ctx, "shared", "value1", 0)
Expect(err).ToNot(HaveOccurred())
err = service2.SetString(ctx, "shared", "value2", 0)
Expect(err).ToNot(HaveOccurred())
// Each plugin should get their own value
val1, exists1, err := service1.GetString(ctx, "shared")
Expect(err).ToNot(HaveOccurred())
Expect(exists1).To(BeTrue())
Expect(val1).To(Equal("value1"))
val2, exists2, err := service2.GetString(ctx, "shared")
Expect(err).ToNot(HaveOccurred())
Expect(exists2).To(BeTrue())
Expect(val2).To(Equal("value2"))
})
})
Describe("String Operations", func() {
It("sets and gets a string value", func() {
err := service.SetString(ctx, "string_key", "test_value", 300)
Expect(err).ToNot(HaveOccurred())
value, exists, err := service.GetString(ctx, "string_key")
Expect(err).ToNot(HaveOccurred())
Expect(exists).To(BeTrue())
Expect(value).To(Equal("test_value"))
})
It("returns not exists for missing key", func() {
value, exists, err := service.GetString(ctx, "missing_key")
Expect(err).ToNot(HaveOccurred())
Expect(exists).To(BeFalse())
Expect(value).To(Equal(""))
})
})
Describe("Integer Operations", func() {
It("sets and gets an integer value", func() {
err := service.SetInt(ctx, "int_key", 42, 300)
Expect(err).ToNot(HaveOccurred())
value, exists, err := service.GetInt(ctx, "int_key")
Expect(err).ToNot(HaveOccurred())
Expect(exists).To(BeTrue())
Expect(value).To(Equal(int64(42)))
})
It("returns not exists for missing key", func() {
value, exists, err := service.GetInt(ctx, "missing_int_key")
Expect(err).ToNot(HaveOccurred())
Expect(exists).To(BeFalse())
Expect(value).To(Equal(int64(0)))
})
})
Describe("Float Operations", func() {
It("sets and gets a float value", func() {
err := service.SetFloat(ctx, "float_key", 3.14, 300)
Expect(err).ToNot(HaveOccurred())
value, exists, err := service.GetFloat(ctx, "float_key")
Expect(err).ToNot(HaveOccurred())
Expect(exists).To(BeTrue())
Expect(value).To(Equal(3.14))
})
It("returns not exists for missing key", func() {
value, exists, err := service.GetFloat(ctx, "missing_float_key")
Expect(err).ToNot(HaveOccurred())
Expect(exists).To(BeFalse())
Expect(value).To(Equal(float64(0)))
})
})
Describe("Bytes Operations", func() {
It("sets and gets a bytes value", func() {
byteData := []byte("hello world")
err := service.SetBytes(ctx, "bytes_key", byteData, 300)
Expect(err).ToNot(HaveOccurred())
value, exists, err := service.GetBytes(ctx, "bytes_key")
Expect(err).ToNot(HaveOccurred())
Expect(exists).To(BeTrue())
Expect(value).To(Equal(byteData))
})
It("returns not exists for missing key", func() {
value, exists, err := service.GetBytes(ctx, "missing_bytes_key")
Expect(err).ToNot(HaveOccurred())
Expect(exists).To(BeFalse())
Expect(value).To(BeNil())
})
})
Describe("Type mismatch handling", func() {
It("returns not exists when type doesn't match the getter", func() {
// Set string
err := service.SetString(ctx, "mixed_key", "string value", 0)
Expect(err).ToNot(HaveOccurred())
// Try to get as int
value, exists, err := service.GetInt(ctx, "mixed_key")
Expect(err).ToNot(HaveOccurred())
Expect(exists).To(BeFalse())
Expect(value).To(Equal(int64(0)))
})
It("returns not exists when getting string as float", func() {
err := service.SetString(ctx, "str_as_float", "not a float", 0)
Expect(err).ToNot(HaveOccurred())
value, exists, err := service.GetFloat(ctx, "str_as_float")
Expect(err).ToNot(HaveOccurred())
Expect(exists).To(BeFalse())
Expect(value).To(Equal(float64(0)))
})
It("returns not exists when getting int as bytes", func() {
err := service.SetInt(ctx, "int_as_bytes", 123, 0)
Expect(err).ToNot(HaveOccurred())
value, exists, err := service.GetBytes(ctx, "int_as_bytes")
Expect(err).ToNot(HaveOccurred())
Expect(exists).To(BeFalse())
Expect(value).To(BeNil())
})
})
Describe("Has Operation", func() {
It("returns true for existing key", func() {
err := service.SetString(ctx, "existing_key", "exists", 0)
Expect(err).ToNot(HaveOccurred())
exists, err := service.Has(ctx, "existing_key")
Expect(err).ToNot(HaveOccurred())
Expect(exists).To(BeTrue())
})
It("returns false for non-existing key", func() {
exists, err := service.Has(ctx, "non_existing_key")
Expect(err).ToNot(HaveOccurred())
Expect(exists).To(BeFalse())
})
})
Describe("Remove Operation", func() {
It("removes a value from the cache", func() {
// Set a value
err := service.SetString(ctx, "remove_key", "to be removed", 0)
Expect(err).ToNot(HaveOccurred())
// Verify it exists
exists, err := service.Has(ctx, "remove_key")
Expect(err).ToNot(HaveOccurred())
Expect(exists).To(BeTrue())
// Remove it
err = service.Remove(ctx, "remove_key")
Expect(err).ToNot(HaveOccurred())
// Verify it's gone
exists, err = service.Has(ctx, "remove_key")
Expect(err).ToNot(HaveOccurred())
Expect(exists).To(BeFalse())
})
It("does not error when removing non-existing key", func() {
err := service.Remove(ctx, "never_existed")
Expect(err).ToNot(HaveOccurred())
})
})
Describe("TTL Behavior", func() {
It("uses default TTL when 0 is provided", func() {
err := service.SetString(ctx, "default_ttl", "value", 0)
Expect(err).ToNot(HaveOccurred())
// Value should exist immediately
exists, err := service.Has(ctx, "default_ttl")
Expect(err).ToNot(HaveOccurred())
Expect(exists).To(BeTrue())
})
It("uses custom TTL when provided", func() {
err := service.SetString(ctx, "custom_ttl", "value", 300)
Expect(err).ToNot(HaveOccurred())
// Value should exist immediately
exists, err := service.Has(ctx, "custom_ttl")
Expect(err).ToNot(HaveOccurred())
Expect(exists).To(BeTrue())
})
})
Describe("Close", func() {
It("removes all cache entries for the plugin", func() {
// Use a dedicated service for this test
closeService := newCacheService("close_test_plugin")
// Set multiple values
err := closeService.SetString(ctx, "key1", "value1", 0)
Expect(err).ToNot(HaveOccurred())
err = closeService.SetInt(ctx, "key2", 42, 0)
Expect(err).ToNot(HaveOccurred())
err = closeService.SetFloat(ctx, "key3", 3.14, 0)
Expect(err).ToNot(HaveOccurred())
// Verify they exist
exists, _ := closeService.Has(ctx, "key1")
Expect(exists).To(BeTrue())
exists, _ = closeService.Has(ctx, "key2")
Expect(exists).To(BeTrue())
exists, _ = closeService.Has(ctx, "key3")
Expect(exists).To(BeTrue())
// Close the service
err = closeService.Close()
Expect(err).ToNot(HaveOccurred())
// All entries should be gone
exists, _ = closeService.Has(ctx, "key1")
Expect(exists).To(BeFalse())
exists, _ = closeService.Has(ctx, "key2")
Expect(exists).To(BeFalse())
exists, _ = closeService.Has(ctx, "key3")
Expect(exists).To(BeFalse())
})
It("does not affect other plugins' cache entries", func() {
// Create two services for different plugins
service1 := newCacheService("plugin_close_test1")
service2 := newCacheService("plugin_close_test2")
defer service2.Close()
// Set values for both plugins
err := service1.SetString(ctx, "key", "value1", 0)
Expect(err).ToNot(HaveOccurred())
err = service2.SetString(ctx, "key", "value2", 0)
Expect(err).ToNot(HaveOccurred())
// Close only service1
err = service1.Close()
Expect(err).ToNot(HaveOccurred())
// service1's key should be gone
exists, _ := service1.Has(ctx, "key")
Expect(exists).To(BeFalse())
// service2's key should still exist
exists, _ = service2.Has(ctx, "key")
Expect(exists).To(BeTrue())
})
})
})
var _ = Describe("CacheService Integration", Ordered, func() {
var (
manager *Manager
tmpDir string
)
BeforeAll(func() {
var err error
tmpDir, err = os.MkdirTemp("", "cache-test-*")
Expect(err).ToNot(HaveOccurred())
// Copy the fake_cache_plugin
srcPath := filepath.Join(testdataDir, "fake_cache_plugin.wasm")
destPath := filepath.Join(tmpDir, "fake_cache_plugin.wasm")
data, err := os.ReadFile(srcPath)
Expect(err).ToNot(HaveOccurred())
err = os.WriteFile(destPath, data, 0600)
Expect(err).ToNot(HaveOccurred())
// 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")
// Create and start manager
manager = &Manager{
plugins: make(map[string]*plugin),
}
err = manager.Start(GinkgoT().Context())
Expect(err).ToNot(HaveOccurred())
DeferCleanup(func() {
_ = manager.Stop()
_ = os.RemoveAll(tmpDir)
})
})
Describe("Plugin Loading", func() {
It("should load plugin with cache permission", func() {
manager.mu.RLock()
p, ok := manager.plugins["fake_cache_plugin"]
manager.mu.RUnlock()
Expect(ok).To(BeTrue())
Expect(p.manifest.Permissions).ToNot(BeNil())
Expect(p.manifest.Permissions.Cache).ToNot(BeNil())
})
})
Describe("Cache Operations via Plugin", func() {
type testCacheInput struct {
Operation string `json:"operation"`
Key string `json:"key"`
StringVal string `json:"string_val,omitempty"`
IntVal int64 `json:"int_val,omitempty"`
FloatVal float64 `json:"float_val,omitempty"`
BytesVal []byte `json:"bytes_val,omitempty"`
TTLSeconds int64 `json:"ttl_seconds,omitempty"`
}
type testCacheOutput struct {
StringVal string `json:"string_val,omitempty"`
IntVal int64 `json:"int_val,omitempty"`
FloatVal float64 `json:"float_val,omitempty"`
BytesVal []byte `json:"bytes_val,omitempty"`
Exists bool `json:"exists,omitempty"`
Error *string `json:"error,omitempty"`
}
callTestCache := func(ctx context.Context, input testCacheInput) (*testCacheOutput, error) {
manager.mu.RLock()
p := manager.plugins["fake_cache_plugin"]
manager.mu.RUnlock()
instance, err := p.instance()
if err != nil {
return nil, err
}
defer instance.Close(ctx)
inputBytes, _ := json.Marshal(input)
_, outputBytes, err := instance.Call("nd_test_cache", inputBytes)
if err != nil {
return nil, err
}
var output testCacheOutput
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 set and get string value", func() {
ctx := GinkgoT().Context()
// Set string
_, err := callTestCache(ctx, testCacheInput{
Operation: "set_string",
Key: "test_string",
StringVal: "hello world",
TTLSeconds: 300,
})
Expect(err).ToNot(HaveOccurred())
// Get string
output, err := callTestCache(ctx, testCacheInput{
Operation: "get_string",
Key: "test_string",
})
Expect(err).ToNot(HaveOccurred())
Expect(output.Exists).To(BeTrue())
Expect(output.StringVal).To(Equal("hello world"))
})
It("should set and get integer value", func() {
ctx := GinkgoT().Context()
// Set int
_, err := callTestCache(ctx, testCacheInput{
Operation: "set_int",
Key: "test_int",
IntVal: 42,
TTLSeconds: 300,
})
Expect(err).ToNot(HaveOccurred())
// Get int
output, err := callTestCache(ctx, testCacheInput{
Operation: "get_int",
Key: "test_int",
})
Expect(err).ToNot(HaveOccurred())
Expect(output.Exists).To(BeTrue())
Expect(output.IntVal).To(Equal(int64(42)))
})
It("should set and get float value", func() {
ctx := GinkgoT().Context()
// Set float
_, err := callTestCache(ctx, testCacheInput{
Operation: "set_float",
Key: "test_float",
FloatVal: 3.14159,
TTLSeconds: 300,
})
Expect(err).ToNot(HaveOccurred())
// Get float
output, err := callTestCache(ctx, testCacheInput{
Operation: "get_float",
Key: "test_float",
})
Expect(err).ToNot(HaveOccurred())
Expect(output.Exists).To(BeTrue())
Expect(output.FloatVal).To(Equal(3.14159))
})
It("should set and get bytes value", func() {
ctx := GinkgoT().Context()
testBytes := []byte{0x01, 0x02, 0x03, 0x04}
// Set bytes
_, err := callTestCache(ctx, testCacheInput{
Operation: "set_bytes",
Key: "test_bytes",
BytesVal: testBytes,
TTLSeconds: 300,
})
Expect(err).ToNot(HaveOccurred())
// Get bytes
output, err := callTestCache(ctx, testCacheInput{
Operation: "get_bytes",
Key: "test_bytes",
})
Expect(err).ToNot(HaveOccurred())
Expect(output.Exists).To(BeTrue())
Expect(output.BytesVal).To(Equal(testBytes))
})
It("should check if key exists", func() {
ctx := GinkgoT().Context()
// Set a value
_, err := callTestCache(ctx, testCacheInput{
Operation: "set_string",
Key: "exists_test",
StringVal: "value",
})
Expect(err).ToNot(HaveOccurred())
// Check has
output, err := callTestCache(ctx, testCacheInput{
Operation: "has",
Key: "exists_test",
})
Expect(err).ToNot(HaveOccurred())
Expect(output.Exists).To(BeTrue())
// Check non-existent
output, err = callTestCache(ctx, testCacheInput{
Operation: "has",
Key: "nonexistent",
})
Expect(err).ToNot(HaveOccurred())
Expect(output.Exists).To(BeFalse())
})
It("should remove a key", func() {
ctx := GinkgoT().Context()
// Set a value
_, err := callTestCache(ctx, testCacheInput{
Operation: "set_string",
Key: "remove_test",
StringVal: "value",
})
Expect(err).ToNot(HaveOccurred())
// Remove it
_, err = callTestCache(ctx, testCacheInput{
Operation: "remove",
Key: "remove_test",
})
Expect(err).ToNot(HaveOccurred())
// Verify it's gone
output, err := callTestCache(ctx, testCacheInput{
Operation: "has",
Key: "remove_test",
})
Expect(err).ToNot(HaveOccurred())
Expect(output.Exists).To(BeFalse())
})
})
})

View File

@ -50,7 +50,7 @@ type schedulerServiceImpl struct {
}
// newSchedulerService creates a new SchedulerService for a plugin.
func newSchedulerService(pluginName string, manager *Manager, sched scheduler.Scheduler) host.SchedulerService {
func newSchedulerService(pluginName string, manager *Manager, sched scheduler.Scheduler) *schedulerServiceImpl {
return &schedulerServiceImpl{
pluginName: pluginName,
manager: manager,

View File

@ -59,7 +59,7 @@ type webSocketServiceImpl struct {
}
// newWebSocketService creates a new WebSocketService for a plugin.
func newWebSocketService(pluginName string, manager *Manager, allowedHosts []string) host.WebSocketService {
func newWebSocketService(pluginName string, manager *Manager, allowedHosts []string) *webSocketServiceImpl {
return &webSocketServiceImpl{
pluginName: pluginName,
manager: manager,

View File

@ -363,6 +363,7 @@ func (m *Manager) loadPlugin(name, wasmPath string) error {
stubHostFunctions = append(stubHostFunctions, host.RegisterSchedulerHostFunctions(nil)...)
stubHostFunctions = append(stubHostFunctions, host.RegisterWebSocketHostFunctions(nil)...)
stubHostFunctions = append(stubHostFunctions, host.RegisterArtworkHostFunctions(nil)...)
stubHostFunctions = append(stubHostFunctions, host.RegisterCacheHostFunctions(nil)...)
// Create initial compiled plugin with stub host functions
compiled, err := extism.NewCompiledPlugin(m.ctx, pluginManifest, extismConfig, stubHostFunctions)
@ -440,6 +441,13 @@ func (m *Manager) loadPlugin(name, wasmPath string) error {
hostFunctions = append(hostFunctions, host.RegisterArtworkHostFunctions(service)...)
}
// Register Cache host functions if permission is granted
if manifest.Permissions != nil && manifest.Permissions.Cache != nil {
service := newCacheService(name)
closers = append(closers, service)
hostFunctions = append(hostFunctions, host.RegisterCacheHostFunctions(service)...)
}
// Check if recompilation is needed (AllowedHosts or host functions)
needsRecompile := len(pluginManifest.AllowedHosts) > 0 || len(hostFunctions) > 0

View File

@ -55,6 +55,9 @@
},
"artwork": {
"$ref": "#/$defs/ArtworkPermission"
},
"cache": {
"$ref": "#/$defs/CachePermission"
}
}
},
@ -69,6 +72,17 @@
}
}
},
"CachePermission": {
"type": "object",
"description": "Cache service permissions for storing and retrieving data",
"additionalProperties": false,
"properties": {
"reason": {
"type": "string",
"description": "Explanation for why cache access is needed"
}
}
},
"HTTPPermission": {
"type": "object",
"description": "HTTP access permissions for a plugin",

View File

@ -11,6 +11,12 @@ type ArtworkPermission struct {
Reason *string `json:"reason,omitempty" yaml:"reason,omitempty" mapstructure:"reason,omitempty"`
}
// Cache service permissions for storing and retrieving data
type CachePermission struct {
// Explanation for why cache access is needed
Reason *string `json:"reason,omitempty" yaml:"reason,omitempty" mapstructure:"reason,omitempty"`
}
// Configuration access permissions for a plugin
type ConfigPermission struct {
// Explanation for why config access is needed
@ -86,6 +92,9 @@ type Permissions struct {
// Artwork corresponds to the JSON schema field "artwork".
Artwork *ArtworkPermission `json:"artwork,omitempty" yaml:"artwork,omitempty" mapstructure:"artwork,omitempty"`
// Cache corresponds to the JSON schema field "cache".
Cache *CachePermission `json:"cache,omitempty" yaml:"cache,omitempty" mapstructure:"cache,omitempty"`
// Http corresponds to the JSON schema field "http".
Http *HTTPPermission `json:"http,omitempty" yaml:"http,omitempty" mapstructure:"http,omitempty"`

View File

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

View File

@ -0,0 +1,2 @@
github.com/extism/go-pdk v1.1.3 h1:hfViMPWrqjN6u67cIYRALZTZLk/enSPpNKa+rZ9X2SQ=
github.com/extism/go-pdk v1.1.3/go.mod h1:Gz+LIU/YCKnKXhgge8yo5Yu1F/lbv7KtKFkiCSzW/P4=

View File

@ -0,0 +1,210 @@
// Fake Cache plugin for Navidrome plugin system integration tests.
// Build with: tinygo build -o ../fake_cache_plugin.wasm -target wasip1 -buildmode=c-shared .
package main
import (
"encoding/json"
pdk "github.com/extism/go-pdk"
)
// Manifest types
type Manifest struct {
Name string `json:"name"`
Author string `json:"author"`
Version string `json:"version"`
Description string `json:"description"`
Permissions *Permissions `json:"permissions,omitempty"`
}
type Permissions struct {
Cache *CachePermission `json:"cache,omitempty"`
}
type CachePermission struct {
Reason string `json:"reason,omitempty"`
}
//go:wasmexport nd_manifest
func ndManifest() int32 {
manifest := Manifest{
Name: "Fake Cache Plugin",
Author: "Navidrome Test",
Version: "1.0.0",
Description: "A fake cache plugin for integration testing",
Permissions: &Permissions{
Cache: &CachePermission{
Reason: "For testing cache operations",
},
},
}
out, err := json.Marshal(manifest)
if err != nil {
pdk.SetError(err)
return 1
}
pdk.Output(out)
return 0
}
// TestCacheInput is the input for nd_test_cache callback.
type TestCacheInput struct {
Operation string `json:"operation"` // "set_string", "get_string", "set_int", "get_int", "set_float", "get_float", "set_bytes", "get_bytes", "has", "remove"
Key string `json:"key"` // Cache key
StringVal string `json:"string_val"` // For string operations
IntVal int64 `json:"int_val"` // For int operations
FloatVal float64 `json:"float_val"` // For float operations
BytesVal []byte `json:"bytes_val"` // For bytes operations
TTLSeconds int64 `json:"ttl_seconds"` // TTL in seconds
}
// TestCacheOutput is the output from nd_test_cache callback.
type TestCacheOutput struct {
StringVal string `json:"string_val,omitempty"`
IntVal int64 `json:"int_val,omitempty"`
FloatVal float64 `json:"float_val,omitempty"`
BytesVal []byte `json:"bytes_val,omitempty"`
Exists bool `json:"exists,omitempty"`
Error *string `json:"error,omitempty"`
}
// nd_test_cache is the test callback that tests the cache host functions.
//
//go:wasmexport nd_test_cache
func ndTestCache() int32 {
var input TestCacheInput
if err := pdk.InputJSON(&input); err != nil {
errStr := err.Error()
pdk.OutputJSON(TestCacheOutput{Error: &errStr})
return 0
}
switch input.Operation {
case "set_string":
err := CacheSetString(input.Key, input.StringVal, input.TTLSeconds)
if err != nil {
errStr := err.Error()
pdk.OutputJSON(TestCacheOutput{Error: &errStr})
return 0
}
pdk.OutputJSON(TestCacheOutput{})
return 0
case "get_string":
resp, err := CacheGetString(input.Key)
if err != nil {
errStr := err.Error()
pdk.OutputJSON(TestCacheOutput{Error: &errStr})
return 0
}
if resp.Error != "" {
pdk.OutputJSON(TestCacheOutput{Error: &resp.Error})
return 0
}
pdk.OutputJSON(TestCacheOutput{StringVal: resp.Value, Exists: resp.Exists})
return 0
case "set_int":
err := CacheSetInt(input.Key, input.IntVal, input.TTLSeconds)
if err != nil {
errStr := err.Error()
pdk.OutputJSON(TestCacheOutput{Error: &errStr})
return 0
}
pdk.OutputJSON(TestCacheOutput{})
return 0
case "get_int":
resp, err := CacheGetInt(input.Key)
if err != nil {
errStr := err.Error()
pdk.OutputJSON(TestCacheOutput{Error: &errStr})
return 0
}
if resp.Error != "" {
pdk.OutputJSON(TestCacheOutput{Error: &resp.Error})
return 0
}
pdk.OutputJSON(TestCacheOutput{IntVal: resp.Value, Exists: resp.Exists})
return 0
case "set_float":
err := CacheSetFloat(input.Key, input.FloatVal, input.TTLSeconds)
if err != nil {
errStr := err.Error()
pdk.OutputJSON(TestCacheOutput{Error: &errStr})
return 0
}
pdk.OutputJSON(TestCacheOutput{})
return 0
case "get_float":
resp, err := CacheGetFloat(input.Key)
if err != nil {
errStr := err.Error()
pdk.OutputJSON(TestCacheOutput{Error: &errStr})
return 0
}
if resp.Error != "" {
pdk.OutputJSON(TestCacheOutput{Error: &resp.Error})
return 0
}
pdk.OutputJSON(TestCacheOutput{FloatVal: resp.Value, Exists: resp.Exists})
return 0
case "set_bytes":
err := CacheSetBytes(input.Key, input.BytesVal, input.TTLSeconds)
if err != nil {
errStr := err.Error()
pdk.OutputJSON(TestCacheOutput{Error: &errStr})
return 0
}
pdk.OutputJSON(TestCacheOutput{})
return 0
case "get_bytes":
resp, err := CacheGetBytes(input.Key)
if err != nil {
errStr := err.Error()
pdk.OutputJSON(TestCacheOutput{Error: &errStr})
return 0
}
if resp.Error != "" {
pdk.OutputJSON(TestCacheOutput{Error: &resp.Error})
return 0
}
pdk.OutputJSON(TestCacheOutput{BytesVal: resp.Value, Exists: resp.Exists})
return 0
case "has":
resp, err := CacheHas(input.Key)
if err != nil {
errStr := err.Error()
pdk.OutputJSON(TestCacheOutput{Error: &errStr})
return 0
}
if resp.Error != "" {
pdk.OutputJSON(TestCacheOutput{Error: &resp.Error})
return 0
}
pdk.OutputJSON(TestCacheOutput{Exists: resp.Exists})
return 0
case "remove":
err := CacheRemove(input.Key)
if err != nil {
errStr := err.Error()
pdk.OutputJSON(TestCacheOutput{Error: &errStr})
return 0
}
pdk.OutputJSON(TestCacheOutput{})
return 0
default:
errStr := "unknown operation: " + input.Operation
pdk.OutputJSON(TestCacheOutput{Error: &errStr})
return 0
}
}
func main() {}

View File

@ -0,0 +1,375 @@
// Code generated by hostgen. DO NOT EDIT.
//
// This file contains client wrappers for the Cache host service.
// It is intended for use in Navidrome plugins built with TinyGo.
//
//go:build wasip1
package main
import (
"encoding/json"
"errors"
"github.com/extism/go-pdk"
)
// cache_setstring is the host function provided by Navidrome.
//
//go:wasmimport extism:host/user cache_setstring
func cache_setstring(uint64, uint64, int64) uint64
// cache_getstring is the host function provided by Navidrome.
//
//go:wasmimport extism:host/user cache_getstring
func cache_getstring(uint64) uint64
// cache_setint is the host function provided by Navidrome.
//
//go:wasmimport extism:host/user cache_setint
func cache_setint(uint64, int64, int64) uint64
// cache_getint is the host function provided by Navidrome.
//
//go:wasmimport extism:host/user cache_getint
func cache_getint(uint64) uint64
// cache_setfloat is the host function provided by Navidrome.
//
//go:wasmimport extism:host/user cache_setfloat
func cache_setfloat(uint64, float64, int64) uint64
// cache_getfloat is the host function provided by Navidrome.
//
//go:wasmimport extism:host/user cache_getfloat
func cache_getfloat(uint64) uint64
// cache_setbytes is the host function provided by Navidrome.
//
//go:wasmimport extism:host/user cache_setbytes
func cache_setbytes(uint64, uint64, int64) uint64
// cache_getbytes is the host function provided by Navidrome.
//
//go:wasmimport extism:host/user cache_getbytes
func cache_getbytes(uint64) uint64
// cache_has is the host function provided by Navidrome.
//
//go:wasmimport extism:host/user cache_has
func cache_has(uint64) uint64
// cache_remove is the host function provided by Navidrome.
//
//go:wasmimport extism:host/user cache_remove
func cache_remove(uint64) uint64
// CacheGetStringResponse is the response type for Cache.GetString.
type CacheGetStringResponse struct {
Value string `json:"value,omitempty"`
Exists bool `json:"exists,omitempty"`
Error string `json:"error,omitempty"`
}
// CacheGetIntResponse is the response type for Cache.GetInt.
type CacheGetIntResponse struct {
Value int64 `json:"value,omitempty"`
Exists bool `json:"exists,omitempty"`
Error string `json:"error,omitempty"`
}
// CacheGetFloatResponse is the response type for Cache.GetFloat.
type CacheGetFloatResponse struct {
Value float64 `json:"value,omitempty"`
Exists bool `json:"exists,omitempty"`
Error string `json:"error,omitempty"`
}
// CacheGetBytesResponse is the response type for Cache.GetBytes.
type CacheGetBytesResponse struct {
Value []byte `json:"value,omitempty"`
Exists bool `json:"exists,omitempty"`
Error string `json:"error,omitempty"`
}
// CacheHasResponse is the response type for Cache.Has.
type CacheHasResponse struct {
Exists bool `json:"exists,omitempty"`
Error string `json:"error,omitempty"`
}
// CacheSetString calls the cache_setstring host function.
// SetString stores a string value in the cache.
//
// Parameters:
// - key: The cache key (will be namespaced with plugin ID)
// - value: The string value to store
// - ttlSeconds: Time-to-live in seconds (0 uses default of 24 hours)
//
// Returns an error if the operation fails.
func CacheSetString(key string, value string, ttlSeconds int64) error {
keyMem := pdk.AllocateString(key)
defer keyMem.Free()
valueMem := pdk.AllocateString(value)
defer valueMem.Free()
// Call the host function
responsePtr := cache_setstring(keyMem.Offset(), valueMem.Offset(), ttlSeconds)
// Read the response from memory
responseMem := pdk.FindMemory(responsePtr)
errStr := string(responseMem.ReadBytes())
if errStr != "" {
return errors.New(errStr)
}
return nil
}
// CacheGetString calls the cache_getstring host function.
// GetString retrieves a string value from the cache.
//
// Parameters:
// - key: The cache key (will be namespaced with plugin ID)
//
// Returns the value and whether the key exists. If the key doesn't exist
// or the stored value is not a string, exists will be false.
func CacheGetString(key string) (*CacheGetStringResponse, error) {
keyMem := pdk.AllocateString(key)
defer keyMem.Free()
// Call the host function
responsePtr := cache_getstring(keyMem.Offset())
// Read the response from memory
responseMem := pdk.FindMemory(responsePtr)
responseBytes := responseMem.ReadBytes()
// Parse the response
var response CacheGetStringResponse
if err := json.Unmarshal(responseBytes, &response); err != nil {
return nil, err
}
return &response, nil
}
// CacheSetInt calls the cache_setint host function.
// SetInt stores an integer value in the cache.
//
// Parameters:
// - key: The cache key (will be namespaced with plugin ID)
// - value: The integer value to store
// - ttlSeconds: Time-to-live in seconds (0 uses default of 24 hours)
//
// Returns an error if the operation fails.
func CacheSetInt(key string, value int64, ttlSeconds int64) error {
keyMem := pdk.AllocateString(key)
defer keyMem.Free()
// Call the host function
responsePtr := cache_setint(keyMem.Offset(), value, ttlSeconds)
// Read the response from memory
responseMem := pdk.FindMemory(responsePtr)
errStr := string(responseMem.ReadBytes())
if errStr != "" {
return errors.New(errStr)
}
return nil
}
// CacheGetInt calls the cache_getint host function.
// GetInt retrieves an integer value from the cache.
//
// Parameters:
// - key: The cache key (will be namespaced with plugin ID)
//
// Returns the value and whether the key exists. If the key doesn't exist
// or the stored value is not an integer, exists will be false.
func CacheGetInt(key string) (*CacheGetIntResponse, error) {
keyMem := pdk.AllocateString(key)
defer keyMem.Free()
// Call the host function
responsePtr := cache_getint(keyMem.Offset())
// Read the response from memory
responseMem := pdk.FindMemory(responsePtr)
responseBytes := responseMem.ReadBytes()
// Parse the response
var response CacheGetIntResponse
if err := json.Unmarshal(responseBytes, &response); err != nil {
return nil, err
}
return &response, nil
}
// CacheSetFloat calls the cache_setfloat host function.
// SetFloat stores a float value in the cache.
//
// Parameters:
// - key: The cache key (will be namespaced with plugin ID)
// - value: The float value to store
// - ttlSeconds: Time-to-live in seconds (0 uses default of 24 hours)
//
// Returns an error if the operation fails.
func CacheSetFloat(key string, value float64, ttlSeconds int64) error {
keyMem := pdk.AllocateString(key)
defer keyMem.Free()
// Call the host function
responsePtr := cache_setfloat(keyMem.Offset(), value, ttlSeconds)
// Read the response from memory
responseMem := pdk.FindMemory(responsePtr)
errStr := string(responseMem.ReadBytes())
if errStr != "" {
return errors.New(errStr)
}
return nil
}
// CacheGetFloat calls the cache_getfloat host function.
// GetFloat retrieves a float value from the cache.
//
// Parameters:
// - key: The cache key (will be namespaced with plugin ID)
//
// Returns the value and whether the key exists. If the key doesn't exist
// or the stored value is not a float, exists will be false.
func CacheGetFloat(key string) (*CacheGetFloatResponse, error) {
keyMem := pdk.AllocateString(key)
defer keyMem.Free()
// Call the host function
responsePtr := cache_getfloat(keyMem.Offset())
// Read the response from memory
responseMem := pdk.FindMemory(responsePtr)
responseBytes := responseMem.ReadBytes()
// Parse the response
var response CacheGetFloatResponse
if err := json.Unmarshal(responseBytes, &response); err != nil {
return nil, err
}
return &response, nil
}
// CacheSetBytes calls the cache_setbytes host function.
// SetBytes stores a byte slice in the cache.
//
// Parameters:
// - key: The cache key (will be namespaced with plugin ID)
// - value: The byte slice to store
// - ttlSeconds: Time-to-live in seconds (0 uses default of 24 hours)
//
// Returns an error if the operation fails.
func CacheSetBytes(key string, value []byte, ttlSeconds int64) error {
keyMem := pdk.AllocateString(key)
defer keyMem.Free()
valueMem := pdk.AllocateBytes(value)
defer valueMem.Free()
// Call the host function
responsePtr := cache_setbytes(keyMem.Offset(), valueMem.Offset(), ttlSeconds)
// Read the response from memory
responseMem := pdk.FindMemory(responsePtr)
errStr := string(responseMem.ReadBytes())
if errStr != "" {
return errors.New(errStr)
}
return nil
}
// CacheGetBytes calls the cache_getbytes host function.
// GetBytes retrieves a byte slice from the cache.
//
// Parameters:
// - key: The cache key (will be namespaced with plugin ID)
//
// Returns the value and whether the key exists. If the key doesn't exist
// or the stored value is not a byte slice, exists will be false.
func CacheGetBytes(key string) (*CacheGetBytesResponse, error) {
keyMem := pdk.AllocateString(key)
defer keyMem.Free()
// Call the host function
responsePtr := cache_getbytes(keyMem.Offset())
// Read the response from memory
responseMem := pdk.FindMemory(responsePtr)
responseBytes := responseMem.ReadBytes()
// Parse the response
var response CacheGetBytesResponse
if err := json.Unmarshal(responseBytes, &response); err != nil {
return nil, err
}
return &response, nil
}
// CacheHas calls the cache_has host function.
// Has checks if a key exists in the cache.
//
// Parameters:
// - key: The cache key (will be namespaced with plugin ID)
//
// Returns true if the key exists and has not expired.
func CacheHas(key string) (*CacheHasResponse, error) {
keyMem := pdk.AllocateString(key)
defer keyMem.Free()
// Call the host function
responsePtr := cache_has(keyMem.Offset())
// Read the response from memory
responseMem := pdk.FindMemory(responsePtr)
responseBytes := responseMem.ReadBytes()
// Parse the response
var response CacheHasResponse
if err := json.Unmarshal(responseBytes, &response); err != nil {
return nil, err
}
return &response, nil
}
// CacheRemove calls the cache_remove host function.
// Remove deletes a value from the cache.
//
// Parameters:
// - key: The cache key (will be namespaced with plugin ID)
//
// Returns an error if the operation fails. Does not return an error if the key doesn't exist.
func CacheRemove(key string) error {
keyMem := pdk.AllocateString(key)
defer keyMem.Free()
// Call the host function
responsePtr := cache_remove(keyMem.Offset())
// Read the response from memory
responseMem := pdk.FindMemory(responsePtr)
errStr := string(responseMem.ReadBytes())
if errStr != "" {
return errors.New(errStr)
}
return nil
}