feat(plugins): add SubsonicAPI CallRaw, with support for raw=true binary response for host functions (#4982)

* feat: implement raw binary framing for host function responses

Signed-off-by: Deluan <deluan@navidrome.org>

* feat: add CallRaw method for Subsonic API to handle binary responses

Signed-off-by: Deluan <deluan@navidrome.org>

* test: add tests for raw=true methods and binary framing generation

Signed-off-by: Deluan <deluan@navidrome.org>

* fix: improve error message for malformed raw responses to indicate incomplete header

Signed-off-by: Deluan <deluan@navidrome.org>

* fix: add wasm_import_module attribute for raw methods and improve content-type handling

Signed-off-by: Deluan <deluan@navidrome.org>

---------

Signed-off-by: Deluan <deluan@navidrome.org>
This commit is contained in:
Deluan Quintão
2026-02-04 15:48:08 -05:00
committed by GitHub
parent 19ea338bed
commit e8863ed147
23 changed files with 1223 additions and 40 deletions
+3
View File
@@ -282,6 +282,9 @@ type ServiceB interface {
Entry("option pattern (value, exists bool)",
"config_service.go.txt", "config_client_expected.go.txt", "config_client_expected.py", "config_client_expected.rs"),
Entry("raw=true binary response",
"raw_service.go.txt", "raw_client_expected.go.txt", "raw_client_expected.py", "raw_client_expected.rs"),
)
It("generates compilable client code for comprehensive service", func() {
@@ -264,6 +264,96 @@ var _ = Describe("Generator", func() {
Expect(codeStr).To(ContainSubstring(`extism "github.com/extism/go-sdk"`))
})
It("should generate binary framing for raw=true methods", func() {
svc := Service{
Name: "Stream",
Permission: "stream",
Interface: "StreamService",
Methods: []Method{
{
Name: "GetStream",
HasError: true,
Raw: true,
Params: []Param{NewParam("uri", "string")},
Returns: []Param{
NewParam("contentType", "string"),
NewParam("data", "[]byte"),
},
},
},
}
code, err := GenerateHost(svc, "host")
Expect(err).NotTo(HaveOccurred())
_, err = format.Source(code)
Expect(err).NotTo(HaveOccurred())
codeStr := string(code)
// Should include encoding/binary import for raw methods
Expect(codeStr).To(ContainSubstring(`"encoding/binary"`))
// Should NOT generate a response type for raw methods
Expect(codeStr).NotTo(ContainSubstring("type StreamGetStreamResponse struct"))
// Should generate request type (request is still JSON)
Expect(codeStr).To(ContainSubstring("type StreamGetStreamRequest struct"))
// Should build binary frame [0x00][4-byte CT len][CT][data]
Expect(codeStr).To(ContainSubstring("frame[0] = 0x00"))
Expect(codeStr).To(ContainSubstring("binary.BigEndian.PutUint32"))
// Should have writeRawError helper
Expect(codeStr).To(ContainSubstring("streamWriteRawError"))
// Should use writeRawError instead of writeError for raw methods
Expect(codeStr).To(ContainSubstring("streamWriteRawError(p, stack"))
})
It("should generate both writeError and writeRawError for mixed services", func() {
svc := Service{
Name: "API",
Permission: "api",
Interface: "APIService",
Methods: []Method{
{
Name: "Call",
HasError: true,
Params: []Param{NewParam("uri", "string")},
Returns: []Param{NewParam("response", "string")},
},
{
Name: "CallRaw",
HasError: true,
Raw: true,
Params: []Param{NewParam("uri", "string")},
Returns: []Param{
NewParam("contentType", "string"),
NewParam("data", "[]byte"),
},
},
},
}
code, err := GenerateHost(svc, "host")
Expect(err).NotTo(HaveOccurred())
_, err = format.Source(code)
Expect(err).NotTo(HaveOccurred())
codeStr := string(code)
// Should have both helpers
Expect(codeStr).To(ContainSubstring("apiWriteResponse"))
Expect(codeStr).To(ContainSubstring("apiWriteError"))
Expect(codeStr).To(ContainSubstring("apiWriteRawError"))
// Should generate response type for non-raw method only
Expect(codeStr).To(ContainSubstring("type APICallResponse struct"))
Expect(codeStr).NotTo(ContainSubstring("type APICallRawResponse struct"))
})
It("should always include json import for JSON protocol", func() {
// All services use JSON protocol, so json import is always needed
svc := Service{
@@ -626,6 +716,72 @@ var _ = Describe("Generator", func() {
Expect(codeStr).To(ContainSubstring(`response.get("floatVal", 0.0)`))
Expect(codeStr).To(ContainSubstring(`response.get("boolVal", False)`))
})
It("should generate binary frame parsing for raw methods", func() {
svc := Service{
Name: "Stream",
Permission: "stream",
Interface: "StreamService",
Methods: []Method{
{
Name: "GetStream",
HasError: true,
Raw: true,
Params: []Param{NewParam("uri", "string")},
Returns: []Param{
NewParam("contentType", "string"),
NewParam("data", "[]byte"),
},
Doc: "GetStream returns raw binary stream data.",
},
},
}
code, err := GenerateClientPython(svc)
Expect(err).NotTo(HaveOccurred())
codeStr := string(code)
// Should import Tuple and struct for raw methods
Expect(codeStr).To(ContainSubstring("from typing import Any, Tuple"))
Expect(codeStr).To(ContainSubstring("import struct"))
// Should return Tuple[str, bytes]
Expect(codeStr).To(ContainSubstring("-> Tuple[str, bytes]:"))
// Should parse binary frame instead of JSON
Expect(codeStr).To(ContainSubstring("response_bytes = response_mem.bytes()"))
Expect(codeStr).To(ContainSubstring("response_bytes[0] == 0x01"))
Expect(codeStr).To(ContainSubstring("struct.unpack"))
Expect(codeStr).To(ContainSubstring("return content_type, data"))
// Should NOT use json.loads for response
Expect(codeStr).NotTo(ContainSubstring("json.loads(extism.memory.string(response_mem))"))
})
It("should not import Tuple or struct for non-raw services", func() {
svc := Service{
Name: "Test",
Permission: "test",
Interface: "TestService",
Methods: []Method{
{
Name: "Call",
HasError: true,
Params: []Param{NewParam("uri", "string")},
Returns: []Param{NewParam("response", "string")},
},
},
}
code, err := GenerateClientPython(svc)
Expect(err).NotTo(HaveOccurred())
codeStr := string(code)
Expect(codeStr).NotTo(ContainSubstring("Tuple"))
Expect(codeStr).NotTo(ContainSubstring("import struct"))
})
})
Describe("GenerateGoDoc", func() {
@@ -782,6 +938,47 @@ var _ = Describe("Generator", func() {
// Check for PDK import
Expect(codeStr).To(ContainSubstring("github.com/navidrome/navidrome/plugins/pdk/go/pdk"))
})
It("should include encoding/binary import for raw methods", func() {
svc := Service{
Name: "Stream",
Permission: "stream",
Interface: "StreamService",
Methods: []Method{
{
Name: "GetStream",
HasError: true,
Raw: true,
Params: []Param{NewParam("uri", "string")},
Returns: []Param{
NewParam("contentType", "string"),
NewParam("data", "[]byte"),
},
},
},
}
code, err := GenerateClientGo(svc, "host")
Expect(err).NotTo(HaveOccurred())
codeStr := string(code)
// Should include encoding/binary for raw binary frame parsing
Expect(codeStr).To(ContainSubstring(`"encoding/binary"`))
// Should NOT generate response type struct for raw methods
Expect(codeStr).NotTo(ContainSubstring("streamGetStreamResponse struct"))
// Should still generate request type
Expect(codeStr).To(ContainSubstring("streamGetStreamRequest struct"))
// Should parse binary frame
Expect(codeStr).To(ContainSubstring("responseBytes[0] == 0x01"))
Expect(codeStr).To(ContainSubstring("binary.BigEndian.Uint32"))
// Should return (string, []byte, error)
Expect(codeStr).To(ContainSubstring("func StreamGetStream(uri string) (string, []byte, error)"))
})
})
Describe("GenerateClientGoStub", func() {
@@ -1550,6 +1747,51 @@ var _ = Describe("Rust Generation", func() {
Expect(codeStr).To(ContainSubstring("Result<bool, Error>"))
Expect(codeStr).NotTo(ContainSubstring("Option<bool>"))
})
It("should generate raw extern C import and binary frame parsing for raw methods", func() {
svc := Service{
Name: "Stream",
Permission: "stream",
Interface: "StreamService",
Methods: []Method{
{
Name: "GetStream",
HasError: true,
Raw: true,
Params: []Param{NewParam("uri", "string")},
Returns: []Param{
NewParam("contentType", "string"),
NewParam("data", "[]byte"),
},
Doc: "GetStream returns raw binary stream data.",
},
},
}
code, err := GenerateClientRust(svc)
Expect(err).NotTo(HaveOccurred())
codeStr := string(code)
// Should use extern "C" with wasm_import_module for raw methods, not #[host_fn] extern "ExtismHost"
Expect(codeStr).To(ContainSubstring(`#[link(wasm_import_module = "extism:host/user")]`))
Expect(codeStr).To(ContainSubstring(`extern "C"`))
Expect(codeStr).To(ContainSubstring("fn stream_getstream(offset: u64) -> u64"))
// Should NOT generate response type for raw methods
Expect(codeStr).NotTo(ContainSubstring("StreamGetStreamResponse"))
// Should generate request type (request is still JSON)
Expect(codeStr).To(ContainSubstring("struct StreamGetStreamRequest"))
// Should return Result<(String, Vec<u8>), Error>
Expect(codeStr).To(ContainSubstring("Result<(String, Vec<u8>), Error>"))
// Should parse binary frame
Expect(codeStr).To(ContainSubstring("response_bytes[0] == 0x01"))
Expect(codeStr).To(ContainSubstring("u32::from_be_bytes"))
Expect(codeStr).To(ContainSubstring("String::from_utf8_lossy"))
})
})
})
+8
View File
@@ -761,6 +761,7 @@ func parseMethod(name string, funcType *ast.FuncType, annotation map[string]stri
m := Method{
Name: name,
ExportName: annotation["name"],
Raw: annotation["raw"] == "true",
Doc: doc,
}
@@ -799,6 +800,13 @@ func parseMethod(name string, funcType *ast.FuncType, annotation map[string]stri
}
}
// Validate raw=true methods: must return exactly (string, []byte, error)
if m.Raw {
if !m.HasError || len(m.Returns) != 2 || m.Returns[0].Type != "string" || m.Returns[1].Type != "[]byte" {
return m, fmt.Errorf("raw=true method %s must return (string, []byte, error) — content-type, data, error", name)
}
}
return m, nil
}
+113
View File
@@ -122,6 +122,119 @@ type TestService interface {
Expect(services[0].Methods[0].Name).To(Equal("Exported"))
})
It("should parse raw=true annotation", func() {
src := `package host
import "context"
//nd:hostservice name=Stream permission=stream
type StreamService interface {
//nd:hostfunc raw=true
GetStream(ctx context.Context, uri string) (contentType string, data []byte, err error)
}
`
err := os.WriteFile(filepath.Join(tmpDir, "stream.go"), []byte(src), 0600)
Expect(err).NotTo(HaveOccurred())
services, err := ParseDirectory(tmpDir)
Expect(err).NotTo(HaveOccurred())
Expect(services).To(HaveLen(1))
m := services[0].Methods[0]
Expect(m.Name).To(Equal("GetStream"))
Expect(m.Raw).To(BeTrue())
Expect(m.HasError).To(BeTrue())
Expect(m.Returns).To(HaveLen(2))
Expect(m.Returns[0].Name).To(Equal("contentType"))
Expect(m.Returns[0].Type).To(Equal("string"))
Expect(m.Returns[1].Name).To(Equal("data"))
Expect(m.Returns[1].Type).To(Equal("[]byte"))
})
It("should set Raw=false when raw annotation is absent", func() {
src := `package host
import "context"
//nd:hostservice name=Test permission=test
type TestService interface {
//nd:hostfunc
Call(ctx context.Context, uri string) (response string, err error)
}
`
err := os.WriteFile(filepath.Join(tmpDir, "test.go"), []byte(src), 0600)
Expect(err).NotTo(HaveOccurred())
services, err := ParseDirectory(tmpDir)
Expect(err).NotTo(HaveOccurred())
Expect(services[0].Methods[0].Raw).To(BeFalse())
})
It("should reject raw=true with invalid return signature", func() {
src := `package host
import "context"
//nd:hostservice name=Test permission=test
type TestService interface {
//nd:hostfunc raw=true
BadRaw(ctx context.Context, uri string) (result string, err error)
}
`
err := os.WriteFile(filepath.Join(tmpDir, "test.go"), []byte(src), 0600)
Expect(err).NotTo(HaveOccurred())
_, err = ParseDirectory(tmpDir)
Expect(err).To(HaveOccurred())
Expect(err.Error()).To(ContainSubstring("raw=true"))
Expect(err.Error()).To(ContainSubstring("must return (string, []byte, error)"))
})
It("should reject raw=true without error return", func() {
src := `package host
import "context"
//nd:hostservice name=Test permission=test
type TestService interface {
//nd:hostfunc raw=true
BadRaw(ctx context.Context, uri string) (contentType string, data []byte)
}
`
err := os.WriteFile(filepath.Join(tmpDir, "test.go"), []byte(src), 0600)
Expect(err).NotTo(HaveOccurred())
_, err = ParseDirectory(tmpDir)
Expect(err).To(HaveOccurred())
Expect(err.Error()).To(ContainSubstring("raw=true"))
})
It("should parse mixed raw and non-raw methods", func() {
src := `package host
import "context"
//nd:hostservice name=API permission=api
type APIService interface {
//nd:hostfunc
Call(ctx context.Context, uri string) (responseJSON string, err error)
//nd:hostfunc raw=true
CallRaw(ctx context.Context, uri string) (contentType string, data []byte, err error)
}
`
err := os.WriteFile(filepath.Join(tmpDir, "api.go"), []byte(src), 0600)
Expect(err).NotTo(HaveOccurred())
services, err := ParseDirectory(tmpDir)
Expect(err).NotTo(HaveOccurred())
Expect(services).To(HaveLen(1))
Expect(services[0].Methods).To(HaveLen(2))
Expect(services[0].Methods[0].Raw).To(BeFalse())
Expect(services[0].Methods[1].Raw).To(BeTrue())
Expect(services[0].HasRawMethods()).To(BeTrue())
})
It("should handle custom export name", func() {
src := `package host
@@ -8,6 +8,9 @@
package {{.Package}}
import (
{{- if .Service.HasRawMethods}}
"encoding/binary"
{{- end}}
"encoding/json"
{{- if .Service.HasErrors}}
"errors"
@@ -49,7 +52,7 @@ type {{requestType .}} struct {
{{- end}}
}
{{- end}}
{{- if not .IsErrorOnly}}
{{- if and (not .IsErrorOnly) (not .Raw)}}
type {{responseType .}} struct {
{{- range .Returns}}
@@ -95,7 +98,27 @@ func {{$.Service.Name}}{{.Name}}({{range $i, $p := .Params}}{{if $i}}, {{end}}{{
// Read the response from memory
responseMem := pdk.FindMemory(responsePtr)
responseBytes := responseMem.ReadBytes()
{{- if .IsErrorOnly}}
{{- if .Raw}}
// Parse binary-framed response
if len(responseBytes) == 0 {
return "", nil, errors.New("empty response from host")
}
if responseBytes[0] == 0x01 { // error
return "", nil, errors.New(string(responseBytes[1:]))
}
if responseBytes[0] != 0x00 {
return "", nil, errors.New("unknown response status")
}
if len(responseBytes) < 5 {
return "", nil, errors.New("malformed raw response: incomplete header")
}
ctLen := binary.BigEndian.Uint32(responseBytes[1:5])
if uint32(len(responseBytes)) < 5+ctLen {
return "", nil, errors.New("malformed raw response: content-type overflow")
}
return string(responseBytes[5 : 5+ctLen]), responseBytes[5+ctLen:], nil
{{- else if .IsErrorOnly}}
// Parse error-only response
var response struct {
@@ -8,10 +8,13 @@
# main __init__.py file. Copy the needed functions from this file into your plugin.
from dataclasses import dataclass
from typing import Any
from typing import Any{{- if .Service.HasRawMethods}}, Tuple{{end}}
import extism
import json
{{- if .Service.HasRawMethods}}
import struct
{{- end}}
class HostFunctionError(Exception):
@@ -29,7 +32,7 @@ def _{{exportName .}}(offset: int) -> int:
{{- end}}
{{- /* Generate dataclasses for multi-value returns */ -}}
{{range .Service.Methods}}
{{- if .NeedsResultClass}}
{{- if and .NeedsResultClass (not .Raw)}}
@dataclass
@@ -44,7 +47,7 @@ class {{pythonResultType .}}:
{{range .Service.Methods}}
def {{pythonFunc .}}({{range $i, $p := .Params}}{{if $i}}, {{end}}{{$p.PythonName}}: {{$p.PythonType}}{{end}}){{if .NeedsResultClass}} -> {{pythonResultType .}}{{else if .HasReturns}} -> {{(index .Returns 0).PythonType}}{{else}} -> None{{end}}:
def {{pythonFunc .}}({{range $i, $p := .Params}}{{if $i}}, {{end}}{{$p.PythonName}}: {{$p.PythonType}}{{end}}){{if .Raw}} -> Tuple[str, bytes]{{else if .NeedsResultClass}} -> {{pythonResultType .}}{{else if .HasReturns}} -> {{(index .Returns 0).PythonType}}{{else}} -> None{{end}}:
"""{{if .Doc}}{{.Doc}}{{else}}Call the {{exportName .}} host function.{{end}}
{{- if .HasParams}}
@@ -53,7 +56,11 @@ def {{pythonFunc .}}({{range $i, $p := .Params}}{{if $i}}, {{end}}{{$p.PythonNam
{{.PythonName}}: {{.PythonType}} parameter.
{{- end}}
{{- end}}
{{- if .HasReturns}}
{{- if .Raw}}
Returns:
Tuple of (content_type, data) with the raw binary response.
{{- else if .HasReturns}}
Returns:
{{- if .NeedsResultClass}}
@@ -79,6 +86,24 @@ def {{pythonFunc .}}({{range $i, $p := .Params}}{{if $i}}, {{end}}{{$p.PythonNam
request_mem = extism.memory.alloc(request_bytes)
response_offset = _{{exportName .}}(request_mem.offset)
response_mem = extism.memory.find(response_offset)
{{- if .Raw}}
response_bytes = response_mem.bytes()
if len(response_bytes) == 0:
raise HostFunctionError("empty response from host")
if response_bytes[0] == 0x01:
raise HostFunctionError(response_bytes[1:].decode("utf-8"))
if response_bytes[0] != 0x00:
raise HostFunctionError("unknown response status")
if len(response_bytes) < 5:
raise HostFunctionError("malformed raw response: incomplete header")
ct_len = struct.unpack(">I", response_bytes[1:5])[0]
if len(response_bytes) < 5 + ct_len:
raise HostFunctionError("malformed raw response: content-type overflow")
content_type = response_bytes[5:5 + ct_len].decode("utf-8")
data = response_bytes[5 + ct_len:]
return content_type, data
{{- else}}
response = json.loads(extism.memory.string(response_mem))
{{if .HasError}}
if response.get("error"):
@@ -94,3 +119,4 @@ def {{pythonFunc .}}({{range $i, $p := .Params}}{{if $i}}, {{end}}{{$p.PythonNam
return response.get("{{(index .Returns 0).JSONName}}"{{pythonDefault (index .Returns 0)}})
{{- end}}
{{- end}}
{{- end}}
@@ -33,6 +33,7 @@ struct {{requestType .}} {
{{- end}}
}
{{- end}}
{{- if not .Raw}}
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
@@ -47,16 +48,92 @@ struct {{responseType .}} {
{{- end}}
}
{{- end}}
{{- end}}
#[host_fn]
extern "ExtismHost" {
{{- range .Service.Methods}}
{{- if not .Raw}}
fn {{exportName .}}(input: Json<{{if .HasParams}}{{requestType .}}{{else}}serde_json::Value{{end}}>) -> Json<{{responseType .}}>;
{{- end}}
{{- end}}
}
{{- /* Declare raw extern "C" imports for raw methods */ -}}
{{- range .Service.Methods}}
{{- if .Raw}}
#[link(wasm_import_module = "extism:host/user")]
extern "C" {
fn {{exportName .}}(offset: u64) -> u64;
}
{{- end}}
{{- end}}
{{- /* Generate wrapper functions */ -}}
{{range .Service.Methods}}
{{- if .Raw}}
{{if .Doc}}{{rustDocComment .Doc}}{{else}}/// Calls the {{exportName .}} host function.{{end}}
{{- if .HasParams}}
///
/// # Arguments
{{- range .Params}}
/// * `{{.RustName}}` - {{rustType .}} parameter.
{{- end}}
{{- end}}
///
/// # Returns
/// A tuple of (content_type, data) with the raw binary response.
///
/// # Errors
/// Returns an error if the host function call fails.
pub fn {{rustFunc .}}({{range $i, $p := .Params}}{{if $i}}, {{end}}{{$p.RustName}}: {{rustParamType $p}}{{end}}) -> Result<(String, Vec<u8>), Error> {
{{- if .HasParams}}
let req = {{requestType .}} {
{{- range .Params}}
{{.RustName}}: {{.RustName}}{{if .NeedsToOwned}}.to_owned(){{end}},
{{- end}}
};
let input_bytes = serde_json::to_vec(&req).map_err(|e| Error::msg(e.to_string()))?;
{{- else}}
let input_bytes = b"{}".to_vec();
{{- end}}
let input_mem = Memory::from_bytes(&input_bytes).map_err(|e| Error::msg(e.to_string()))?;
let response_offset = unsafe { {{exportName .}}(input_mem.offset()) };
let response_mem = Memory::find(response_offset)
.ok_or_else(|| Error::msg("empty response from host"))?;
let response_bytes = response_mem.to_vec();
if response_bytes.is_empty() {
return Err(Error::msg("empty response from host"));
}
if response_bytes[0] == 0x01 {
let msg = String::from_utf8_lossy(&response_bytes[1..]).to_string();
return Err(Error::msg(msg));
}
if response_bytes[0] != 0x00 {
return Err(Error::msg("unknown response status"));
}
if response_bytes.len() < 5 {
return Err(Error::msg("malformed raw response: incomplete header"));
}
let ct_len = u32::from_be_bytes([
response_bytes[1],
response_bytes[2],
response_bytes[3],
response_bytes[4],
]) as usize;
if ct_len > response_bytes.len() - 5 {
return Err(Error::msg("malformed raw response: content-type overflow"));
}
let ct_end = 5 + ct_len;
let content_type = String::from_utf8_lossy(&response_bytes[5..ct_end]).to_string();
let data = response_bytes[ct_end..].to_vec();
Ok((content_type, data))
}
{{- else}}
{{if .Doc}}{{rustDocComment .Doc}}{{else}}/// Calls the {{exportName .}} host function.{{end}}
{{- if .HasParams}}
@@ -132,3 +209,4 @@ pub fn {{rustFunc .}}({{range $i, $p := .Params}}{{if $i}}, {{end}}{{$p.RustName
}
{{- end}}
{{- end}}
{{- end}}
@@ -4,6 +4,9 @@ package {{.Package}}
import (
"context"
{{- if .Service.HasRawMethods}}
"encoding/binary"
{{- end}}
"encoding/json"
extism "github.com/extism/go-sdk"
@@ -20,6 +23,7 @@ type {{requestType .}} struct {
{{- end}}
}
{{- end}}
{{- if not .Raw}}
// {{responseType .}} is the response type for {{$.Service.Name}}.{{.Name}}.
type {{responseType .}} struct {
@@ -30,6 +34,7 @@ type {{responseType .}} struct {
Error string `json:"error,omitempty"`
{{- end}}
}
{{- end}}
{{end}}
// Register{{.Service.Name}}HostFunctions registers {{.Service.Name}} service host functions.
@@ -51,18 +56,48 @@ func new{{$.Service.Name}}{{.Name}}HostFunction(service {{$.Service.Interface}})
// Read JSON request from plugin memory
reqBytes, err := p.ReadBytes(stack[0])
if err != nil {
{{- if .Raw}}
{{$.Service.Name | lower}}WriteRawError(p, stack, err)
{{- else}}
{{$.Service.Name | lower}}WriteError(p, stack, err)
{{- end}}
return
}
var req {{requestType .}}
if err := json.Unmarshal(reqBytes, &req); err != nil {
{{- if .Raw}}
{{$.Service.Name | lower}}WriteRawError(p, stack, err)
{{- else}}
{{$.Service.Name | lower}}WriteError(p, stack, err)
{{- end}}
return
}
{{- end}}
// Call the service method
{{- if .HasReturns}}
{{- if .Raw}}
{{range $i, $r := .Returns}}{{if $i}}, {{end}}{{lower $r.Name}}{{end}}, svcErr := service.{{.Name}}(ctx{{range .Params}}, req.{{title .Name}}{{end}})
if svcErr != nil {
{{$.Service.Name | lower}}WriteRawError(p, stack, svcErr)
return
}
// Write binary-framed response to plugin memory:
// [0x00][4-byte content-type length (big-endian)][content-type string][raw data]
ctBytes := []byte({{lower (index .Returns 0).Name}})
frame := make([]byte, 1+4+len(ctBytes)+len({{lower (index .Returns 1).Name}}))
frame[0] = 0x00 // success
binary.BigEndian.PutUint32(frame[1:5], uint32(len(ctBytes)))
copy(frame[5:5+len(ctBytes)], ctBytes)
copy(frame[5+len(ctBytes):], {{lower (index .Returns 1).Name}})
respPtr, err := p.WriteBytes(frame)
if err != nil {
stack[0] = 0
return
}
stack[0] = respPtr
{{- else if .HasReturns}}
{{- if .HasError}}
{{range $i, $r := .Returns}}{{if $i}}, {{end}}{{lower $r.Name}}{{end}}, svcErr := service.{{.Name}}(ctx{{range .Params}}, req.{{title .Name}}{{end}})
if svcErr != nil {
@@ -72,14 +107,6 @@ func new{{$.Service.Name}}{{.Name}}HostFunction(service {{$.Service.Interface}})
{{- else}}
{{range $i, $r := .Returns}}{{if $i}}, {{end}}{{lower $r.Name}}{{end}} := service.{{.Name}}(ctx{{range .Params}}, req.{{title .Name}}{{end}})
{{- end}}
{{- else if .HasError}}
if svcErr := service.{{.Name}}(ctx{{range .Params}}, req.{{title .Name}}{{end}}); svcErr != nil {
{{$.Service.Name | lower}}WriteError(p, stack, svcErr)
return
}
{{- else}}
service.{{.Name}}(ctx{{range .Params}}, req.{{title .Name}}{{end}})
{{- end}}
// Write JSON response to plugin memory
resp := {{responseType .}}{
@@ -88,6 +115,22 @@ func new{{$.Service.Name}}{{.Name}}HostFunction(service {{$.Service.Interface}})
{{- end}}
}
{{$.Service.Name | lower}}WriteResponse(p, stack, resp)
{{- else if .HasError}}
if svcErr := service.{{.Name}}(ctx{{range .Params}}, req.{{title .Name}}{{end}}); svcErr != nil {
{{$.Service.Name | lower}}WriteError(p, stack, svcErr)
return
}
// Write JSON response to plugin memory
resp := {{responseType .}}{}
{{$.Service.Name | lower}}WriteResponse(p, stack, resp)
{{- else}}
service.{{.Name}}(ctx{{range .Params}}, req.{{title .Name}}{{end}})
// Write JSON response to plugin memory
resp := {{responseType .}}{}
{{$.Service.Name | lower}}WriteResponse(p, stack, resp)
{{- end}}
},
[]extism.ValueType{extism.ValueTypePTR},
[]extism.ValueType{extism.ValueTypePTR},
@@ -119,3 +162,16 @@ func {{.Service.Name | lower}}WriteError(p *extism.CurrentPlugin, stack []uint64
respPtr, _ := p.WriteBytes(respBytes)
stack[0] = respPtr
}
{{- if .Service.HasRawMethods}}
// {{.Service.Name | lower}}WriteRawError writes a binary-framed error response to plugin memory.
// Format: [0x01][UTF-8 error message]
func {{.Service.Name | lower}}WriteRawError(p *extism.CurrentPlugin, stack []uint64, err error) {
errMsg := []byte(err.Error())
frame := make([]byte, 1+len(errMsg))
frame[0] = 0x01 // error
copy(frame[1:], errMsg)
respPtr, _ := p.WriteBytes(frame)
stack[0] = respPtr
}
{{- end}}
+11
View File
@@ -173,6 +173,16 @@ func (s Service) HasErrors() bool {
return false
}
// HasRawMethods returns true if any method in the service uses raw binary framing.
func (s Service) HasRawMethods() bool {
for _, m := range s.Methods {
if m.Raw {
return true
}
}
return false
}
// Method represents a host function method within a service.
type Method struct {
Name string // Go method name (e.g., "Call")
@@ -181,6 +191,7 @@ type Method struct {
Returns []Param // Return values (excluding error)
HasError bool // Whether the method returns an error
Doc string // Documentation comment for the method
Raw bool // If true, response uses binary framing instead of JSON
}
// FunctionName returns the Extism host function export name.
+66
View File
@@ -0,0 +1,66 @@
// Code generated by ndpgen. DO NOT EDIT.
//
// This file contains client wrappers for the Stream host service.
// It is intended for use in Navidrome plugins built with TinyGo.
//
//go:build wasip1
package ndpdk
import (
"encoding/binary"
"encoding/json"
"errors"
"github.com/navidrome/navidrome/plugins/pdk/go/pdk"
)
// stream_getstream is the host function provided by Navidrome.
//
//go:wasmimport extism:host/user stream_getstream
func stream_getstream(uint64) uint64
type streamGetStreamRequest struct {
Uri string `json:"uri"`
}
// StreamGetStream calls the stream_getstream host function.
// GetStream returns raw binary stream data with content type.
func StreamGetStream(uri string) (string, []byte, error) {
// Marshal request to JSON
req := streamGetStreamRequest{
Uri: uri,
}
reqBytes, err := json.Marshal(req)
if err != nil {
return "", nil, err
}
reqMem := pdk.AllocateBytes(reqBytes)
defer reqMem.Free()
// Call the host function
responsePtr := stream_getstream(reqMem.Offset())
// Read the response from memory
responseMem := pdk.FindMemory(responsePtr)
responseBytes := responseMem.ReadBytes()
// Parse binary-framed response
if len(responseBytes) == 0 {
return "", nil, errors.New("empty response from host")
}
if responseBytes[0] == 0x01 { // error
return "", nil, errors.New(string(responseBytes[1:]))
}
if responseBytes[0] != 0x00 {
return "", nil, errors.New("unknown response status")
}
if len(responseBytes) < 5 {
return "", nil, errors.New("malformed raw response: incomplete header")
}
ctLen := binary.BigEndian.Uint32(responseBytes[1:5])
if uint32(len(responseBytes)) < 5+ctLen {
return "", nil, errors.New("malformed raw response: content-type overflow")
}
return string(responseBytes[5 : 5+ctLen]), responseBytes[5+ctLen:], nil
}
+63
View File
@@ -0,0 +1,63 @@
# Code generated by ndpgen. DO NOT EDIT.
#
# This file contains client wrappers for the Stream 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, Tuple
import extism
import json
import struct
class HostFunctionError(Exception):
"""Raised when a host function returns an error."""
pass
@extism.import_fn("extism:host/user", "stream_getstream")
def _stream_getstream(offset: int) -> int:
"""Raw host function - do not call directly."""
...
def stream_get_stream(uri: str) -> Tuple[str, bytes]:
"""GetStream returns raw binary stream data with content type.
Args:
uri: str parameter.
Returns:
Tuple of (content_type, data) with the raw binary response.
Raises:
HostFunctionError: If the host function returns an error.
"""
request = {
"uri": uri,
}
request_bytes = json.dumps(request).encode("utf-8")
request_mem = extism.memory.alloc(request_bytes)
response_offset = _stream_getstream(request_mem.offset)
response_mem = extism.memory.find(response_offset)
response_bytes = response_mem.bytes()
if len(response_bytes) == 0:
raise HostFunctionError("empty response from host")
if response_bytes[0] == 0x01:
raise HostFunctionError(response_bytes[1:].decode("utf-8"))
if response_bytes[0] != 0x00:
raise HostFunctionError("unknown response status")
if len(response_bytes) < 5:
raise HostFunctionError("malformed raw response: incomplete header")
ct_len = struct.unpack(">I", response_bytes[1:5])[0]
if len(response_bytes) < 5 + ct_len:
raise HostFunctionError("malformed raw response: content-type overflow")
content_type = response_bytes[5:5 + ct_len].decode("utf-8")
data = response_bytes[5 + ct_len:]
return content_type, data
+73
View File
@@ -0,0 +1,73 @@
// Code generated by ndpgen. DO NOT EDIT.
//
// This file contains client wrappers for the Stream 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 StreamGetStreamRequest {
uri: String,
}
#[host_fn]
extern "ExtismHost" {
}
#[link(wasm_import_module = "extism:host/user")]
extern "C" {
fn stream_getstream(offset: u64) -> u64;
}
/// GetStream returns raw binary stream data with content type.
///
/// # Arguments
/// * `uri` - String parameter.
///
/// # Returns
/// A tuple of (content_type, data) with the raw binary response.
///
/// # Errors
/// Returns an error if the host function call fails.
pub fn get_stream(uri: &str) -> Result<(String, Vec<u8>), Error> {
let req = StreamGetStreamRequest {
uri: uri.to_owned(),
};
let input_bytes = serde_json::to_vec(&req).map_err(|e| Error::msg(e.to_string()))?;
let input_mem = Memory::from_bytes(&input_bytes).map_err(|e| Error::msg(e.to_string()))?;
let response_offset = unsafe { stream_getstream(input_mem.offset()) };
let response_mem = Memory::find(response_offset)
.ok_or_else(|| Error::msg("empty response from host"))?;
let response_bytes = response_mem.to_vec();
if response_bytes.is_empty() {
return Err(Error::msg("empty response from host"));
}
if response_bytes[0] == 0x01 {
let msg = String::from_utf8_lossy(&response_bytes[1..]).to_string();
return Err(Error::msg(msg));
}
if response_bytes[0] != 0x00 {
return Err(Error::msg("unknown response status"));
}
if response_bytes.len() < 5 {
return Err(Error::msg("malformed raw response: incomplete header"));
}
let ct_len = u32::from_be_bytes([
response_bytes[1],
response_bytes[2],
response_bytes[3],
response_bytes[4],
]) as usize;
if ct_len > response_bytes.len() - 5 {
return Err(Error::msg("malformed raw response: content-type overflow"));
}
let ct_end = 5 + ct_len;
let content_type = String::from_utf8_lossy(&response_bytes[5..ct_end]).to_string();
let data = response_bytes[ct_end..].to_vec();
Ok((content_type, data))
}
+10
View File
@@ -0,0 +1,10 @@
package testpkg
import "context"
//nd:hostservice name=Stream permission=stream
type StreamService interface {
// GetStream returns raw binary stream data with content type.
//nd:hostfunc raw=true
GetStream(ctx context.Context, uri string) (contentType string, data []byte, err error)
}
+6
View File
@@ -15,4 +15,10 @@ type SubsonicAPIService interface {
// e.g., "getAlbumList2?type=random&size=10". The response is returned as raw JSON.
//nd:hostfunc
Call(ctx context.Context, uri string) (responseJSON string, err error)
// CallRaw executes a Subsonic API request and returns the raw binary response.
// Optimized for binary endpoints like getCoverArt and stream that return
// non-JSON data. The response is returned as raw bytes without JSON encoding overhead.
//nd:hostfunc raw=true
CallRaw(ctx context.Context, uri string) (contentType string, data []byte, err error)
}
+62
View File
@@ -4,6 +4,7 @@ package host
import (
"context"
"encoding/binary"
"encoding/json"
extism "github.com/extism/go-sdk"
@@ -20,11 +21,17 @@ type SubsonicAPICallResponse struct {
Error string `json:"error,omitempty"`
}
// SubsonicAPICallRawRequest is the request type for SubsonicAPI.CallRaw.
type SubsonicAPICallRawRequest struct {
Uri string `json:"uri"`
}
// RegisterSubsonicAPIHostFunctions registers SubsonicAPI service host functions.
// The returned host functions should be added to the plugin's configuration.
func RegisterSubsonicAPIHostFunctions(service SubsonicAPIService) []extism.HostFunction {
return []extism.HostFunction{
newSubsonicAPICallHostFunction(service),
newSubsonicAPICallRawHostFunction(service),
}
}
@@ -62,6 +69,50 @@ func newSubsonicAPICallHostFunction(service SubsonicAPIService) extism.HostFunct
)
}
func newSubsonicAPICallRawHostFunction(service SubsonicAPIService) extism.HostFunction {
return extism.NewHostFunctionWithStack(
"subsonicapi_callraw",
func(ctx context.Context, p *extism.CurrentPlugin, stack []uint64) {
// Read JSON request from plugin memory
reqBytes, err := p.ReadBytes(stack[0])
if err != nil {
subsonicapiWriteRawError(p, stack, err)
return
}
var req SubsonicAPICallRawRequest
if err := json.Unmarshal(reqBytes, &req); err != nil {
subsonicapiWriteRawError(p, stack, err)
return
}
// Call the service method
contenttype, data, svcErr := service.CallRaw(ctx, req.Uri)
if svcErr != nil {
subsonicapiWriteRawError(p, stack, svcErr)
return
}
// Write binary-framed response to plugin memory:
// [0x00][4-byte content-type length (big-endian)][content-type string][raw data]
ctBytes := []byte(contenttype)
frame := make([]byte, 1+4+len(ctBytes)+len(data))
frame[0] = 0x00 // success
binary.BigEndian.PutUint32(frame[1:5], uint32(len(ctBytes)))
copy(frame[5:5+len(ctBytes)], ctBytes)
copy(frame[5+len(ctBytes):], data)
respPtr, err := p.WriteBytes(frame)
if err != nil {
stack[0] = 0
return
}
stack[0] = respPtr
},
[]extism.ValueType{extism.ValueTypePTR},
[]extism.ValueType{extism.ValueTypePTR},
)
}
// subsonicapiWriteResponse writes a JSON response to plugin memory.
func subsonicapiWriteResponse(p *extism.CurrentPlugin, stack []uint64, resp any) {
respBytes, err := json.Marshal(resp)
@@ -86,3 +137,14 @@ func subsonicapiWriteError(p *extism.CurrentPlugin, stack []uint64, err error) {
respPtr, _ := p.WriteBytes(respBytes)
stack[0] = respPtr
}
// subsonicapiWriteRawError writes a binary-framed error response to plugin memory.
// Format: [0x01][UTF-8 error message]
func subsonicapiWriteRawError(p *extism.CurrentPlugin, stack []uint64, err error) {
errMsg := []byte(err.Error())
frame := make([]byte, 1+len(errMsg))
frame[0] = 0x01 // error
copy(frame[1:], errMsg)
respPtr, _ := p.WriteBytes(frame)
stack[0] = respPtr
}
+30 -9
View File
@@ -24,7 +24,7 @@ const subsonicAPIVersion = "1.16.1"
//
// 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).
// Automatic Parameters: The service adds 'c' (client), 'v' (version), and optionally 'f' (format).
type subsonicAPIServiceImpl struct {
pluginID string
router SubsonicRouter
@@ -50,15 +50,18 @@ func newSubsonicAPIService(pluginID string, router SubsonicRouter, ds model.Data
}
}
func (s *subsonicAPIServiceImpl) Call(ctx context.Context, uri string) (string, error) {
// executeRequest handles URL parsing, validation, permission checks, HTTP request creation,
// and router invocation. Shared between Call and CallRaw.
// If setJSON is true, the 'f=json' query parameter is added.
func (s *subsonicAPIServiceImpl) executeRequest(ctx context.Context, uri string, setJSON bool) (*httptest.ResponseRecorder, error) {
if s.router == nil {
return "", fmt.Errorf("SubsonicAPI router not available")
return nil, 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)
return nil, fmt.Errorf("invalid URL format: %w", err)
}
// Extract query parameters
@@ -67,18 +70,20 @@ func (s *subsonicAPIServiceImpl) Call(ctx context.Context, uri string) (string,
// Validate that 'u' (username) parameter is present
username := query.Get("u")
if username == "" {
return "", fmt.Errorf("missing required parameter 'u' (username)")
return nil, 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
return nil, 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
if setJSON {
query.Set("f", "json") // Response format
}
// Extract the endpoint from the path
endpoint := path.Base(parsedURL.Path)
@@ -96,7 +101,7 @@ func (s *subsonicAPIServiceImpl) Call(ctx context.Context, uri string) (string,
// 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)
return nil, fmt.Errorf("failed to create HTTP request: %w", err)
}
// Set internal authentication context using the username from the 'u' parameter
@@ -109,10 +114,26 @@ func (s *subsonicAPIServiceImpl) Call(ctx context.Context, uri string) (string,
// Call the subsonic router
s.router.ServeHTTP(recorder, httpReq)
// Return the response body as JSON
return recorder, nil
}
func (s *subsonicAPIServiceImpl) Call(ctx context.Context, uri string) (string, error) {
recorder, err := s.executeRequest(ctx, uri, true)
if err != nil {
return "", err
}
return recorder.Body.String(), nil
}
func (s *subsonicAPIServiceImpl) CallRaw(ctx context.Context, uri string) (string, []byte, error) {
recorder, err := s.executeRequest(ctx, uri, false)
if err != nil {
return "", nil, err
}
contentType := recorder.Header().Get("Content-Type")
return contentType, recorder.Body.Bytes(), nil
}
func (s *subsonicAPIServiceImpl) checkPermissions(ctx context.Context, username string) error {
// If allUsers is true, allow any user
if s.allUsers {
+134 -8
View File
@@ -8,6 +8,7 @@ import (
"encoding/json"
"net/http"
"os"
"path"
"path/filepath"
"github.com/navidrome/navidrome/conf"
@@ -177,6 +178,61 @@ var _ = Describe("SubsonicAPI Host Function", Ordered, func() {
Expect(err.Error()).To(ContainSubstring("missing required parameter"))
})
})
Describe("SubsonicAPI CallRaw", func() {
var plugin *plugin
BeforeEach(func() {
manager.mu.RLock()
plugin = manager.plugins["test-subsonicapi-plugin"]
manager.mu.RUnlock()
Expect(plugin).ToNot(BeNil())
})
It("successfully calls getCoverArt and returns binary data", func() {
instance, err := plugin.instance(GinkgoT().Context())
Expect(err).ToNot(HaveOccurred())
defer instance.Close(GinkgoT().Context())
exit, output, err := instance.Call("call_subsonic_api_raw", []byte("/getCoverArt?u=testuser&id=al-1"))
Expect(err).ToNot(HaveOccurred())
Expect(exit).To(Equal(uint32(0)))
// Parse the metadata response from the test plugin
var result map[string]any
err = json.Unmarshal(output, &result)
Expect(err).ToNot(HaveOccurred())
Expect(result["contentType"]).To(Equal("image/png"))
Expect(result["size"]).To(BeNumerically("==", len(fakePNGHeader)))
Expect(result["firstByte"]).To(BeNumerically("==", 0x89)) // PNG magic byte
})
It("does NOT set f=json parameter for raw calls", func() {
instance, err := plugin.instance(GinkgoT().Context())
Expect(err).ToNot(HaveOccurred())
defer instance.Close(GinkgoT().Context())
_, _, err = instance.Call("call_subsonic_api_raw", []byte("/getCoverArt?u=testuser&id=al-1"))
Expect(err).ToNot(HaveOccurred())
Expect(router.lastRequest).ToNot(BeNil())
query := router.lastRequest.URL.Query()
Expect(query.Get("f")).To(BeEmpty())
Expect(query.Get("c")).To(Equal("test-subsonicapi-plugin"))
Expect(query.Get("v")).To(Equal("1.16.1"))
})
It("returns error when username is missing", func() {
instance, err := plugin.instance(GinkgoT().Context())
Expect(err).ToNot(HaveOccurred())
defer instance.Close(GinkgoT().Context())
exit, _, err := instance.Call("call_subsonic_api_raw", []byte("/getCoverArt"))
Expect(err).To(HaveOccurred())
Expect(exit).To(Equal(uint32(1)))
Expect(err.Error()).To(ContainSubstring("missing required parameter"))
})
})
})
var _ = Describe("SubsonicAPIService", func() {
@@ -323,6 +379,66 @@ var _ = Describe("SubsonicAPIService", func() {
})
})
Describe("CallRaw", func() {
It("returns binary data and content-type", func() {
service := newSubsonicAPIService("test-plugin", router, dataStore, nil, true)
ctx := GinkgoT().Context()
contentType, data, err := service.CallRaw(ctx, "/getCoverArt?u=testuser&id=al-1")
Expect(err).ToNot(HaveOccurred())
Expect(contentType).To(Equal("image/png"))
Expect(data).To(Equal(fakePNGHeader))
})
It("does not set f=json parameter", func() {
service := newSubsonicAPIService("test-plugin", router, dataStore, nil, true)
ctx := GinkgoT().Context()
_, _, err := service.CallRaw(ctx, "/getCoverArt?u=testuser&id=al-1")
Expect(err).ToNot(HaveOccurred())
Expect(router.lastRequest).ToNot(BeNil())
query := router.lastRequest.URL.Query()
Expect(query.Get("f")).To(BeEmpty())
})
It("enforces permission checks", func() {
service := newSubsonicAPIService("test-plugin", router, dataStore, []string{"user2"}, false)
ctx := GinkgoT().Context()
_, _, err := service.CallRaw(ctx, "/getCoverArt?u=testuser&id=al-1")
Expect(err).To(HaveOccurred())
Expect(err.Error()).To(ContainSubstring("not authorized"))
})
It("returns error when username is missing", func() {
service := newSubsonicAPIService("test-plugin", router, dataStore, nil, true)
ctx := GinkgoT().Context()
_, _, err := service.CallRaw(ctx, "/getCoverArt")
Expect(err).To(HaveOccurred())
Expect(err.Error()).To(ContainSubstring("missing required parameter"))
})
It("returns error when router is nil", func() {
service := newSubsonicAPIService("test-plugin", nil, dataStore, nil, true)
ctx := GinkgoT().Context()
_, _, err := service.CallRaw(ctx, "/getCoverArt?u=testuser")
Expect(err).To(HaveOccurred())
Expect(err.Error()).To(ContainSubstring("router not available"))
})
It("returns error for invalid URL", func() {
service := newSubsonicAPIService("test-plugin", router, dataStore, nil, true)
ctx := GinkgoT().Context()
_, _, err := service.CallRaw(ctx, "://invalid")
Expect(err).To(HaveOccurred())
Expect(err.Error()).To(ContainSubstring("invalid URL"))
})
})
Describe("Router Availability", func() {
It("returns error when router is nil", func() {
service := newSubsonicAPIService("test-plugin", nil, dataStore, nil, true)
@@ -335,6 +451,9 @@ var _ = Describe("SubsonicAPIService", func() {
})
})
// fakePNGHeader is a minimal PNG file header used in tests.
var fakePNGHeader = []byte{0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A}
// fakeSubsonicRouter is a mock Subsonic router that returns predictable responses.
type fakeSubsonicRouter struct {
lastRequest *http.Request
@@ -343,13 +462,20 @@ type fakeSubsonicRouter struct {
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",
},
endpoint := path.Base(req.URL.Path)
switch endpoint {
case "getCoverArt":
w.Header().Set("Content-Type", "image/png")
_, _ = w.Write(fakePNGHeader)
default:
// 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)
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(response)
}
-7
View File
@@ -6,10 +6,3 @@ require (
github.com/extism/go-pdk v1.1.3
github.com/stretchr/testify v1.11.1
)
require (
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/stretchr/objx v0.5.2 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)
@@ -8,6 +8,7 @@
package host
import (
"encoding/binary"
"encoding/json"
"errors"
@@ -19,6 +20,11 @@ import (
//go:wasmimport extism:host/user subsonicapi_call
func subsonicapi_call(uint64) uint64
// subsonicapi_callraw is the host function provided by Navidrome.
//
//go:wasmimport extism:host/user subsonicapi_callraw
func subsonicapi_callraw(uint64) uint64
type subsonicAPICallRequest struct {
Uri string `json:"uri"`
}
@@ -28,6 +34,10 @@ type subsonicAPICallResponse struct {
Error string `json:"error,omitempty"`
}
type subsonicAPICallRawRequest struct {
Uri string `json:"uri"`
}
// SubsonicAPICall calls the subsonicapi_call host function.
// Call executes a Subsonic API request and returns the JSON response.
//
@@ -65,3 +75,46 @@ func SubsonicAPICall(uri string) (string, error) {
return response.ResponseJSON, nil
}
// SubsonicAPICallRaw calls the subsonicapi_callraw host function.
// CallRaw executes a Subsonic API request and returns the raw binary response.
// Optimized for binary endpoints like getCoverArt and stream that return
// non-JSON data. The response is returned as raw bytes without JSON encoding overhead.
func SubsonicAPICallRaw(uri string) (string, []byte, error) {
// Marshal request to JSON
req := subsonicAPICallRawRequest{
Uri: uri,
}
reqBytes, err := json.Marshal(req)
if err != nil {
return "", nil, err
}
reqMem := pdk.AllocateBytes(reqBytes)
defer reqMem.Free()
// Call the host function
responsePtr := subsonicapi_callraw(reqMem.Offset())
// Read the response from memory
responseMem := pdk.FindMemory(responsePtr)
responseBytes := responseMem.ReadBytes()
// Parse binary-framed response
if len(responseBytes) == 0 {
return "", nil, errors.New("empty response from host")
}
if responseBytes[0] == 0x01 { // error
return "", nil, errors.New(string(responseBytes[1:]))
}
if responseBytes[0] != 0x00 {
return "", nil, errors.New("unknown response status")
}
if len(responseBytes) < 5 {
return "", nil, errors.New("malformed raw response: incomplete header")
}
ctLen := binary.BigEndian.Uint32(responseBytes[1:5])
if uint32(len(responseBytes)) < 5+ctLen {
return "", nil, errors.New("malformed raw response: content-type overflow")
}
return string(responseBytes[5 : 5+ctLen]), responseBytes[5+ctLen:], nil
}
@@ -33,3 +33,17 @@ func (m *mockSubsonicAPIService) Call(uri string) (string, error) {
func SubsonicAPICall(uri string) (string, error) {
return SubsonicAPIMock.Call(uri)
}
// CallRaw is the mock method for SubsonicAPICallRaw.
func (m *mockSubsonicAPIService) CallRaw(uri string) (string, []byte, error) {
args := m.Called(uri)
return args.String(0), args.Get(1).([]byte), args.Error(2)
}
// SubsonicAPICallRaw delegates to the mock instance.
// CallRaw executes a Subsonic API request and returns the raw binary response.
// Optimized for binary endpoints like getCoverArt and stream that return
// non-JSON data. The response is returned as raw bytes without JSON encoding overhead.
func SubsonicAPICallRaw(uri string) (string, []byte, error) {
return SubsonicAPIMock.CallRaw(uri)
}
+47 -1
View File
@@ -8,10 +8,11 @@
# main __init__.py file. Copy the needed functions from this file into your plugin.
from dataclasses import dataclass
from typing import Any
from typing import Any, Tuple
import extism
import json
import struct
class HostFunctionError(Exception):
@@ -25,6 +26,12 @@ def _subsonicapi_call(offset: int) -> int:
...
@extism.import_fn("extism:host/user", "subsonicapi_callraw")
def _subsonicapi_callraw(offset: int) -> int:
"""Raw host function - do not call directly."""
...
def subsonicapi_call(uri: str) -> str:
"""Call executes a Subsonic API request and returns the JSON response.
@@ -53,3 +60,42 @@ e.g., "getAlbumList2?type=random&size=10". The response is returned as raw JSON.
raise HostFunctionError(response["error"])
return response.get("responseJson", "")
def subsonicapi_call_raw(uri: str) -> Tuple[str, bytes]:
"""CallRaw executes a Subsonic API request and returns the raw binary response.
Optimized for binary endpoints like getCoverArt and stream that return
non-JSON data. The response is returned as raw bytes without JSON encoding overhead.
Args:
uri: str parameter.
Returns:
Tuple of (content_type, data) with the raw binary response.
Raises:
HostFunctionError: If the host function returns an error.
"""
request = {
"uri": uri,
}
request_bytes = json.dumps(request).encode("utf-8")
request_mem = extism.memory.alloc(request_bytes)
response_offset = _subsonicapi_callraw(request_mem.offset)
response_mem = extism.memory.find(response_offset)
response_bytes = response_mem.bytes()
if len(response_bytes) == 0:
raise HostFunctionError("empty response from host")
if response_bytes[0] == 0x01:
raise HostFunctionError(response_bytes[1:].decode("utf-8"))
if response_bytes[0] != 0x00:
raise HostFunctionError("unknown response status")
if len(response_bytes) < 5:
raise HostFunctionError("malformed raw response: incomplete header")
ct_len = struct.unpack(">I", response_bytes[1:5])[0]
if len(response_bytes) < 5 + ct_len:
raise HostFunctionError("malformed raw response: content-type overflow")
content_type = response_bytes[5:5 + ct_len].decode("utf-8")
data = response_bytes[5 + ct_len:]
return content_type, data
@@ -21,11 +21,22 @@ struct SubsonicAPICallResponse {
error: Option<String>,
}
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
struct SubsonicAPICallRawRequest {
uri: String,
}
#[host_fn]
extern "ExtismHost" {
fn subsonicapi_call(input: Json<SubsonicAPICallRequest>) -> Json<SubsonicAPICallResponse>;
}
#[link(wasm_import_module = "extism:host/user")]
extern "C" {
fn subsonicapi_callraw(offset: u64) -> u64;
}
/// Call executes a Subsonic API request and returns the JSON response.
///
/// The uri parameter should be the Subsonic API path without the server prefix,
@@ -52,3 +63,56 @@ pub fn call(uri: &str) -> Result<String, Error> {
Ok(response.0.response_json)
}
/// CallRaw executes a Subsonic API request and returns the raw binary response.
/// Optimized for binary endpoints like getCoverArt and stream that return
/// non-JSON data. The response is returned as raw bytes without JSON encoding overhead.
///
/// # Arguments
/// * `uri` - String parameter.
///
/// # Returns
/// A tuple of (content_type, data) with the raw binary response.
///
/// # Errors
/// Returns an error if the host function call fails.
pub fn call_raw(uri: &str) -> Result<(String, Vec<u8>), Error> {
let req = SubsonicAPICallRawRequest {
uri: uri.to_owned(),
};
let input_bytes = serde_json::to_vec(&req).map_err(|e| Error::msg(e.to_string()))?;
let input_mem = Memory::from_bytes(&input_bytes).map_err(|e| Error::msg(e.to_string()))?;
let response_offset = unsafe { subsonicapi_callraw(input_mem.offset()) };
let response_mem = Memory::find(response_offset)
.ok_or_else(|| Error::msg("empty response from host"))?;
let response_bytes = response_mem.to_vec();
if response_bytes.is_empty() {
return Err(Error::msg("empty response from host"));
}
if response_bytes[0] == 0x01 {
let msg = String::from_utf8_lossy(&response_bytes[1..]).to_string();
return Err(Error::msg(msg));
}
if response_bytes[0] != 0x00 {
return Err(Error::msg("unknown response status"));
}
if response_bytes.len() < 5 {
return Err(Error::msg("malformed raw response: incomplete header"));
}
let ct_len = u32::from_be_bytes([
response_bytes[1],
response_bytes[2],
response_bytes[3],
response_bytes[4],
]) as usize;
if ct_len > response_bytes.len() - 5 {
return Err(Error::msg("malformed raw response: content-type overflow"));
}
let ct_end = 5 + ct_len;
let content_type = String::from_utf8_lossy(&response_bytes[5..ct_end]).to_string();
let data = response_bytes[ct_end..].to_vec();
Ok((content_type, data))
}
+26
View File
@@ -3,6 +3,8 @@
package main
import (
"fmt"
"github.com/navidrome/navidrome/plugins/pdk/go/host"
"github.com/navidrome/navidrome/plugins/pdk/go/pdk"
)
@@ -28,4 +30,28 @@ func callSubsonicAPIExport() int32 {
return 0
}
// call_subsonic_api_raw is the exported function that tests the SubsonicAPI CallRaw host function.
// Input: URI string (e.g., "/getCoverArt?u=testuser&id=al-1")
// Output: JSON with contentType, size, and first bytes of the raw response
//
//go:wasmexport call_subsonic_api_raw
func callSubsonicAPIRawExport() int32 {
uri := pdk.InputString()
contentType, data, err := host.SubsonicAPICallRaw(uri)
if err != nil {
pdk.SetErrorString("failed to call SubsonicAPI raw: " + err.Error())
return 1
}
// Return metadata about the raw response as JSON
firstByte := 0
if len(data) > 0 {
firstByte = int(data[0])
}
result := fmt.Sprintf(`{"contentType":%q,"size":%d,"firstByte":%d}`, contentType, len(data), firstByte)
pdk.OutputString(result)
return 0
}
func main() {}