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