feat(plugins): implement SubsonicAPI host function integration with permissions

Signed-off-by: Deluan <deluan@navidrome.org>
This commit is contained in:
Deluan 2025-12-23 17:17:20 -05:00
parent d93b758b16
commit 26bfbb968d
9 changed files with 730 additions and 11 deletions

152
plugins/host_subsonicapi.go Normal file
View File

@ -0,0 +1,152 @@
package plugins
import (
"context"
"errors"
"fmt"
"net/http"
"net/http/httptest"
"net/url"
"path"
"strings"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/model/request"
"github.com/navidrome/navidrome/plugins/host"
)
// subsonicAPIVersion is the Subsonic API version used for plugin calls.
// This is defined locally to avoid import cycle with server/subsonic.
const subsonicAPIVersion = "1.16.1"
// subsonicAPIServiceImpl implements host.SubsonicAPIService.
// It provides plugins with access to Navidrome's Subsonic API.
//
// Authentication: The plugin must provide a valid 'u' (username) parameter in the URL.
// URL Format: Only the path and query parameters are used - host/protocol are ignored.
// Automatic Parameters: The service adds 'c' (client), 'v' (version), 'f' (format).
type subsonicAPIServiceImpl struct {
pluginID string
router SubsonicRouter
ds model.DataStore
permissions *subsonicAPIPermissions
}
// newSubsonicAPIService creates a new SubsonicAPIService for a plugin.
func newSubsonicAPIService(pluginID string, router SubsonicRouter, ds model.DataStore, permissions *SubsonicAPIPermission) host.SubsonicAPIService {
return &subsonicAPIServiceImpl{
pluginID: pluginID,
router: router,
ds: ds,
permissions: parseSubsonicAPIPermissions(permissions),
}
}
func (s *subsonicAPIServiceImpl) Call(ctx context.Context, uri string) (string, error) {
if s.router == nil {
return "", fmt.Errorf("SubsonicAPI router not available")
}
// Parse the input URL
parsedURL, err := url.Parse(uri)
if err != nil {
return "", fmt.Errorf("invalid URL format: %w", err)
}
// Extract query parameters
query := parsedURL.Query()
// Validate that 'u' (username) parameter is present
username := query.Get("u")
if username == "" {
return "", fmt.Errorf("missing required parameter 'u' (username)")
}
if err := s.checkPermissions(ctx, username); err != nil {
log.Warn(ctx, "SubsonicAPI call blocked by permissions", "plugin", s.pluginID, "user", username, err)
return "", err
}
// Add required Subsonic API parameters
query.Set("c", s.pluginID) // Client name (plugin ID)
query.Set("f", "json") // Response format
query.Set("v", subsonicAPIVersion) // API version
// Extract the endpoint from the path
endpoint := path.Base(parsedURL.Path)
// Build the final URL with processed path and modified query parameters
finalURL := &url.URL{
Path: "/" + endpoint,
RawQuery: query.Encode(),
}
// Create HTTP request with a fresh context to avoid Chi RouteContext pollution.
// Using http.NewRequest (instead of http.NewRequestWithContext) ensures the internal
// SubsonicAPI call doesn't inherit routing information from the parent handler,
// which would cause Chi to invoke the wrong handler. Authentication context is
// explicitly added in the next step via request.WithInternalAuth.
httpReq, err := http.NewRequest("GET", finalURL.String(), nil)
if err != nil {
return "", fmt.Errorf("failed to create HTTP request: %w", err)
}
// Set internal authentication context using the username from the 'u' parameter
authCtx := request.WithInternalAuth(httpReq.Context(), username)
httpReq = httpReq.WithContext(authCtx)
// Use ResponseRecorder to capture the response
recorder := httptest.NewRecorder()
// Call the subsonic router
s.router.ServeHTTP(recorder, httpReq)
// Return the response body as JSON
return recorder.Body.String(), nil
}
func (s *subsonicAPIServiceImpl) checkPermissions(ctx context.Context, username string) error {
if s.permissions == nil {
return nil
}
if len(s.permissions.AllowedUsernames) > 0 {
if _, ok := s.permissions.usernameMap[strings.ToLower(username)]; !ok {
return fmt.Errorf("username %s is not allowed", username)
}
}
if !s.permissions.AllowAdmins {
usr, err := s.ds.User(ctx).FindByUsername(username)
if err != nil {
if errors.Is(err, model.ErrNotFound) {
return fmt.Errorf("username %s not found", username)
}
return err
}
if usr.IsAdmin {
return fmt.Errorf("calling SubsonicAPI as admin user is not allowed")
}
}
return nil
}
type subsonicAPIPermissions struct {
AllowedUsernames []string
AllowAdmins bool
usernameMap map[string]struct{}
}
func parseSubsonicAPIPermissions(data *SubsonicAPIPermission) *subsonicAPIPermissions {
if data == nil {
return &subsonicAPIPermissions{}
}
perms := &subsonicAPIPermissions{
AllowedUsernames: data.AllowedUsernames,
AllowAdmins: data.AllowAdmins,
usernameMap: make(map[string]struct{}),
}
for _, u := range data.AllowedUsernames {
perms.usernameMap[strings.ToLower(u)] = struct{}{}
}
return perms
}

View File

@ -0,0 +1,338 @@
//go:build !windows
package plugins
import (
"encoding/json"
"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("SubsonicAPI Host Function", Ordered, func() {
var (
manager *Manager
tmpDir string
router *fakeSubsonicRouter
userRepo *tests.MockedUserRepo
dataStore *tests.MockDataStore
)
BeforeAll(func() {
var err error
tmpDir, err = os.MkdirTemp("", "subsonicapi-test-*")
Expect(err).ToNot(HaveOccurred())
// Copy test plugin to temp dir
srcPath := filepath.Join(testdataDir, "fake-subsonicapi-plugin.wasm")
destPath := filepath.Join(tmpDir, "fake-subsonicapi-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")
// Setup mock router and data store
router = &fakeSubsonicRouter{}
userRepo = tests.CreateMockUserRepo()
dataStore = &tests.MockDataStore{MockedUser: userRepo}
// Add test users
_ = userRepo.Put(&model.User{
ID: "user1",
UserName: "testuser",
IsAdmin: false,
})
_ = userRepo.Put(&model.User{
ID: "admin1",
UserName: "adminuser",
IsAdmin: true,
})
// Create and configure manager
manager = &Manager{
plugins: make(map[string]*pluginInstance),
}
manager.SetSubsonicRouter(router)
manager.SetDataStore(dataStore)
// Start the manager
err = manager.Start(GinkgoT().Context())
Expect(err).ToNot(HaveOccurred())
DeferCleanup(func() {
_ = manager.Stop()
_ = os.RemoveAll(tmpDir)
})
})
Describe("Plugin Loading", func() {
It("loads the plugin with SubsonicAPI permission", func() {
manager.mu.RLock()
plugin := manager.plugins["fake-subsonicapi-plugin"]
manager.mu.RUnlock()
Expect(plugin).ToNot(BeNil())
})
It("has the correct manifest", func() {
manager.mu.RLock()
plugin := manager.plugins["fake-subsonicapi-plugin"]
manager.mu.RUnlock()
Expect(plugin).ToNot(BeNil())
Expect(plugin.manifest.Name).To(Equal("Fake SubsonicAPI Plugin"))
Expect(plugin.manifest.Permissions.Subsonicapi).ToNot(BeNil())
})
})
Describe("SubsonicAPI Call", func() {
var plugin *pluginInstance
BeforeEach(func() {
manager.mu.RLock()
plugin = manager.plugins["fake-subsonicapi-plugin"]
manager.mu.RUnlock()
Expect(plugin).ToNot(BeNil())
})
It("successfully calls the ping endpoint", func() {
instance, err := plugin.create()
Expect(err).ToNot(HaveOccurred())
defer instance.Close(GinkgoT().Context())
exit, output, err := instance.Call("call_subsonic_api", []byte("/ping?u=testuser"))
Expect(err).ToNot(HaveOccurred())
Expect(exit).To(Equal(uint32(0)))
// Verify the response contains the expected structure
var response map[string]any
err = json.Unmarshal(output, &response)
Expect(err).ToNot(HaveOccurred())
subsonicResponse, ok := response["subsonic-response"].(map[string]any)
Expect(ok).To(BeTrue())
Expect(subsonicResponse["status"]).To(Equal("ok"))
})
It("adds required parameters (c, f, v) to the request", func() {
instance, err := plugin.create()
Expect(err).ToNot(HaveOccurred())
defer instance.Close(GinkgoT().Context())
_, _, err = instance.Call("call_subsonic_api", []byte("/getAlbumList?u=testuser&type=newest"))
Expect(err).ToNot(HaveOccurred())
// Verify the parameters were added
Expect(router.lastRequest).ToNot(BeNil())
query := router.lastRequest.URL.Query()
Expect(query.Get("c")).To(Equal("fake-subsonicapi-plugin"))
Expect(query.Get("f")).To(Equal("json"))
Expect(query.Get("v")).To(Equal("1.16.1"))
Expect(query.Get("type")).To(Equal("newest"))
})
It("returns error when username is missing", func() {
instance, err := plugin.create()
Expect(err).ToNot(HaveOccurred())
defer instance.Close(GinkgoT().Context())
exit, _, err := instance.Call("call_subsonic_api", []byte("/ping"))
Expect(err).To(HaveOccurred())
Expect(exit).To(Equal(uint32(1)))
Expect(err.Error()).To(ContainSubstring("missing required parameter"))
})
})
})
var _ = Describe("SubsonicAPIService", func() {
var (
router *fakeSubsonicRouter
userRepo *tests.MockedUserRepo
dataStore *tests.MockDataStore
)
BeforeEach(func() {
router = &fakeSubsonicRouter{}
userRepo = tests.CreateMockUserRepo()
dataStore = &tests.MockDataStore{MockedUser: userRepo}
_ = userRepo.Put(&model.User{
ID: "user1",
UserName: "testuser",
IsAdmin: false,
})
_ = userRepo.Put(&model.User{
ID: "admin1",
UserName: "adminuser",
IsAdmin: true,
})
_ = userRepo.Put(&model.User{
ID: "user2",
UserName: "alloweduser",
IsAdmin: false,
})
})
Describe("Permission Enforcement", func() {
Context("with AllowedUsernames restriction", func() {
It("blocks users not in the allowed list", func() {
service := newSubsonicAPIService("test-plugin", router, dataStore, &SubsonicAPIPermission{
AllowedUsernames: []string{"alloweduser"},
AllowAdmins: true,
})
ctx := GinkgoT().Context()
_, err := service.Call(ctx, "/ping?u=testuser")
Expect(err).To(HaveOccurred())
Expect(err.Error()).To(ContainSubstring("not allowed"))
})
It("allows users in the allowed list", func() {
service := newSubsonicAPIService("test-plugin", router, dataStore, &SubsonicAPIPermission{
AllowedUsernames: []string{"alloweduser"},
AllowAdmins: true,
})
ctx := GinkgoT().Context()
response, err := service.Call(ctx, "/ping?u=alloweduser")
Expect(err).ToNot(HaveOccurred())
Expect(response).To(ContainSubstring("ok"))
})
It("is case-insensitive for usernames", func() {
service := newSubsonicAPIService("test-plugin", router, dataStore, &SubsonicAPIPermission{
AllowedUsernames: []string{"AllowedUser"},
AllowAdmins: true,
})
ctx := GinkgoT().Context()
response, err := service.Call(ctx, "/ping?u=alloweduser")
Expect(err).ToNot(HaveOccurred())
Expect(response).To(ContainSubstring("ok"))
})
})
Context("with AllowAdmins=false", func() {
It("blocks admin users", func() {
service := newSubsonicAPIService("test-plugin", router, dataStore, &SubsonicAPIPermission{
AllowAdmins: false,
})
ctx := GinkgoT().Context()
_, err := service.Call(ctx, "/ping?u=adminuser")
Expect(err).To(HaveOccurred())
Expect(err.Error()).To(ContainSubstring("admin user is not allowed"))
})
It("allows non-admin users", func() {
service := newSubsonicAPIService("test-plugin", router, dataStore, &SubsonicAPIPermission{
AllowAdmins: false,
})
ctx := GinkgoT().Context()
response, err := service.Call(ctx, "/ping?u=testuser")
Expect(err).ToNot(HaveOccurred())
Expect(response).To(ContainSubstring("ok"))
})
})
Context("with AllowAdmins=true", func() {
It("allows admin users", func() {
service := newSubsonicAPIService("test-plugin", router, dataStore, &SubsonicAPIPermission{
AllowAdmins: true,
})
ctx := GinkgoT().Context()
response, err := service.Call(ctx, "/ping?u=adminuser")
Expect(err).ToNot(HaveOccurred())
Expect(response).To(ContainSubstring("ok"))
})
})
Context("with no permissions set (nil)", func() {
It("allows all users", func() {
service := newSubsonicAPIService("test-plugin", router, dataStore, nil)
ctx := GinkgoT().Context()
response, err := service.Call(ctx, "/ping?u=testuser")
Expect(err).ToNot(HaveOccurred())
Expect(response).To(ContainSubstring("ok"))
})
})
})
Describe("URL Handling", func() {
It("returns error for missing username parameter", func() {
service := newSubsonicAPIService("test-plugin", router, dataStore, nil)
ctx := GinkgoT().Context()
_, err := service.Call(ctx, "/ping")
Expect(err).To(HaveOccurred())
Expect(err.Error()).To(ContainSubstring("missing required parameter"))
})
It("returns error for invalid URL", func() {
service := newSubsonicAPIService("test-plugin", router, dataStore, nil)
ctx := GinkgoT().Context()
_, err := service.Call(ctx, "://invalid")
Expect(err).To(HaveOccurred())
Expect(err.Error()).To(ContainSubstring("invalid URL"))
})
It("extracts endpoint from path correctly", func() {
service := newSubsonicAPIService("test-plugin", router, dataStore, nil)
ctx := GinkgoT().Context()
_, err := service.Call(ctx, "/rest/ping.view?u=testuser")
Expect(err).ToNot(HaveOccurred())
// The endpoint should be extracted as "ping.view"
Expect(router.lastRequest.URL.Path).To(Equal("/ping.view"))
})
})
Describe("Router Availability", func() {
It("returns error when router is nil", func() {
service := newSubsonicAPIService("test-plugin", nil, dataStore, nil)
ctx := GinkgoT().Context()
_, err := service.Call(ctx, "/ping?u=testuser")
Expect(err).To(HaveOccurred())
Expect(err.Error()).To(ContainSubstring("router not available"))
})
})
})
// fakeSubsonicRouter is a mock Subsonic router that returns predictable responses.
type fakeSubsonicRouter struct {
lastRequest *http.Request
}
func (r *fakeSubsonicRouter) ServeHTTP(w http.ResponseWriter, req *http.Request) {
r.lastRequest = req
// Return a successful ping response
response := map[string]any{
"subsonic-response": map[string]any{
"status": "ok",
"version": "1.16.1",
},
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(response)
}

View File

@ -5,6 +5,7 @@ import (
"crypto/rand"
"encoding/json"
"fmt"
"net/http"
"os"
"path/filepath"
"strings"
@ -17,6 +18,8 @@ import (
"github.com/navidrome/navidrome/core/agents"
"github.com/navidrome/navidrome/core/scrobbler"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/plugins/host"
"github.com/navidrome/navidrome/utils/singleton"
"github.com/rjeczalik/notify"
"github.com/tetratelabs/wazero"
@ -31,6 +34,9 @@ const (
defaultTimeout = 30 * time.Second
)
// SubsonicRouter is an http.Handler that serves Subsonic API requests.
type SubsonicRouter = http.Handler
// Manager manages loading and lifecycle of WebAssembly plugins.
// It implements both agents.PluginLoader and scrobbler.PluginLoader interfaces.
type Manager struct {
@ -47,6 +53,10 @@ type Manager struct {
watcherDone chan struct{}
debounceTimers map[string]*time.Timer
debounceMu sync.Mutex
// SubsonicAPI host function dependencies (set once before Start, not modified after)
subsonicRouter SubsonicRouter
ds model.DataStore
}
// pluginInstance represents a loaded plugin
@ -79,6 +89,19 @@ func GetManager() *Manager {
})
}
// SetSubsonicRouter sets the Subsonic router for SubsonicAPI host functions.
// This should be called after the subsonic router is created but before plugins
// that require SubsonicAPI access are loaded.
func (m *Manager) SetSubsonicRouter(router SubsonicRouter) {
m.subsonicRouter = router
}
// SetDataStore sets the data store for plugins that need database access.
// This should be called before plugins are loaded.
func (m *Manager) SetDataStore(ds model.DataStore) {
m.ds = ds
}
// Start initializes the plugin manager and loads plugins from the configured folder.
// It should be called once during application startup when plugins are enabled.
func (m *Manager) Start(ctx context.Context) error {
@ -313,8 +336,14 @@ func (m *Manager) loadPlugin(name, wasmPath string) error {
RuntimeConfig: wazero.NewRuntimeConfig().WithCompilationCache(m.cache),
}
// Create initial compiled plugin (without AllowedHosts)
compiled, err := extism.NewCompiledPlugin(m.ctx, pluginManifest, extismConfig, nil)
// Register stub host functions for initial compilation.
// This is necessary because plugins that import host functions will fail to compile
// if those functions aren't available at compile time. We use a stub service that
// returns an error - the real service will be registered during recompilation.
stubHostFunctions := host.RegisterSubsonicAPIHostFunctions(nil)
// Create initial compiled plugin with stub host functions
compiled, err := extism.NewCompiledPlugin(m.ctx, pluginManifest, extismConfig, stubHostFunctions)
if err != nil {
return err
}
@ -350,11 +379,31 @@ func (m *Manager) loadPlugin(name, wasmPath string) error {
capabilities := detectCapabilities(instance)
instance.Close(m.ctx)
// Recompile only if plugin requires HTTP access (AllowedHosts)
// Check if recompilation is needed (AllowedHosts or SubsonicAPI permission)
needsRecompile := false
var hostFunctions []extism.HostFunction
if hosts := manifest.AllowedHosts(); len(hosts) > 0 {
compiled.Close(m.ctx)
pluginManifest.AllowedHosts = hosts
compiled, err = extism.NewCompiledPlugin(m.ctx, pluginManifest, extismConfig, nil)
needsRecompile = true
}
// Register SubsonicAPI host functions if permission is granted
if manifest.Permissions != nil && manifest.Permissions.Subsonicapi != nil {
perm := manifest.Permissions.Subsonicapi
if m.subsonicRouter != nil && m.ds != nil {
service := newSubsonicAPIService(name, m.subsonicRouter, m.ds, perm)
hostFunctions = append(hostFunctions, host.RegisterSubsonicAPIHostFunctions(service)...)
needsRecompile = true
} else {
log.Warn(m.ctx, "Plugin requires SubsonicAPI but router/datastore not available", "plugin", name)
}
}
// Recompile if needed (AllowedHosts or host functions)
if needsRecompile {
compiled.Close(m.ctx)
compiled, err = extism.NewCompiledPlugin(m.ctx, pluginManifest, extismConfig, hostFunctions)
if err != nil {
return err
}
@ -526,9 +575,3 @@ func toExtismLogLevel(level log.Level) extism.LogLevel {
return extism.LogLevelInfo
}
}
// Verify interface implementations at compile time
var (
_ agents.PluginLoader = (*Manager)(nil)
_ scrobbler.PluginLoader = (*Manager)(nil)
)

View File

@ -46,6 +46,9 @@
},
"config": {
"$ref": "#/$defs/ConfigPermission"
},
"subsonicapi": {
"$ref": "#/$defs/SubsonicAPIPermission"
}
}
},
@ -77,6 +80,29 @@
"description": "Explanation for why config access is needed"
}
}
},
"SubsonicAPIPermission": {
"type": "object",
"description": "SubsonicAPI service permissions",
"additionalProperties": false,
"properties": {
"reason": {
"type": "string",
"description": "Explanation for why SubsonicAPI access is needed"
},
"allowedUsernames": {
"type": "array",
"description": "List of usernames the plugin can pass as u. Any user if empty",
"items": {
"type": "string"
}
},
"allowAdmins": {
"type": "boolean",
"description": "If false, reject calls where the u is an admin",
"default": false
}
}
}
}
}

View File

@ -82,4 +82,37 @@ type Permissions struct {
// Http corresponds to the JSON schema field "http".
Http *HTTPPermission `json:"http,omitempty" yaml:"http,omitempty" mapstructure:"http,omitempty"`
// Subsonicapi corresponds to the JSON schema field "subsonicapi".
Subsonicapi *SubsonicAPIPermission `json:"subsonicapi,omitempty" yaml:"subsonicapi,omitempty" mapstructure:"subsonicapi,omitempty"`
}
// SubsonicAPI service permissions
type SubsonicAPIPermission struct {
// If false, reject calls where the u is an admin
AllowAdmins bool `json:"allowAdmins,omitempty" yaml:"allowAdmins,omitempty" mapstructure:"allowAdmins,omitempty"`
// List of usernames the plugin can pass as u. Any user if empty
AllowedUsernames []string `json:"allowedUsernames,omitempty" yaml:"allowedUsernames,omitempty" mapstructure:"allowedUsernames,omitempty"`
// Explanation for why SubsonicAPI access is needed
Reason *string `json:"reason,omitempty" yaml:"reason,omitempty" mapstructure:"reason,omitempty"`
}
// UnmarshalJSON implements json.Unmarshaler.
func (j *SubsonicAPIPermission) UnmarshalJSON(value []byte) error {
var raw map[string]interface{}
if err := json.Unmarshal(value, &raw); err != nil {
return err
}
type Plain SubsonicAPIPermission
var plain Plain
if err := json.Unmarshal(value, &plain); err != nil {
return err
}
if v, ok := raw["allowAdmins"]; !ok || v == nil {
plain.AllowAdmins = false
}
*j = SubsonicAPIPermission(plain)
return nil
}

View File

@ -0,0 +1,5 @@
module fake-subsonicapi-plugin
go 1.24
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,74 @@
// Test plugin for SubsonicAPI host function integration tests.
// Build with: tinygo build -o ../fake-subsonicapi-plugin.wasm -target wasip1 -buildmode=c-shared ./main.go
package main
import (
"encoding/json"
"github.com/extism/go-pdk"
)
type Manifest struct {
Name string `json:"name"`
Author string `json:"author"`
Version string `json:"version"`
Description string `json:"description"`
Permissions *Permissions `json:"permissions,omitempty"`
}
type Permissions struct {
SubsonicAPI *SubsonicAPIPermission `json:"subsonicapi,omitempty"`
}
type SubsonicAPIPermission struct {
Reason string `json:"reason,omitempty"`
AllowedUsernames []string `json:"allowedUsernames,omitempty"`
AllowAdmins bool `json:"allowAdmins,omitempty"`
}
//go:wasmexport nd_manifest
func ndManifest() int32 {
manifest := Manifest{
Name: "Fake SubsonicAPI Plugin",
Author: "Navidrome Test",
Version: "1.0.0",
Description: "Test plugin for SubsonicAPI host function",
Permissions: &Permissions{
SubsonicAPI: &SubsonicAPIPermission{
Reason: "Testing SubsonicAPI access",
AllowedUsernames: nil, // Allow all users
AllowAdmins: true,
},
},
}
output, err := json.Marshal(manifest)
if err != nil {
pdk.SetErrorString("failed to marshal manifest")
return 1
}
pdk.Output(output)
return 0
}
// call_subsonic_api is the exported function that tests the SubsonicAPI host function.
// Input: URI string (e.g., "/ping?u=testuser")
// Output: The raw JSON response from the Subsonic API
//
//go:wasmexport call_subsonic_api
func callSubsonicAPIExport() int32 {
// Get the URI from input
uri := pdk.InputString()
// Call the Subsonic API via host function
response, err := SubsonicAPICall(uri)
if err != nil {
pdk.SetErrorString("failed to call SubsonicAPI: " + err.Error())
return 1
}
// Return the response
pdk.OutputString(response.ResponseJSON)
return 0
}
func main() {}

View File

@ -0,0 +1,46 @@
package main
import (
"encoding/json"
"errors"
"github.com/extism/go-pdk"
)
// subsonicapiCall is the host function provided by Navidrome to call the Subsonic API.
// It takes a URI string and returns a JSON response.
//
//go:wasmimport extism:host/user subsonicapi_call
func subsonicapiCall(uri uint64) uint64
// SubsonicAPICallResponse matches the host response format.
type SubsonicAPICallResponse struct {
ResponseJSON string `json:"responseJSON,omitempty"`
Error string `json:"error,omitempty"`
}
// SubsonicAPICall is a wrapper around the host subsonicapi_call function.
func SubsonicAPICall(uri string) (*SubsonicAPICallResponse, error) {
// Allocate memory for the URI string
mem := pdk.AllocateString(uri)
defer mem.Free()
// Call the host function
responsePtr := subsonicapiCall(mem.Offset())
// Read the response from memory
responseMem := pdk.FindMemory(responsePtr)
responseBytes := responseMem.ReadBytes()
// Parse the response
var response SubsonicAPICallResponse
if err := json.Unmarshal(responseBytes, &response); err != nil {
return nil, err
}
if response.Error != "" {
return nil, errors.New(response.Error)
}
return &response, nil
}