Compare commits

..

1 Commits

63 changed files with 1174 additions and 920 deletions

View File

@@ -1,5 +0,0 @@
-- +goose Up
ALTER TABLE plugin ADD COLUMN allow_write_access BOOL NOT NULL DEFAULT false;
-- +goose Down
ALTER TABLE plugin DROP COLUMN allow_write_access;

View File

@@ -3,20 +3,19 @@ package model
import "time"
type Plugin struct {
ID string `structs:"id" json:"id"`
Path string `structs:"path" json:"path"`
Manifest string `structs:"manifest" json:"manifest"`
Config string `structs:"config" json:"config,omitempty"`
Users string `structs:"users" json:"users,omitempty"`
AllUsers bool `structs:"all_users" json:"allUsers,omitempty"`
Libraries string `structs:"libraries" json:"libraries,omitempty"`
AllLibraries bool `structs:"all_libraries" json:"allLibraries,omitempty"`
AllowWriteAccess bool `structs:"allow_write_access" json:"allowWriteAccess,omitempty"`
Enabled bool `structs:"enabled" json:"enabled"`
LastError string `structs:"last_error" json:"lastError,omitempty"`
SHA256 string `structs:"sha256" json:"sha256"`
CreatedAt time.Time `structs:"created_at" json:"createdAt"`
UpdatedAt time.Time `structs:"updated_at" json:"updatedAt"`
ID string `structs:"id" json:"id"`
Path string `structs:"path" json:"path"`
Manifest string `structs:"manifest" json:"manifest"`
Config string `structs:"config" json:"config,omitempty"`
Users string `structs:"users" json:"users,omitempty"`
AllUsers bool `structs:"all_users" json:"allUsers,omitempty"`
Libraries string `structs:"libraries" json:"libraries,omitempty"`
AllLibraries bool `structs:"all_libraries" json:"allLibraries,omitempty"`
Enabled bool `structs:"enabled" json:"enabled"`
LastError string `structs:"last_error" json:"lastError,omitempty"`
SHA256 string `structs:"sha256" json:"sha256"`
CreatedAt time.Time `structs:"created_at" json:"createdAt"`
UpdatedAt time.Time `structs:"updated_at" json:"updatedAt"`
}
type Plugins []Plugin

View File

@@ -79,8 +79,8 @@ func (r *pluginRepository) Put(plugin *model.Plugin) error {
// Upsert using INSERT ... ON CONFLICT for atomic operation
_, err := r.db.NewQuery(`
INSERT INTO plugin (id, path, manifest, config, users, all_users, libraries, all_libraries, allow_write_access, enabled, last_error, sha256, created_at, updated_at)
VALUES ({:id}, {:path}, {:manifest}, {:config}, {:users}, {:all_users}, {:libraries}, {:all_libraries}, {:allow_write_access}, {:enabled}, {:last_error}, {:sha256}, {:created_at}, {:updated_at})
INSERT INTO plugin (id, path, manifest, config, users, all_users, libraries, all_libraries, enabled, last_error, sha256, created_at, updated_at)
VALUES ({:id}, {:path}, {:manifest}, {:config}, {:users}, {:all_users}, {:libraries}, {:all_libraries}, {:enabled}, {:last_error}, {:sha256}, {:created_at}, {:updated_at})
ON CONFLICT(id) DO UPDATE SET
path = excluded.path,
manifest = excluded.manifest,
@@ -89,26 +89,24 @@ func (r *pluginRepository) Put(plugin *model.Plugin) error {
all_users = excluded.all_users,
libraries = excluded.libraries,
all_libraries = excluded.all_libraries,
allow_write_access = excluded.allow_write_access,
enabled = excluded.enabled,
last_error = excluded.last_error,
sha256 = excluded.sha256,
updated_at = excluded.updated_at
`).Bind(dbx.Params{
"id": plugin.ID,
"path": plugin.Path,
"manifest": plugin.Manifest,
"config": plugin.Config,
"users": plugin.Users,
"all_users": plugin.AllUsers,
"libraries": plugin.Libraries,
"all_libraries": plugin.AllLibraries,
"allow_write_access": plugin.AllowWriteAccess,
"enabled": plugin.Enabled,
"last_error": plugin.LastError,
"sha256": plugin.SHA256,
"created_at": time.Now(),
"updated_at": plugin.UpdatedAt,
"id": plugin.ID,
"path": plugin.Path,
"manifest": plugin.Manifest,
"config": plugin.Config,
"users": plugin.Users,
"all_users": plugin.AllUsers,
"libraries": plugin.Libraries,
"all_libraries": plugin.AllLibraries,
"enabled": plugin.Enabled,
"last_error": plugin.LastError,
"sha256": plugin.SHA256,
"created_at": time.Now(),
"updated_at": plugin.UpdatedAt,
}).Execute()
return err
}

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

View File

@@ -256,15 +256,6 @@ func GenerateClientRust(svc Service) ([]byte, error) {
return nil, fmt.Errorf("parsing template: %w", err)
}
partialContent, err := templatesFS.ReadFile("templates/base64_bytes.rs.tmpl")
if err != nil {
return nil, fmt.Errorf("reading base64_bytes partial: %w", err)
}
tmpl, err = tmpl.Parse(string(partialContent))
if err != nil {
return nil, fmt.Errorf("parsing base64_bytes partial: %w", err)
}
data := templateData{
Service: svc,
}
@@ -631,15 +622,6 @@ func GenerateCapabilityRust(cap Capability) ([]byte, error) {
return nil, fmt.Errorf("parsing template: %w", err)
}
partialContent, err := templatesFS.ReadFile("templates/base64_bytes.rs.tmpl")
if err != nil {
return nil, fmt.Errorf("reading base64_bytes partial: %w", err)
}
tmpl, err = tmpl.Parse(string(partialContent))
if err != nil {
return nil, fmt.Errorf("parsing base64_bytes partial: %w", err)
}
data := capabilityTemplateData{
Package: cap.Name,
Capability: cap,

View File

@@ -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{
@@ -627,7 +717,49 @@ var _ = Describe("Generator", func() {
Expect(codeStr).To(ContainSubstring(`response.get("boolVal", False)`))
})
It("should not import base64 for non-byte services", func() {
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",
@@ -647,37 +779,8 @@ var _ = Describe("Generator", func() {
codeStr := string(code)
Expect(codeStr).NotTo(ContainSubstring("import base64"))
})
It("should generate base64 encoding/decoding for byte fields", func() {
svc := Service{
Name: "Codec",
Permission: "codec",
Interface: "CodecService",
Methods: []Method{
{
Name: "Encode",
HasError: true,
Params: []Param{NewParam("data", "[]byte")},
Returns: []Param{NewParam("result", "[]byte")},
},
},
}
code, err := GenerateClientPython(svc)
Expect(err).NotTo(HaveOccurred())
codeStr := string(code)
// Should import base64
Expect(codeStr).To(ContainSubstring("import base64"))
// Should base64-encode byte params in request
Expect(codeStr).To(ContainSubstring(`base64.b64encode(data).decode("ascii")`))
// Should base64-decode byte returns in response
Expect(codeStr).To(ContainSubstring(`base64.b64decode(response.get("result", ""))`))
Expect(codeStr).NotTo(ContainSubstring("Tuple"))
Expect(codeStr).NotTo(ContainSubstring("import struct"))
})
})
@@ -836,6 +939,46 @@ var _ = Describe("Generator", func() {
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() {
@@ -1605,45 +1748,22 @@ var _ = Describe("Rust Generation", func() {
Expect(codeStr).NotTo(ContainSubstring("Option<bool>"))
})
It("should generate base64 serde for Vec<u8> fields", func() {
It("should generate raw extern C import and binary frame parsing for raw methods", func() {
svc := Service{
Name: "Codec",
Permission: "codec",
Interface: "CodecService",
Name: "Stream",
Permission: "stream",
Interface: "StreamService",
Methods: []Method{
{
Name: "Encode",
HasError: true,
Params: []Param{NewParam("data", "[]byte")},
Returns: []Param{NewParam("result", "[]byte")},
},
},
}
code, err := GenerateClientRust(svc)
Expect(err).NotTo(HaveOccurred())
codeStr := string(code)
// Should generate base64_bytes serde module
Expect(codeStr).To(ContainSubstring("mod base64_bytes"))
Expect(codeStr).To(ContainSubstring("use base64::Engine as _"))
// Should add serde(with = "base64_bytes") on Vec<u8> fields
Expect(codeStr).To(ContainSubstring(`#[serde(with = "base64_bytes")]`))
})
It("should not generate base64 module when no byte fields", func() {
svc := Service{
Name: "Test",
Permission: "test",
Interface: "TestService",
Methods: []Method{
{
Name: "Call",
Name: "GetStream",
HasError: true,
Raw: true,
Params: []Param{NewParam("uri", "string")},
Returns: []Param{NewParam("response", "string")},
Returns: []Param{
NewParam("contentType", "string"),
NewParam("data", "[]byte"),
},
Doc: "GetStream returns raw binary stream data.",
},
},
}
@@ -1653,8 +1773,24 @@ var _ = Describe("Rust Generation", func() {
codeStr := string(code)
Expect(codeStr).NotTo(ContainSubstring("mod base64_bytes"))
Expect(codeStr).NotTo(ContainSubstring("use base64"))
// 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"))
})
})
})

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
}

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

View File

@@ -1,25 +0,0 @@
{{define "base64_bytes_module"}}
use base64::Engine as _;
use base64::engine::general_purpose::STANDARD as BASE64;
mod base64_bytes {
use serde::{self, Deserialize, Deserializer, Serializer};
use base64::Engine as _;
use base64::engine::general_purpose::STANDARD as BASE64;
pub fn serialize<S>(bytes: &Vec<u8>, serializer: S) -> Result<S::Ok, S::Error>
where
S: Serializer,
{
serializer.serialize_str(&BASE64.encode(bytes))
}
pub fn deserialize<'de, D>(deserializer: D) -> Result<Vec<u8>, D::Error>
where
D: Deserializer<'de>,
{
let s = String::deserialize(deserializer)?;
BASE64.decode(&s).map_err(serde::de::Error::custom)
}
}
{{- end}}

View File

@@ -7,7 +7,6 @@ use serde::{Deserialize, Serialize};
{{- if hasHashMap .Capability}}
use std::collections::HashMap;
{{- end}}
{{- if .Capability.HasByteFields}}{{template "base64_bytes_module" .}}{{- end}}
// Helper functions for skip_serializing_if with numeric types
#[allow(dead_code)]
@@ -71,9 +70,6 @@ pub struct {{.Name}} {
#[serde(default, skip_serializing_if = "{{skipSerializingFunc .Type}}")]
{{- else}}
#[serde(default)]
{{- end}}
{{- if .IsByteSlice}}
#[serde(with = "base64_bytes")]
{{- end}}
pub {{rustFieldName .Name}}: {{fieldRustType .}},
{{- end}}

View File

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

View File

@@ -8,12 +8,12 @@
# 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.HasByteFields}}
import base64
{{- if .Service.HasRawMethods}}
import struct
{{- end}}
@@ -32,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
@@ -47,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}}
@@ -56,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}}
@@ -72,11 +76,7 @@ def {{pythonFunc .}}({{range $i, $p := .Params}}{{if $i}}, {{end}}{{$p.PythonNam
{{- if .HasParams}}
request = {
{{- range .Params}}
{{- if .IsByteSlice}}
"{{.JSONName}}": base64.b64encode({{.PythonName}}).decode("ascii"),
{{- else}}
"{{.JSONName}}": {{.PythonName}},
{{- end}}
{{- end}}
}
request_bytes = json.dumps(request).encode("utf-8")
@@ -86,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,17 +112,10 @@ def {{pythonFunc .}}({{range $i, $p := .Params}}{{if $i}}, {{end}}{{$p.PythonNam
{{- if .NeedsResultClass}}
return {{pythonResultType .}}(
{{- range .Returns}}
{{- if .IsByteSlice}}
{{.PythonName}}=base64.b64decode(response.get("{{.JSONName}}", "")),
{{- else}}
{{.PythonName}}=response.get("{{.JSONName}}"{{pythonDefault .}}),
{{- end}}
{{- end}}
)
{{- else if .HasReturns}}
{{- if (index .Returns 0).IsByteSlice}}
return base64.b64decode(response.get("{{(index .Returns 0).JSONName}}", ""))
{{- else}}
return response.get("{{(index .Returns 0).JSONName}}"{{pythonDefault (index .Returns 0)}})
{{- end}}
{{- end}}

View File

@@ -5,7 +5,6 @@
use extism_pdk::*;
use serde::{Deserialize, Serialize};
{{- if .Service.HasByteFields}}{{template "base64_bytes_module" .}}{{- end}}
{{- /* Generate struct definitions */ -}}
{{- range .Service.Structs}}
{{if .Doc}}
@@ -17,9 +16,6 @@ pub struct {{.Name}} {
{{- range .Fields}}
{{- if .NeedsDefault}}
#[serde(default)]
{{- end}}
{{- if .IsByteSlice}}
#[serde(with = "base64_bytes")]
{{- end}}
pub {{.RustName}}: {{fieldRustType .}},
{{- end}}
@@ -33,22 +29,17 @@ pub struct {{.Name}} {
#[serde(rename_all = "camelCase")]
struct {{requestType .}} {
{{- range .Params}}
{{- if .IsByteSlice}}
#[serde(with = "base64_bytes")]
{{- end}}
{{.RustName}}: {{rustType .}},
{{- end}}
}
{{- end}}
{{- if not .Raw}}
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
struct {{responseType .}} {
{{- range .Returns}}
#[serde(default)]
{{- if .IsByteSlice}}
#[serde(with = "base64_bytes")]
{{- end}}
{{.RustName}}: {{rustType .}},
{{- end}}
{{- if .HasError}}
@@ -57,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}}
@@ -142,3 +209,4 @@ pub fn {{rustFunc .}}({{range $i, $p := .Params}}{{if $i}}, {{end}}{{$p.RustName
}
{{- end}}
{{- end}}
{{- end}}

View File

@@ -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 {
@@ -127,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}}

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.
@@ -332,52 +343,6 @@ type Param struct {
JSONName string // JSON field name (camelCase)
}
// IsByteSlice returns true if the parameter type is []byte.
func (p Param) IsByteSlice() bool {
return p.Type == "[]byte"
}
// IsByteSlice returns true if the field type is []byte.
func (f FieldDef) IsByteSlice() bool {
return f.Type == "[]byte"
}
// HasByteFields returns true if any method params, returns, or struct fields use []byte.
func (s Service) HasByteFields() bool {
for _, m := range s.Methods {
for _, p := range m.Params {
if p.IsByteSlice() {
return true
}
}
for _, r := range m.Returns {
if r.IsByteSlice() {
return true
}
}
}
for _, st := range s.Structs {
for _, f := range st.Fields {
if f.IsByteSlice() {
return true
}
}
}
return false
}
// HasByteFields returns true if any capability struct fields use []byte.
func (c Capability) HasByteFields() bool {
for _, st := range c.Structs {
for _, f := range st.Fields {
if f.IsByteSlice() {
return true
}
}
}
return false
}
// NewParam creates a Param with auto-generated JSON name.
func NewParam(name, typ string) Param {
return Param{

View File

@@ -12,7 +12,6 @@ from typing import Any
import extism
import json
import base64
class HostFunctionError(Exception):
@@ -39,7 +38,7 @@ def codec_encode(data: bytes) -> bytes:
HostFunctionError: If the host function returns an error.
"""
request = {
"data": base64.b64encode(data).decode("ascii"),
"data": data,
}
request_bytes = json.dumps(request).encode("utf-8")
request_mem = extism.memory.alloc(request_bytes)
@@ -50,4 +49,4 @@ def codec_encode(data: bytes) -> bytes:
if response.get("error"):
raise HostFunctionError(response["error"])
return base64.b64decode(response.get("result", ""))
return response.get("result", b"")

View File

@@ -5,34 +5,10 @@
use extism_pdk::*;
use serde::{Deserialize, Serialize};
use base64::Engine as _;
use base64::engine::general_purpose::STANDARD as BASE64;
mod base64_bytes {
use serde::{self, Deserialize, Deserializer, Serializer};
use base64::Engine as _;
use base64::engine::general_purpose::STANDARD as BASE64;
pub fn serialize<S>(bytes: &Vec<u8>, serializer: S) -> Result<S::Ok, S::Error>
where
S: Serializer,
{
serializer.serialize_str(&BASE64.encode(bytes))
}
pub fn deserialize<'de, D>(deserializer: D) -> Result<Vec<u8>, D::Error>
where
D: Deserializer<'de>,
{
let s = String::deserialize(deserializer)?;
BASE64.decode(&s).map_err(serde::de::Error::custom)
}
}
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
struct CodecEncodeRequest {
#[serde(with = "base64_bytes")]
data: Vec<u8>,
}
@@ -40,7 +16,6 @@ struct CodecEncodeRequest {
#[serde(rename_all = "camelCase")]
struct CodecEncodeResponse {
#[serde(default)]
#[serde(with = "base64_bytes")]
result: Vec<u8>,
#[serde(default)]
error: Option<String>,

View File

@@ -12,7 +12,6 @@ from typing import Any
import extism
import json
import base64
class HostFunctionError(Exception):
@@ -328,7 +327,7 @@ def comprehensive_byte_slice(data: bytes) -> bytes:
HostFunctionError: If the host function returns an error.
"""
request = {
"data": base64.b64encode(data).decode("ascii"),
"data": data,
}
request_bytes = json.dumps(request).encode("utf-8")
request_mem = extism.memory.alloc(request_bytes)
@@ -339,4 +338,4 @@ def comprehensive_byte_slice(data: bytes) -> bytes:
if response.get("error"):
raise HostFunctionError(response["error"])
return base64.b64decode(response.get("result", ""))
return response.get("result", b"")

View File

@@ -5,29 +5,6 @@
use extism_pdk::*;
use serde::{Deserialize, Serialize};
use base64::Engine as _;
use base64::engine::general_purpose::STANDARD as BASE64;
mod base64_bytes {
use serde::{self, Deserialize, Deserializer, Serializer};
use base64::Engine as _;
use base64::engine::general_purpose::STANDARD as BASE64;
pub fn serialize<S>(bytes: &Vec<u8>, serializer: S) -> Result<S::Ok, S::Error>
where
S: Serializer,
{
serializer.serialize_str(&BASE64.encode(bytes))
}
pub fn deserialize<'de, D>(deserializer: D) -> Result<Vec<u8>, D::Error>
where
D: Deserializer<'de>,
{
let s = String::deserialize(deserializer)?;
BASE64.decode(&s).map_err(serde::de::Error::custom)
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
@@ -167,7 +144,6 @@ struct ComprehensiveMultipleReturnsResponse {
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
struct ComprehensiveByteSliceRequest {
#[serde(with = "base64_bytes")]
data: Vec<u8>,
}
@@ -175,7 +151,6 @@ struct ComprehensiveByteSliceRequest {
#[serde(rename_all = "camelCase")]
struct ComprehensiveByteSliceResponse {
#[serde(default)]
#[serde(with = "base64_bytes")]
result: Vec<u8>,
#[serde(default)]
error: Option<String>,

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
}

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

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

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

View File

@@ -17,8 +17,8 @@ type SubsonicAPIService interface {
Call(ctx context.Context, uri string) (responseJSON string, err error)
// CallRaw executes a Subsonic API request and returns the raw binary response.
// Designed for binary endpoints like getCoverArt and stream that return
// non-JSON data. The data is base64-encoded over JSON on the wire.
//nd:hostfunc
// 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)
}

View File

@@ -4,6 +4,7 @@ package host
import (
"context"
"encoding/binary"
"encoding/json"
extism "github.com/extism/go-sdk"
@@ -25,13 +26,6 @@ type SubsonicAPICallRawRequest struct {
Uri string `json:"uri"`
}
// SubsonicAPICallRawResponse is the response type for SubsonicAPI.CallRaw.
type SubsonicAPICallRawResponse struct {
ContentType string `json:"contentType,omitempty"`
Data []byte `json:"data,omitempty"`
Error string `json:"error,omitempty"`
}
// RegisterSubsonicAPIHostFunctions registers SubsonicAPI service host functions.
// The returned host functions should be added to the plugin's configuration.
func RegisterSubsonicAPIHostFunctions(service SubsonicAPIService) []extism.HostFunction {
@@ -82,28 +76,37 @@ func newSubsonicAPICallRawHostFunction(service SubsonicAPIService) extism.HostFu
// Read JSON request from plugin memory
reqBytes, err := p.ReadBytes(stack[0])
if err != nil {
subsonicapiWriteError(p, stack, err)
subsonicapiWriteRawError(p, stack, err)
return
}
var req SubsonicAPICallRawRequest
if err := json.Unmarshal(reqBytes, &req); err != nil {
subsonicapiWriteError(p, stack, err)
subsonicapiWriteRawError(p, stack, err)
return
}
// Call the service method
contenttype, data, svcErr := service.CallRaw(ctx, req.Uri)
if svcErr != nil {
subsonicapiWriteError(p, stack, svcErr)
subsonicapiWriteRawError(p, stack, svcErr)
return
}
// Write JSON response to plugin memory
resp := SubsonicAPICallRawResponse{
ContentType: contenttype,
Data: data,
// 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
}
subsonicapiWriteResponse(p, stack, resp)
stack[0] = respPtr
},
[]extism.ValueType{extism.ValueTypePTR},
[]extism.ValueType{extism.ValueTypePTR},
@@ -134,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
}

View File

@@ -428,11 +428,10 @@ func (m *Manager) UpdatePluginUsers(ctx context.Context, id, usersJSON string, a
// If the plugin is enabled, it will be reloaded with the new settings.
// If the plugin requires library permission and no libraries are configured (and allLibraries is false),
// the plugin will be automatically disabled.
func (m *Manager) UpdatePluginLibraries(ctx context.Context, id, librariesJSON string, allLibraries, allowWriteAccess bool) error {
func (m *Manager) UpdatePluginLibraries(ctx context.Context, id, librariesJSON string, allLibraries bool) error {
return m.updatePluginSettings(ctx, id, func(p *model.Plugin) {
p.Libraries = librariesJSON
p.AllLibraries = allLibraries
p.AllowWriteAccess = allowWriteAccess
})
}

View File

@@ -226,8 +226,6 @@ func (m *Manager) loadEnabledPlugins(ctx context.Context) error {
// loadPluginWithConfig loads a plugin with configuration from DB.
// The p.Path should point to an .ndp package file.
func (m *Manager) loadPluginWithConfig(p *model.Plugin) error {
ctx := log.NewContext(m.ctx, "plugin", p.ID)
if m.stopped.Load() {
return fmt.Errorf("manager is stopped")
}
@@ -285,13 +283,27 @@ func (m *Manager) loadPluginWithConfig(p *model.Plugin) error {
// Configure filesystem access for library permission
if pkg.Manifest.Permissions != nil && pkg.Manifest.Permissions.Library != nil && pkg.Manifest.Permissions.Library.Filesystem {
adminCtx := adminContext(ctx)
adminCtx := adminContext(m.ctx)
libraries, err := m.ds.Library(adminCtx).GetAll()
if err != nil {
return fmt.Errorf("failed to get libraries for filesystem access: %w", err)
}
allowedPaths := buildAllowedPaths(ctx, libraries, allowedLibraries, p.AllLibraries, p.AllowWriteAccess)
// Build a set of allowed library IDs for fast lookup
allowedLibrarySet := make(map[int]struct{}, len(allowedLibraries))
for _, id := range allowedLibraries {
allowedLibrarySet[id] = struct{}{}
}
allowedPaths := make(map[string]string)
for _, lib := range libraries {
// Only mount if allLibraries is true or library is in the allowed list
if p.AllLibraries {
allowedPaths[lib.Path] = toPluginMountPoint(int32(lib.ID))
} else if _, ok := allowedLibrarySet[lib.ID]; ok {
allowedPaths[lib.Path] = toPluginMountPoint(int32(lib.ID))
}
}
pluginManifest.AllowedPaths = allowedPaths
}
@@ -327,7 +339,7 @@ func (m *Manager) loadPluginWithConfig(p *model.Plugin) error {
// Enable experimental threads if requested in manifest
if pkg.Manifest.HasExperimentalThreads() {
runtimeConfig = runtimeConfig.WithCoreFeatures(api.CoreFeaturesV2 | experimental.CoreFeaturesThreads)
log.Debug(ctx, "Enabling experimental threads support")
log.Debug(m.ctx, "Enabling experimental threads support", "plugin", p.ID)
}
extismConfig := extism.PluginConfig{
@@ -335,24 +347,24 @@ func (m *Manager) loadPluginWithConfig(p *model.Plugin) error {
RuntimeConfig: runtimeConfig,
EnableHttpResponseHeaders: true,
}
compiled, err := extism.NewCompiledPlugin(ctx, pluginManifest, extismConfig, hostFunctions)
compiled, err := extism.NewCompiledPlugin(m.ctx, pluginManifest, extismConfig, hostFunctions)
if err != nil {
return fmt.Errorf("compiling plugin: %w", err)
}
// Create instance to detect capabilities
instance, err := compiled.Instance(ctx, extism.PluginInstanceConfig{})
instance, err := compiled.Instance(m.ctx, extism.PluginInstanceConfig{})
if err != nil {
compiled.Close(ctx)
compiled.Close(m.ctx)
return fmt.Errorf("creating instance: %w", err)
}
instance.SetLogger(extismLogger(p.ID))
capabilities := detectCapabilities(instance)
instance.Close(ctx)
instance.Close(m.ctx)
// Validate manifest against detected capabilities
if err := ValidateWithCapabilities(pkg.Manifest, capabilities); err != nil {
compiled.Close(ctx)
compiled.Close(m.ctx)
return fmt.Errorf("manifest validation: %w", err)
}
@@ -371,7 +383,7 @@ func (m *Manager) loadPluginWithConfig(p *model.Plugin) error {
m.mu.Unlock()
// Call plugin init function
callPluginInit(ctx, m.plugins[p.ID])
callPluginInit(m.ctx, m.plugins[p.ID])
return nil
}
@@ -402,29 +414,3 @@ func parsePluginConfig(configJSON string) (map[string]string, error) {
}
return pluginConfig, nil
}
// buildAllowedPaths constructs the extism AllowedPaths map for filesystem access.
// When allowWriteAccess is false (default), paths are prefixed with "ro:" for read-only.
// Only libraries that match the allowed set (or all libraries if allLibraries is true) are included.
func buildAllowedPaths(ctx context.Context, libraries model.Libraries, allowedLibraryIDs []int, allLibraries, allowWriteAccess bool) map[string]string {
allowedLibrarySet := make(map[int]struct{}, len(allowedLibraryIDs))
for _, id := range allowedLibraryIDs {
allowedLibrarySet[id] = struct{}{}
}
allowedPaths := make(map[string]string)
for _, lib := range libraries {
_, allowed := allowedLibrarySet[lib.ID]
if allLibraries || allowed {
mountPoint := toPluginMountPoint(int32(lib.ID))
if allowWriteAccess {
log.Info(ctx, "Granting read-write filesystem access to library", "libraryID", lib.ID, "mountPoint", mountPoint)
allowedPaths[lib.Path] = mountPoint
} else {
log.Debug(ctx, "Granting read-only filesystem access to library", "libraryID", lib.ID, "mountPoint", mountPoint)
allowedPaths["ro:"+lib.Path] = mountPoint
}
}
}
return allowedPaths
}

View File

@@ -3,7 +3,6 @@
package plugins
import (
"github.com/navidrome/navidrome/model"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
@@ -59,66 +58,3 @@ var _ = Describe("parsePluginConfig", func() {
Expect(result).ToNot(BeNil())
})
})
var _ = Describe("buildAllowedPaths", func() {
var libraries model.Libraries
BeforeEach(func() {
libraries = model.Libraries{
{ID: 1, Path: "/music/library1"},
{ID: 2, Path: "/music/library2"},
{ID: 3, Path: "/music/library3"},
}
})
Context("read-only (default)", func() {
It("mounts all libraries with ro: prefix when allLibraries is true", func() {
result := buildAllowedPaths(nil, libraries, nil, true, false)
Expect(result).To(HaveLen(3))
Expect(result).To(HaveKeyWithValue("ro:/music/library1", "/libraries/1"))
Expect(result).To(HaveKeyWithValue("ro:/music/library2", "/libraries/2"))
Expect(result).To(HaveKeyWithValue("ro:/music/library3", "/libraries/3"))
})
It("mounts only selected libraries with ro: prefix", func() {
result := buildAllowedPaths(nil, libraries, []int{1, 3}, false, false)
Expect(result).To(HaveLen(2))
Expect(result).To(HaveKeyWithValue("ro:/music/library1", "/libraries/1"))
Expect(result).To(HaveKeyWithValue("ro:/music/library3", "/libraries/3"))
Expect(result).ToNot(HaveKey("ro:/music/library2"))
})
})
Context("read-write (allowWriteAccess=true)", func() {
It("mounts all libraries without ro: prefix when allLibraries is true", func() {
result := buildAllowedPaths(nil, libraries, nil, true, true)
Expect(result).To(HaveLen(3))
Expect(result).To(HaveKeyWithValue("/music/library1", "/libraries/1"))
Expect(result).To(HaveKeyWithValue("/music/library2", "/libraries/2"))
Expect(result).To(HaveKeyWithValue("/music/library3", "/libraries/3"))
})
It("mounts only selected libraries without ro: prefix", func() {
result := buildAllowedPaths(nil, libraries, []int{2}, false, true)
Expect(result).To(HaveLen(1))
Expect(result).To(HaveKeyWithValue("/music/library2", "/libraries/2"))
})
})
Context("edge cases", func() {
It("returns empty map when no libraries match", func() {
result := buildAllowedPaths(nil, libraries, []int{99}, false, false)
Expect(result).To(BeEmpty())
})
It("returns empty map when libraries list is empty", func() {
result := buildAllowedPaths(nil, nil, []int{1}, false, false)
Expect(result).To(BeEmpty())
})
It("returns empty map when allLibraries is false and no IDs provided", func() {
result := buildAllowedPaths(nil, libraries, nil, false, false)
Expect(result).To(BeEmpty())
})
})
})

View File

@@ -8,6 +8,7 @@
package host
import (
"encoding/binary"
"encoding/json"
"errors"
@@ -37,12 +38,6 @@ type subsonicAPICallRawRequest struct {
Uri string `json:"uri"`
}
type subsonicAPICallRawResponse struct {
ContentType string `json:"contentType,omitempty"`
Data []byte `json:"data,omitempty"`
Error string `json:"error,omitempty"`
}
// SubsonicAPICall calls the subsonicapi_call host function.
// Call executes a Subsonic API request and returns the JSON response.
//
@@ -83,8 +78,8 @@ func SubsonicAPICall(uri string) (string, error) {
// SubsonicAPICallRaw calls the subsonicapi_callraw host function.
// CallRaw executes a Subsonic API request and returns the raw binary response.
// Designed for binary endpoints like getCoverArt and stream that return
// non-JSON data. The data is base64-encoded over JSON on the wire.
// 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{
@@ -104,16 +99,22 @@ func SubsonicAPICallRaw(uri string) (string, []byte, error) {
responseMem := pdk.FindMemory(responsePtr)
responseBytes := responseMem.ReadBytes()
// Parse the response
var response subsonicAPICallRawResponse
if err := json.Unmarshal(responseBytes, &response); err != nil {
return "", nil, err
// Parse binary-framed response
if len(responseBytes) == 0 {
return "", nil, errors.New("empty response from host")
}
// Convert Error field to Go error
if response.Error != "" {
return "", nil, errors.New(response.Error)
if responseBytes[0] == 0x01 { // error
return "", nil, errors.New(string(responseBytes[1:]))
}
return response.ContentType, response.Data, nil
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
}

View File

@@ -42,8 +42,8 @@ func (m *mockSubsonicAPIService) CallRaw(uri string) (string, []byte, error) {
// SubsonicAPICallRaw delegates to the mock instance.
// CallRaw executes a Subsonic API request and returns the raw binary response.
// Designed for binary endpoints like getCoverArt and stream that return
// non-JSON data. The data is base64-encoded over JSON on the wire.
// 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)
}

View File

@@ -12,7 +12,6 @@ from typing import Any
import extism
import json
import base64
class HostFunctionError(Exception):
@@ -338,7 +337,7 @@ Returns an error if the operation fails.
"""
request = {
"key": key,
"value": base64.b64encode(value).decode("ascii"),
"value": value,
"ttlSeconds": ttl_seconds,
}
request_bytes = json.dumps(request).encode("utf-8")
@@ -383,7 +382,7 @@ or the stored value is not a byte slice, exists will be false.
raise HostFunctionError(response["error"])
return CacheGetBytesResult(
value=base64.b64decode(response.get("value", "")),
value=response.get("value", b""),
exists=response.get("exists", False),
)

View File

@@ -12,7 +12,6 @@ from typing import Any
import extism
import json
import base64
class HostFunctionError(Exception):

View File

@@ -12,7 +12,6 @@ from typing import Any
import extism
import json
import base64
class HostFunctionError(Exception):
@@ -81,7 +80,7 @@ Returns an error if the storage limit would be exceeded or the operation fails.
"""
request = {
"key": key,
"value": base64.b64encode(value).decode("ascii"),
"value": value,
}
request_bytes = json.dumps(request).encode("utf-8")
request_mem = extism.memory.alloc(request_bytes)
@@ -124,7 +123,7 @@ Returns the value and whether the key exists.
raise HostFunctionError(response["error"])
return KVStoreGetResult(
value=base64.b64decode(response.get("value", "")),
value=response.get("value", b""),
exists=response.get("exists", False),
)

View File

@@ -8,11 +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 base64
import struct
class HostFunctionError(Exception):
@@ -32,13 +32,6 @@ def _subsonicapi_callraw(offset: int) -> int:
...
@dataclass
class SubsonicAPICallRawResult:
"""Result type for subsonicapi_call_raw."""
content_type: str
data: bytes
def subsonicapi_call(uri: str) -> str:
"""Call executes a Subsonic API request and returns the JSON response.
@@ -69,16 +62,16 @@ e.g., "getAlbumList2?type=random&size=10". The response is returned as raw JSON.
return response.get("responseJson", "")
def subsonicapi_call_raw(uri: str) -> SubsonicAPICallRawResult:
def subsonicapi_call_raw(uri: str) -> Tuple[str, bytes]:
"""CallRaw executes a Subsonic API request and returns the raw binary response.
Designed for binary endpoints like getCoverArt and stream that return
non-JSON data. The data is base64-encoded over JSON on the wire.
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:
SubsonicAPICallRawResult containing content_type, data,.
Tuple of (content_type, data) with the raw binary response.
Raises:
HostFunctionError: If the host function returns an error.
@@ -90,12 +83,19 @@ non-JSON data. The data is base64-encoded over JSON on the wire.
request_mem = extism.memory.alloc(request_bytes)
response_offset = _subsonicapi_callraw(request_mem.offset)
response_mem = extism.memory.find(response_offset)
response = json.loads(extism.memory.string(response_mem))
response_bytes = response_mem.bytes()
if response.get("error"):
raise HostFunctionError(response["error"])
return SubsonicAPICallRawResult(
content_type=response.get("contentType", ""),
data=base64.b64decode(response.get("data", "")),
)
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

View File

@@ -12,7 +12,6 @@ from typing import Any
import extism
import json
import base64
class HostFunctionError(Exception):
@@ -135,7 +134,7 @@ Returns an error if the connection is not found or if sending fails.
"""
request = {
"connectionId": connection_id,
"data": base64.b64encode(data).decode("ascii"),
"data": data,
}
request_bytes = json.dumps(request).encode("utf-8")
request_mem = extism.memory.alloc(request_bytes)

View File

@@ -11,7 +11,6 @@ path = "src/lib.rs"
crate-type = ["rlib"]
[dependencies]
base64 = "0.22"
extism-pdk = "1.2"
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"

View File

@@ -11,7 +11,6 @@ readme = "README.md"
crate-type = ["rlib"]
[dependencies]
base64 = "0.22"
extism-pdk = "1.2"
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"

View File

@@ -5,29 +5,6 @@
use extism_pdk::*;
use serde::{Deserialize, Serialize};
use base64::Engine as _;
use base64::engine::general_purpose::STANDARD as BASE64;
mod base64_bytes {
use serde::{self, Deserialize, Deserializer, Serializer};
use base64::Engine as _;
use base64::engine::general_purpose::STANDARD as BASE64;
pub fn serialize<S>(bytes: &Vec<u8>, serializer: S) -> Result<S::Ok, S::Error>
where
S: Serializer,
{
serializer.serialize_str(&BASE64.encode(bytes))
}
pub fn deserialize<'de, D>(deserializer: D) -> Result<Vec<u8>, D::Error>
where
D: Deserializer<'de>,
{
let s = String::deserialize(deserializer)?;
BASE64.decode(&s).map_err(serde::de::Error::custom)
}
}
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
@@ -129,7 +106,6 @@ struct CacheGetFloatResponse {
#[serde(rename_all = "camelCase")]
struct CacheSetBytesRequest {
key: String,
#[serde(with = "base64_bytes")]
value: Vec<u8>,
ttl_seconds: i64,
}
@@ -151,7 +127,6 @@ struct CacheGetBytesRequest {
#[serde(rename_all = "camelCase")]
struct CacheGetBytesResponse {
#[serde(default)]
#[serde(with = "base64_bytes")]
value: Vec<u8>,
#[serde(default)]
exists: bool,

View File

@@ -5,29 +5,6 @@
use extism_pdk::*;
use serde::{Deserialize, Serialize};
use base64::Engine as _;
use base64::engine::general_purpose::STANDARD as BASE64;
mod base64_bytes {
use serde::{self, Deserialize, Deserializer, Serializer};
use base64::Engine as _;
use base64::engine::general_purpose::STANDARD as BASE64;
pub fn serialize<S>(bytes: &Vec<u8>, serializer: S) -> Result<S::Ok, S::Error>
where
S: Serializer,
{
serializer.serialize_str(&BASE64.encode(bytes))
}
pub fn deserialize<'de, D>(deserializer: D) -> Result<Vec<u8>, D::Error>
where
D: Deserializer<'de>,
{
let s = String::deserialize(deserializer)?;
BASE64.decode(&s).map_err(serde::de::Error::custom)
}
}
/// HTTPRequest represents an outbound HTTP request from a plugin.
#[derive(Debug, Clone, Serialize, Deserialize)]
@@ -38,7 +15,6 @@ pub struct HTTPRequest {
#[serde(default)]
pub headers: std::collections::HashMap<String, String>,
#[serde(default)]
#[serde(with = "base64_bytes")]
pub body: Vec<u8>,
#[serde(default)]
pub timeout_ms: i32,
@@ -52,7 +28,6 @@ pub struct HTTPResponse {
#[serde(default)]
pub headers: std::collections::HashMap<String, String>,
#[serde(default)]
#[serde(with = "base64_bytes")]
pub body: Vec<u8>,
}

View File

@@ -5,35 +5,11 @@
use extism_pdk::*;
use serde::{Deserialize, Serialize};
use base64::Engine as _;
use base64::engine::general_purpose::STANDARD as BASE64;
mod base64_bytes {
use serde::{self, Deserialize, Deserializer, Serializer};
use base64::Engine as _;
use base64::engine::general_purpose::STANDARD as BASE64;
pub fn serialize<S>(bytes: &Vec<u8>, serializer: S) -> Result<S::Ok, S::Error>
where
S: Serializer,
{
serializer.serialize_str(&BASE64.encode(bytes))
}
pub fn deserialize<'de, D>(deserializer: D) -> Result<Vec<u8>, D::Error>
where
D: Deserializer<'de>,
{
let s = String::deserialize(deserializer)?;
BASE64.decode(&s).map_err(serde::de::Error::custom)
}
}
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
struct KVStoreSetRequest {
key: String,
#[serde(with = "base64_bytes")]
value: Vec<u8>,
}
@@ -54,7 +30,6 @@ struct KVStoreGetRequest {
#[serde(rename_all = "camelCase")]
struct KVStoreGetResponse {
#[serde(default)]
#[serde(with = "base64_bytes")]
value: Vec<u8>,
#[serde(default)]
exists: bool,

View File

@@ -5,29 +5,6 @@
use extism_pdk::*;
use serde::{Deserialize, Serialize};
use base64::Engine as _;
use base64::engine::general_purpose::STANDARD as BASE64;
mod base64_bytes {
use serde::{self, Deserialize, Deserializer, Serializer};
use base64::Engine as _;
use base64::engine::general_purpose::STANDARD as BASE64;
pub fn serialize<S>(bytes: &Vec<u8>, serializer: S) -> Result<S::Ok, S::Error>
where
S: Serializer,
{
serializer.serialize_str(&BASE64.encode(bytes))
}
pub fn deserialize<'de, D>(deserializer: D) -> Result<Vec<u8>, D::Error>
where
D: Deserializer<'de>,
{
let s = String::deserialize(deserializer)?;
BASE64.decode(&s).map_err(serde::de::Error::custom)
}
}
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
@@ -50,22 +27,14 @@ struct SubsonicAPICallRawRequest {
uri: String,
}
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
struct SubsonicAPICallRawResponse {
#[serde(default)]
content_type: String,
#[serde(default)]
#[serde(with = "base64_bytes")]
data: Vec<u8>,
#[serde(default)]
error: Option<String>,
}
#[host_fn]
extern "ExtismHost" {
fn subsonicapi_call(input: Json<SubsonicAPICallRequest>) -> Json<SubsonicAPICallResponse>;
fn subsonicapi_callraw(input: Json<SubsonicAPICallRawRequest>) -> Json<SubsonicAPICallRawResponse>;
}
#[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.
@@ -96,27 +65,54 @@ pub fn call(uri: &str) -> Result<String, Error> {
}
/// CallRaw executes a Subsonic API request and returns the raw binary response.
/// Designed for binary endpoints like getCoverArt and stream that return
/// non-JSON data. The data is base64-encoded over JSON on the wire.
/// 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).
/// 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 response = unsafe {
subsonicapi_callraw(Json(SubsonicAPICallRawRequest {
uri: uri.to_owned(),
}))?
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()))?;
if let Some(err) = response.0.error {
return Err(Error::msg(err));
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"));
}
Ok((response.0.content_type, response.0.data))
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))
}

View File

@@ -5,29 +5,6 @@
use extism_pdk::*;
use serde::{Deserialize, Serialize};
use base64::Engine as _;
use base64::engine::general_purpose::STANDARD as BASE64;
mod base64_bytes {
use serde::{self, Deserialize, Deserializer, Serializer};
use base64::Engine as _;
use base64::engine::general_purpose::STANDARD as BASE64;
pub fn serialize<S>(bytes: &Vec<u8>, serializer: S) -> Result<S::Ok, S::Error>
where
S: Serializer,
{
serializer.serialize_str(&BASE64.encode(bytes))
}
pub fn deserialize<'de, D>(deserializer: D) -> Result<Vec<u8>, D::Error>
where
D: Deserializer<'de>,
{
let s = String::deserialize(deserializer)?;
BASE64.decode(&s).map_err(serde::de::Error::custom)
}
}
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
@@ -64,7 +41,6 @@ struct WebSocketSendTextResponse {
#[serde(rename_all = "camelCase")]
struct WebSocketSendBinaryRequest {
connection_id: String,
#[serde(with = "base64_bytes")]
data: Vec<u8>,
}

View File

@@ -31,13 +31,13 @@
"mood": "Настроение",
"participants": "Допълнителни участници",
"tags": "Допълнителни етикети",
"mappedTags": "",
"rawTags": "",
"mappedTags": "Картирани тагове",
"rawTags": "Сурови тагове",
"bitDepth": "Битова дълбочина",
"sampleRate": "",
"sampleRate": "Честота на семплиране",
"missing": "Липсва",
"libraryName": "",
"composer": ""
"libraryName": "Библиотека",
"composer": "Композитор"
},
"actions": {
"addToQueue": "Пусни по-късно",
@@ -47,8 +47,8 @@
"download": "Свали",
"playNext": "Следваща",
"info": "Информация",
"showInPlaylist": "",
"instantMix": ""
"showInPlaylist": "Показване в плейлиста",
"instantMix": "Незабавен микс"
}
},
"album": {
@@ -80,7 +80,7 @@
"mood": "Настроение",
"date": "Дата на запис",
"missing": "Липсва",
"libraryName": ""
"libraryName": "Библиотека"
},
"actions": {
"playAll": "Пусни",
@@ -129,12 +129,12 @@
"remixer": "Ремиксер |||| Ремиксери",
"djmixer": "DJ миксер |||| DJ миксери",
"performer": "Изпълнител |||| Изпълнители",
"maincredit": ""
"maincredit": "Изпълнител на албума или изпълнител |||| Изпълнители на албума или изпълнители"
},
"actions": {
"shuffle": "",
"radio": "",
"topSongs": ""
"shuffle": "Разбъркване",
"radio": "Радио",
"topSongs": "Топ песни"
}
},
"user": {
@@ -152,11 +152,11 @@
"newPassword": "Нова парола",
"token": "Токен",
"lastAccessAt": "Последен достъп",
"libraries": ""
"libraries": "Библиотеки"
},
"helperTexts": {
"name": "Промените в името ще бъдат отразени при следващото влизане",
"libraries": ""
"libraries": "Изберете конкретни библиотеки за този потребител или оставете празно, за да използвате библиотеки по подразбиране"
},
"notifications": {
"created": "Потребителят е създаден",
@@ -166,11 +166,11 @@
"message": {
"listenBrainzToken": "Въведете Вашия токен за ListenBrainz.",
"clickHereForToken": "Кликнете тук, за да получите Вашия токен",
"selectAllLibraries": "",
"adminAutoLibraries": ""
"selectAllLibraries": "Изберете всички библиотеки",
"adminAutoLibraries": "Администраторите автоматично получават достъп до всички библиотеки"
},
"validation": {
"librariesRequired": ""
"librariesRequired": "Трябва да бъде избрана поне една библиотека за потребители без администраторски права"
}
},
"player": {
@@ -215,16 +215,16 @@
"export": "Експорт",
"makePublic": "Направи публичен",
"makePrivate": "Направи личен",
"saveQueue": "",
"searchOrCreate": "",
"pressEnterToCreate": "",
"removeFromSelection": ""
"saveQueue": "Запазване на опашката в плейлист",
"searchOrCreate": "Търсете в плейлисти или пишете, за да създадете нови...",
"pressEnterToCreate": "Натиснете Enter, за да създадете нов плейлист",
"removeFromSelection": "Премахване от селекцията"
},
"message": {
"duplicate_song": "Добави дублирани песни",
"song_exist": "Към плейлиста се добавят дублиращи. Желаете ли да ги добавите или предпочитате да ги пропуснете?",
"noPlaylistsFound": "",
"noPlaylists": ""
"noPlaylistsFound": "Няма намерени плейлисти",
"noPlaylists": "Няма налични плейлисти"
}
},
"radio": {
@@ -263,7 +263,7 @@
"path": "Път",
"size": "Размер",
"updatedAt": "Изчезнал на",
"libraryName": ""
"libraryName": "Библиотека"
},
"actions": {
"remove": "Премахни",
@@ -275,134 +275,134 @@
"empty": "Няма липсващи файлове"
},
"library": {
"name": "",
"name": "Библиотека |||| Библиотеки",
"fields": {
"name": "",
"path": "",
"remotePath": "",
"lastScanAt": "",
"songCount": "",
"albumCount": "",
"artistCount": "",
"totalSongs": "",
"totalAlbums": "",
"totalArtists": "",
"totalFolders": "",
"totalFiles": "",
"totalMissingFiles": "",
"totalSize": "",
"totalDuration": "",
"defaultNewUsers": "",
"createdAt": "",
"updatedAt": ""
"name": "Име",
"path": "Път",
"remotePath": "Отдалечен път",
"lastScanAt": "Последно сканиране",
"songCount": "Песни",
"albumCount": "Албуми",
"artistCount": "Изпълнители",
"totalSongs": "Песни",
"totalAlbums": "Албуми",
"totalArtists": "Изпълнители",
"totalFolders": "Папки",
"totalFiles": "Файлове",
"totalMissingFiles": "Липсващи файлове",
"totalSize": "Общ размер",
"totalDuration": "Продължителност",
"defaultNewUsers": "По подразбиране за нови потребители",
"createdAt": "Създаден",
"updatedAt": "Актуализиран"
},
"sections": {
"basic": "",
"statistics": ""
"basic": "Основна информация",
"statistics": "Статистика"
},
"actions": {
"scan": "",
"manageUsers": "",
"viewDetails": "",
"scan": "Сканирай библиотеката",
"manageUsers": "Управление на потребителския достъп",
"viewDetails": "Преглед на подробности",
"quickScan": "Quick Scan",
"fullScan": ""
"fullScan": "Пълно сканиране"
},
"notifications": {
"created": "",
"updated": "",
"deleted": "",
"scanStarted": "",
"scanCompleted": "",
"quickScanStarted": "",
"fullScanStarted": "",
"scanError": ""
"created": "Библиотеката е създадена успешно",
"updated": "Библиотеката е актуализирана успешно",
"deleted": "Библиотеката е изтрита успешно",
"scanStarted": "Сканирането на библиотеката започна",
"scanCompleted": "Сканирането на библиотеката е завършено",
"quickScanStarted": "Бързото сканиране започна",
"fullScanStarted": "Пълното сканиране започна",
"scanError": "Грешка при стартиране на сканирането. Проверете лог файловете"
},
"validation": {
"nameRequired": "",
"pathRequired": "",
"pathNotDirectory": "",
"pathNotFound": "",
"pathNotAccessible": "",
"pathInvalid": ""
"nameRequired": "Името на библиотеката е задължително",
"pathRequired": "Пътят към библиотеката е задължителен",
"pathNotDirectory": "Пътят до библиотеката трябва да е директория",
"pathNotFound": "Пътят към библиотеката не е намерен",
"pathNotAccessible": "Пътят до библиотеката не е достъпен",
"pathInvalid": "Невалиден път към библиотеката"
},
"messages": {
"deleteConfirm": "",
"scanInProgress": "",
"noLibrariesAssigned": ""
"deleteConfirm": "Сигурни ли сте, че желаете да изтриете тази библиотека? Това ще премахне всички свързани данни и потребителски достъп.",
"scanInProgress": "Сканирането е в ход...",
"noLibrariesAssigned": "Няма библиотеки, присвоени на този потребител"
}
},
"plugin": {
"name": "",
"name": "Плъгин |||| Плъгини",
"fields": {
"id": "",
"name": "",
"description": "",
"version": "",
"author": "",
"website": "",
"permissions": "",
"enabled": "",
"status": "",
"path": "",
"lastError": "",
"hasError": "",
"updatedAt": "",
"createdAt": "",
"configKey": "",
"configValue": "",
"allUsers": "",
"selectedUsers": "",
"allLibraries": "",
"selectedLibraries": ""
"id": "ID номер",
"name": "Име",
"description": "Описание",
"version": "Версия",
"author": "Автор",
"website": "Уебсайт",
"permissions": "Разрешения",
"enabled": "Активирано",
"status": "Статус",
"path": "Път",
"lastError": "Грешка",
"hasError": "Грешка",
"updatedAt": "Актуализирано",
"createdAt": "Инсталирано",
"configKey": "Ключ",
"configValue": "Стойност",
"allUsers": "Разрешаване на всички потребители",
"selectedUsers": "Избрани потребители",
"allLibraries": "Разрешаване на всички библиотеки",
"selectedLibraries": "Избрани библиотеки"
},
"sections": {
"status": "",
"info": "",
"configuration": "",
"manifest": "",
"usersPermission": "",
"libraryPermission": ""
"status": "Статус",
"info": "Информация за плъгина",
"configuration": "Конфигурация",
"manifest": "Манифест",
"usersPermission": "Права за потребители",
"libraryPermission": "Права за библиотека"
},
"status": {
"enabled": "",
"disabled": ""
"enabled": "Активирано",
"disabled": "Деактивирано"
},
"actions": {
"enable": "",
"disable": "",
"disabledDueToError": "",
"disabledUsersRequired": "",
"disabledLibrariesRequired": "",
"addConfig": "",
"rescan": ""
"enable": "Активирай",
"disable": "Деактивирай",
"disabledDueToError": "Поправете грешката преди активиране",
"disabledUsersRequired": "Изберете потребители преди активиране",
"disabledLibrariesRequired": "Изберете библиотеки преди активиране",
"addConfig": "Добавяне на конфигурация",
"rescan": "Повторно сканиране"
},
"notifications": {
"enabled": "",
"disabled": "",
"updated": "",
"error": ""
"enabled": "Плъгинът е активиран",
"disabled": "Плъгинът е деактивиран",
"updated": "Плъгинът е актуализиран",
"error": "Грешка при актуализиране на плъгина"
},
"validation": {
"invalidJson": ""
"invalidJson": "Конфигурацията трябва да е валиден JSON"
},
"messages": {
"configHelp": "",
"clickPermissions": "",
"noConfig": "",
"allUsersHelp": "",
"noUsers": "",
"permissionReason": "",
"usersRequired": "",
"allLibrariesHelp": "",
"noLibraries": "",
"librariesRequired": "",
"requiredHosts": "",
"configValidationError": "",
"schemaRenderError": ""
"configHelp": "Конфигурирайте плъгина, използвайки двойки ключ-стойност. Оставете празно, ако плъгинът не изисква конфигурация.",
"clickPermissions": "Кликнете върху разрешение за подробности",
"noConfig": "Няма зададена конфигурация",
"allUsersHelp": "Когато е активиран, плъгинът ще има достъп до всички потребители, включително тези, създадени в бъдеще.",
"noUsers": "Няма избрани потребители",
"permissionReason": "Причина",
"usersRequired": "Този плъгин изисква достъп до потребителска информация. Изберете до кои потребители плъгинът може да има достъп или активирайте „Разрешаване на всички потребители“.",
"allLibrariesHelp": "Когато е активиран, плъгинът ще има достъп до всички библиотеки, включително тези, създадени в бъдеще.",
"noLibraries": "Няма избрани библиотеки",
"librariesRequired": "Този плъгин изисква достъп до информация за библиотеката. Изберете до кои библиотеки плъгинът може да има достъп или активирайте „Разрешаване на всички библиотеки“.",
"requiredHosts": "Необходими хостове",
"configValidationError": "Валидирането на конфигурацията не бе успешно:",
"schemaRenderError": "Не може да се изобрази формята за конфигурация. Схемата на плъгина може да е невалидна."
},
"placeholders": {
"configKey": "",
"configValue": ""
"configKey": "ключ",
"configValue": "стойност"
}
}
},
@@ -586,9 +586,9 @@
"remove_missing_content": "Сигурни ли сте, че желаете да премахнете избраните липсващи файлове от базата данни? Това ще премахне завинаги всички препратки към тях, включително броя на възпроизвежданията и оценките им.",
"remove_all_missing_title": "Премахни всички липсващи файлове",
"remove_all_missing_content": "Сигурни ли сте, че желаете да премахнете всички липсващи файлове от базата данни? Това ще премахне завинаги всички препратки към тях, включително броя на възпроизвежданията и оценките им.",
"noSimilarSongsFound": "",
"noTopSongsFound": "",
"startingInstantMix": ""
"noSimilarSongsFound": "Не са намерени подобни песни",
"noTopSongsFound": "Няма намерени топ песни",
"startingInstantMix": "Зареждане на незабавен микс..."
},
"menu": {
"library": "Библиотека",
@@ -619,10 +619,10 @@
"playlists": "Плейлисти",
"sharedPlaylists": "Споделени плейлисти",
"librarySelector": {
"allLibraries": "",
"multipleLibraries": "",
"selectLibraries": "",
"none": ""
"allLibraries": "Всички библиотеки (%{count})",
"multipleLibraries": "%{selected} от %{total} библиотеки",
"selectLibraries": "Изберете библиотеки",
"none": "Няма"
}
},
"player": {
@@ -655,7 +655,7 @@
"homepage": "Начална страница",
"source": "Програмен код",
"featureRequests": "Заявете функционалност",
"lastInsightsCollection": "",
"lastInsightsCollection": "Последна колекция от анализи",
"insights": {
"disabled": "Деактивиран",
"waiting": "Изчакване"
@@ -669,12 +669,13 @@
"configName": "Име на конфигурация",
"environmentVariable": "Променлива на средата",
"currentValue": "Текуща стойност",
"configurationFile": "",
"configurationFile": "Конфигурационен файл",
"exportToml": "Експортиране на конфигурация (TOML)",
"exportSuccess": "Конфигурация, експортирана в клипборда във формат TOML",
"exportFailed": "Неуспешно копиране на конфигурация",
"devFlagsHeader": "",
"devFlagsComment": ""
"devFlagsHeader": "Флагове за разработка (подлежащи на промяна/премахване)",
"devFlagsComment": "Това са експериментални настройки и е възможно да бъдат премахнати в бъдещи версии.",
"downloadToml": "Изтегляне на конфигурация (TOML)"
}
},
"activity": {
@@ -687,7 +688,7 @@
"scanType": "Последно сканиране",
"status": "Грешка при сканиране",
"elapsedTime": "Изминало време",
"selectiveScan": ""
"selectiveScan": "Селективен"
},
"help": {
"title": "Бързи клавиши на Navidrome",
@@ -704,8 +705,8 @@
}
},
"nowPlaying": {
"title": "",
"empty": "",
"minutesAgo": ""
"title": "Сега свири",
"empty": "Нищо не се възпроизвежда",
"minutesAgo": "преди %{smart_count} минута |||| преди %{smart_count} минути"
}
}

View File

@@ -674,7 +674,8 @@
"exportSuccess": "Configuració exportada al porta-retalls en format TOML",
"exportFailed": "La còpia de la configuració ha fallat",
"devFlagsHeader": "Indicadors de desenvolupament (subjecte a canvis o eliminació)",
"devFlagsComment": "Aquests paràmetres són experimentals i és possible que s'eliminin en versions futures"
"devFlagsComment": "Aquests paràmetres són experimentals i és possible que s'eliminin en versions futures",
"downloadToml": "Descarrega la configuració (TOML)"
}
},
"activity": {

View File

@@ -675,7 +675,7 @@
"exportFailed": "Kunne ikke kopiere konfigurationen",
"devFlagsHeader": "Udviklingsflagget (med forbehold for ændring/fjernelse)",
"devFlagsComment": "Disse er eksperimental-indstillinger og kan blive fjernet i fremtidige udgaver",
"downloadToml": ""
"downloadToml": "Download konfigurationen (TOML)"
}
},
"activity": {

View File

@@ -674,7 +674,8 @@
"exportSuccess": "Konfiguration im TOML Format in die Zwischenablage kopiert",
"exportFailed": "Fehler beim Kopieren der Konfiguration",
"devFlagsHeader": "Entwicklungseinstellungen (können sich ändern)",
"devFlagsComment": "Experimentelle Einstellungen, die eventuell in Zukunft entfernt oder geändert werden"
"devFlagsComment": "Experimentelle Einstellungen, die eventuell in Zukunft entfernt oder geändert werden",
"downloadToml": "Konfiguration Herunterladen (TOML)"
}
},
"activity": {

View File

@@ -674,7 +674,8 @@
"exportSuccess": "Η διαμόρφωση εξήχθη στο πρόχειρο σε μορφή TOML",
"exportFailed": "Η αντιγραφή της διαμόρφωσης απέτυχε",
"devFlagsHeader": "Σημαίες Ανάπτυξης (υπόκειται σε αλλαγές / αφαίρεση)",
"devFlagsComment": "Αυτές είναι πειραματικές ρυθμίσεις και ενδέχεται να καταργηθούν σε μελλοντικές εκδόσεις"
"devFlagsComment": "Αυτές είναι πειραματικές ρυθμίσεις και ενδέχεται να καταργηθούν σε μελλοντικές εκδόσεις",
"downloadToml": "Λήψη διαμόρφωσης (TOML)"
}
},
"activity": {

View File

@@ -674,7 +674,8 @@
"exportSuccess": "Configuración exportada al portapapeles en formato TOML",
"exportFailed": "Error al copiar la configuración",
"devFlagsHeader": "Indicadores de desarrollo (sujetos a cambios o eliminación)",
"devFlagsComment": "Estas son configuraciones experimentales y pueden eliminarse en versiones futuras"
"devFlagsComment": "Estas son configuraciones experimentales y pueden eliminarse en versiones futuras",
"downloadToml": "Descargar la configuración (TOML)"
}
},
"activity": {

View File

@@ -674,7 +674,8 @@
"exportSuccess": "Määritykset viety leikepöydälle TOML-muodossa",
"exportFailed": "Määritysten kopiointi epäonnistui",
"devFlagsHeader": "Kehitysliput (voivat muuttua/poistua)",
"devFlagsComment": "Nämä ovat kokeellisia asetuksia ja ne voidaan poistaa tulevissa versioissa"
"devFlagsComment": "Nämä ovat kokeellisia asetuksia ja ne voidaan poistaa tulevissa versioissa",
"downloadToml": "Lataa määritykset (TOML)"
}
},
"activity": {

View File

@@ -674,7 +674,8 @@
"exportSuccess": "La configuration a été copiée vers le presse-papier au format TOML",
"exportFailed": "Une erreur est survenue en copiant la configuration",
"devFlagsHeader": "Options de développement (peuvent être amenés à changer / être supprimés)",
"devFlagsComment": "Ces paramètres sont expérimentaux et peuvent être amenés à changer dans le futur"
"devFlagsComment": "Ces paramètres sont expérimentaux et peuvent être amenés à changer dans le futur",
"downloadToml": "Télécharger la configuration (TOML)"
}
},
"activity": {

View File

@@ -674,7 +674,8 @@
"exportSuccess": "Configuración exportada ao portapapeis no formato TOML",
"exportFailed": "Fallou a copia da configuración",
"devFlagsHeader": "Configuracións de Desenvolvemento (suxeitas a cambio/retirada)",
"devFlagsComment": "Son axustes experimentais e poden retirarse en futuras versións"
"devFlagsComment": "Son axustes experimentais e poden retirarse en futuras versións",
"downloadToml": "Descargar configuración (TOML)"
}
},
"activity": {

View File

@@ -353,8 +353,7 @@
"allUsers": "Permitir todos os usuários",
"selectedUsers": "Usuários selecionados",
"allLibraries": "Permitir todas as bibliotecas",
"selectedLibraries": "Bibliotecas selecionadas",
"allowWriteAccess": "Permitir acesso de escrita"
"selectedLibraries": "Bibliotecas selecionadas"
},
"sections": {
"status": "Status",
@@ -397,7 +396,6 @@
"allLibrariesHelp": "Quando habilitado, o plugin terá acesso a todas as bibliotecas, incluindo as criadas no futuro.",
"noLibraries": "Nenhuma biblioteca selecionada",
"librariesRequired": "Este plugin requer acesso a informações de bibliotecas. Selecione quais bibliotecas o plugin pode acessar, ou habilite 'Permitir todas as bibliotecas'.",
"allowWriteAccessHelp": "Quando habilitado, o plugin pode modificar arquivos nos diretórios das bibliotecas. Por padrão, plugins têm acesso somente leitura.",
"requiredHosts": "Hosts necessários",
"configValidationError": "Falha na validação da configuração:",
"schemaRenderError": "Não foi possível renderizar o formulário de configuração. O schema do plugin pode estar inválido."

View File

@@ -674,7 +674,8 @@
"exportSuccess": "Конфигурация экспортирована в буфер обмена в формате TOML",
"exportFailed": "Не удалось скопировать конфигурацию",
"devFlagsHeader": "Флаги разработки (могут быть изменены/удалены)",
"devFlagsComment": "Это экспериментальные настройки, которые могут быть удалены в будущих версиях."
"devFlagsComment": "Это экспериментальные настройки, которые могут быть удалены в будущих версиях.",
"downloadToml": "Скачать конфигурацию (TOML)"
}
},
"activity": {

View File

@@ -48,7 +48,7 @@
"playNext": "Naslednji",
"info": "Več informacij",
"showInPlaylist": "Prikaži na seznamu predvajanja",
"instantMix": ""
"instantMix": "Instant Mix"
}
},
"album": {
@@ -397,8 +397,8 @@
"noLibraries": "Ni izbranih knjižnic",
"librariesRequired": "Vtičnik zahteva dostop do knjižnih informacij. Izberi do katerih knjižnic lahko dostopa, ali vključi dostop do vseh knjižnic.",
"requiredHosts": "Zahtevani gostitelji",
"configValidationError": "",
"schemaRenderError": ""
"configValidationError": "Validacija konfiguracije neuspešna:",
"schemaRenderError": "Konfiguracijskega obrazca ni mogoče upodobiti. Shema vtičnika je morda neveljavna."
},
"placeholders": {
"configKey": "ključ",
@@ -588,7 +588,7 @@
"remove_all_missing_content": "Ste prepričani, da želite odstraniti vse manjkajoče datoteke iz baze? Trajno boste odstranili vse reference nanje, vključno s številom predvajanj in ocenami.",
"noSimilarSongsFound": "Ni najdenih podobnih pesmi",
"noTopSongsFound": "Ni najdenih najboljših pesmi",
"startingInstantMix": ""
"startingInstantMix": "Nalaganje Instant Mix..."
},
"menu": {
"library": "Knjižnica",
@@ -674,7 +674,8 @@
"exportSuccess": "Konfiguracija izvožena v odložišče v formatu TOML",
"exportFailed": "Kopiranje konfiguracije ni uspelo",
"devFlagsHeader": "Razvojne zastavice (lahko se spremenijo/odstranijo)",
"devFlagsComment": "To so eksperimentalne nastavitve in bodo morda odstranjene v prihodnjih različicah"
"devFlagsComment": "To so eksperimentalne nastavitve in bodo morda odstranjene v prihodnjih različicah",
"downloadToml": "Naloži konfiguracijo (TOML)"
}
},
"activity": {

View File

@@ -674,7 +674,8 @@
"exportSuccess": "Inställningarna kopierade till urklippet i TOML-format",
"exportFailed": "Kopiering av inställningarna misslyckades",
"devFlagsHeader": "Utvecklingsflaggor (kan ändras eller tas bort)",
"devFlagsComment": "Dessa inställningar är experimentella och kan tas bort i framtida versioner"
"devFlagsComment": "Dessa inställningar är experimentella och kan tas bort i framtida versioner",
"downloadToml": "Ladda ner konfiguration (TOML)"
}
},
"activity": {

View File

@@ -48,7 +48,7 @@
"playNext": "เล่นถัดไป",
"info": "ดูรายละเอียด",
"showInPlaylist": "แสดงในเพลย์ลิสต์",
"instantMix": ""
"instantMix": "อินสแตนต์ มิก"
}
},
"album": {
@@ -588,7 +588,7 @@
"remove_all_missing_content": "คุณแน่ใจว่าจะเอารายการไฟล์ที่หายไปออกจากดาต้าเบส นี่จะเป็นการลบข้อมูลอ้างอิงทั้งหมดของไฟล์ออกอย่างถาวร",
"noSimilarSongsFound": "ไม่มีเพลงคล้ายกัน",
"noTopSongsFound": "ไม่พบเพลงยอดนิยม",
"startingInstantMix": ""
"startingInstantMix": "กำลังโหลดอินสแตนท์ มิก..."
},
"menu": {
"library": "ห้องสมุดเพลง",
@@ -674,7 +674,8 @@
"exportSuccess": "นำออกการตั้งค่าไปยังคลิปบอร์ดในรูปแบบ TOML แล้ว",
"exportFailed": "คัดลอกการตั้งค่าล้มเหลว",
"devFlagsHeader": "ปักธงการพัฒนา (อาจมีการเปลี่ยน/เอาออก)",
"devFlagsComment": "การตั้งค่านี้อยู่ในช่วงทดลองและอาจจะมีการเอาออกในเวอร์ชั่นหลัง"
"devFlagsComment": "การตั้งค่านี้อยู่ในช่วงทดลองและอาจจะมีการเอาออกในเวอร์ชั่นหลัง",
"downloadToml": "ดาวน์โหลดการตั้งค่า (TOML)"
}
},
"activity": {

View File

@@ -10,19 +10,14 @@
"playCount": "播放次數",
"title": "標題",
"artist": "藝人",
"composer": "作曲者",
"album": "專輯",
"path": "檔案路徑",
"libraryName": "媒體庫",
"genre": "曲風",
"compilation": "合輯",
"year": "發行年份",
"size": "檔案大小",
"updatedAt": "更新於",
"bitRate": "位元率",
"bitDepth": "位元深度",
"sampleRate": "取樣率",
"channels": "聲道",
"discSubtitle": "光碟副標題",
"starred": "收藏",
"comment": "註解",
@@ -30,6 +25,7 @@
"quality": "品質",
"bpm": "BPM",
"playDate": "上次播放",
"channels": "聲道",
"createdAt": "建立於",
"grouping": "分組",
"mood": "情緒",
@@ -37,17 +33,21 @@
"tags": "額外標籤",
"mappedTags": "分類後標籤",
"rawTags": "原始標籤",
"missing": "遺失"
"bitDepth": "位元深度",
"sampleRate": "取樣率",
"missing": "遺失",
"libraryName": "媒體庫",
"composer": "作曲者"
},
"actions": {
"addToQueue": "加入至播放佇列",
"playNow": "立即播放",
"addToPlaylist": "加入至播放清單",
"showInPlaylist": "在播放清單中顯示",
"shuffleAll": "全部隨機播放",
"download": "下載",
"playNext": "下一首播放",
"info": "取得資訊",
"showInPlaylist": "在播放清單中顯示",
"instantMix": "即時混音"
}
},
@@ -59,38 +59,38 @@
"duration": "長度",
"songCount": "歌曲數",
"playCount": "播放次數",
"size": "檔案大小",
"name": "名稱",
"libraryName": "媒體庫",
"genre": "曲風",
"compilation": "合輯",
"year": "發行年份",
"date": "錄製日期",
"originalDate": "原始日期",
"releaseDate": "發行日期",
"releases": "發行",
"released": "已發行",
"updatedAt": "更新於",
"comment": "註解",
"rating": "評分",
"createdAt": "建立於",
"size": "檔案大小",
"originalDate": "原始日期",
"releaseDate": "發行日期",
"releases": "發行",
"released": "已發行",
"recordLabel": "唱片公司",
"catalogNum": "目錄編號",
"releaseType": "發行類型",
"grouping": "分組",
"media": "媒體類型",
"mood": "情緒",
"missing": "遺失"
"date": "錄製日期",
"missing": "遺失",
"libraryName": "媒體庫"
},
"actions": {
"playAll": "播放全部",
"playNext": "下一首播放",
"addToQueue": "加入至播放佇列",
"share": "分享",
"shuffle": "隨機播放",
"addToPlaylist": "加入至播放清單",
"download": "下載",
"info": "取得資訊"
"info": "取得資訊",
"share": "分享"
},
"lists": {
"all": "所有",
@@ -108,10 +108,10 @@
"name": "名稱",
"albumCount": "專輯數",
"songCount": "歌曲數",
"size": "檔案大小",
"playCount": "播放次數",
"rating": "評分",
"genre": "曲風",
"size": "檔案大小",
"role": "參與角色",
"missing": "遺失"
},
@@ -132,9 +132,9 @@
"maincredit": "專輯藝人或藝人 |||| 專輯藝人或藝人"
},
"actions": {
"topSongs": "熱門歌曲",
"shuffle": "隨機播放",
"radio": "電台"
"radio": "電台",
"topSongs": "熱門歌曲"
}
},
"user": {
@@ -143,7 +143,6 @@
"userName": "使用者名稱",
"isAdmin": "管理員",
"lastLoginAt": "上次登入",
"lastAccessAt": "上次存取",
"updatedAt": "更新於",
"name": "名稱",
"password": "密碼",
@@ -152,6 +151,7 @@
"currentPassword": "目前密碼",
"newPassword": "新密碼",
"token": "權杖",
"lastAccessAt": "上次存取",
"libraries": "媒體庫"
},
"helperTexts": {
@@ -163,14 +163,14 @@
"updated": "使用者已更新",
"deleted": "使用者已刪除"
},
"validation": {
"librariesRequired": "非管理員使用者必須至少選擇一個媒體庫"
},
"message": {
"listenBrainzToken": "輸入您的 ListenBrainz 使用者權杖",
"clickHereForToken": "點擊此處來獲得您的 ListenBrainz 權杖",
"selectAllLibraries": "選取全部媒體庫",
"adminAutoLibraries": "管理員預設可存取所有媒體庫"
},
"validation": {
"librariesRequired": "非管理員使用者必須至少選擇一個媒體庫"
}
},
"player": {
@@ -213,9 +213,9 @@
"selectPlaylist": "選取播放清單:",
"addNewPlaylist": "建立「%{name}」",
"export": "匯出",
"saveQueue": "將播放佇列儲存到播放清單",
"makePublic": "設為公開",
"makePrivate": "設為私人",
"saveQueue": "將播放佇列儲存到播放清單",
"searchOrCreate": "搜尋播放清單,或輸入名稱來新建…",
"pressEnterToCreate": "按 Enter 鍵建立新的播放清單",
"removeFromSelection": "移除選取項目"
@@ -246,7 +246,6 @@
"username": "分享者",
"url": "網址",
"description": "描述",
"downloadable": "允許下載?",
"contents": "內容",
"expiresAt": "過期時間",
"lastVisitedAt": "上次造訪時間",
@@ -254,19 +253,17 @@
"format": "格式",
"maxBitRate": "最大位元率",
"updatedAt": "更新於",
"createdAt": "建立於"
},
"notifications": {},
"actions": {}
"createdAt": "建立於",
"downloadable": "允許下載?"
}
},
"missing": {
"name": "遺失檔案 |||| 遺失檔案",
"empty": "無遺失檔案",
"fields": {
"path": "路徑",
"size": "檔案大小",
"libraryName": "媒體庫",
"updatedAt": "遺失於"
"updatedAt": "遺失於",
"libraryName": "媒體庫"
},
"actions": {
"remove": "刪除",
@@ -274,7 +271,8 @@
},
"notifications": {
"removed": "遺失檔案已刪除"
}
},
"empty": "無遺失檔案"
},
"library": {
"name": "媒體庫 |||| 媒體庫",
@@ -304,20 +302,20 @@
},
"actions": {
"scan": "掃描媒體庫",
"quickScan": "快速掃描",
"fullScan": "完整掃描",
"manageUsers": "管理使用者權限",
"viewDetails": "查看詳細資料"
"viewDetails": "查看詳細資料",
"quickScan": "快速掃描",
"fullScan": "完整掃描"
},
"notifications": {
"created": "成功建立媒體庫",
"updated": "成功更新媒體庫",
"deleted": "成功刪除媒體庫",
"scanStarted": "開始掃描媒體庫",
"scanCompleted": "媒體庫掃描完成",
"quickScanStarted": "快速掃描已開始",
"fullScanStarted": "完整掃描已開始",
"scanError": "掃描啟動失敗,請檢查日誌",
"scanCompleted": "媒體庫掃描完成"
"scanError": "掃描啟動失敗,請檢查日誌"
},
"validation": {
"nameRequired": "請輸入媒體庫名稱",
@@ -389,8 +387,6 @@
},
"messages": {
"configHelp": "使用鍵值對設定插件。若插件無需設定則留空。",
"configValidationError": "設定驗證失敗:",
"schemaRenderError": "無法顯示設定表單。插件的 schema 可能無效。",
"clickPermissions": "點擊權限以查看詳細資訊",
"noConfig": "無設定",
"allUsersHelp": "啟用後,插件將可存取所有使用者,包含未來建立的使用者。",
@@ -400,7 +396,9 @@
"allLibrariesHelp": "啟用後,插件將可存取所有媒體庫,包含未來建立的媒體庫。",
"noLibraries": "未選擇媒體庫",
"librariesRequired": "此插件需要存取媒體庫資訊。請選擇插件可存取的媒體庫,或啟用「允許所有媒體庫」。",
"requiredHosts": "必要的 Hosts"
"requiredHosts": "必要的 Hosts",
"configValidationError": "設定驗證失敗:",
"schemaRenderError": "無法顯示設定表單。插件的 schema 可能無效。"
},
"placeholders": {
"configKey": "鍵",
@@ -443,7 +441,6 @@
"add": "加入",
"back": "返回",
"bulk_actions": "選中 1 項 |||| 選中 %{smart_count} 項",
"bulk_actions_mobile": "1 |||| %{smart_count}",
"cancel": "取消",
"clear_input_value": "清除",
"clone": "複製",
@@ -467,6 +464,7 @@
"close_menu": "關閉選單",
"unselect": "取消選取",
"skip": "略過",
"bulk_actions_mobile": "1 |||| %{smart_count}",
"share": "分享",
"download": "下載"
},
@@ -558,48 +556,42 @@
"transcodingDisabled": "出於安全原因,已禁用了從 Web 介面更改參數。要更改(編輯或新增)轉碼選項,請在啟用 %{config} 設定選項的情況下重新啟動伺服器。",
"transcodingEnabled": "Navidrome 目前與 %{config} 一起使用,因此可以透過 Web 介面從轉碼設定中執行系統命令。出於安全考慮,我們建議停用此功能,並僅在設定轉碼選項時啟用。",
"songsAddedToPlaylist": "已加入一首歌到播放清單 |||| 已新增 %{smart_count} 首歌到播放清單",
"noSimilarSongsFound": "找不到相似歌曲",
"startingInstantMix": "正在載入即時混音...",
"noTopSongsFound": "找不到熱門歌曲",
"noPlaylistsAvailable": "沒有可用的播放清單",
"delete_user_title": "刪除使用者「%{name}」",
"delete_user_content": "您確定要刪除此使用者及其所有資料(包括播放清單和偏好設定)嗎?",
"remove_missing_title": "刪除遺失檔案",
"remove_missing_content": "您確定要從媒體庫中刪除所選的遺失的檔案嗎?這將永久刪除它們的所有相關資訊,包括其播放次數和評分。",
"remove_all_missing_title": "刪除所有遺失檔案",
"remove_all_missing_content": "您確定要從媒體庫中刪除所有遺失的檔案嗎?這將永久刪除它們的所有相關資訊,包括它們的播放次數和評分。",
"notifications_blocked": "您已在瀏覽器設定中封鎖了此網站的通知",
"notifications_not_available": "此瀏覽器不支援桌面通知,或您並非透過 HTTPS 存取 Navidrome",
"lastfmLinkSuccess": "已成功連接 Last.fm 並開啟音樂記錄",
"lastfmLinkFailure": "無法連接 Last.fm",
"lastfmUnlinkSuccess": "已取消與 Last.fm 的連接並停用音樂記錄",
"lastfmUnlinkFailure": "無法取消與 Last.fm 的連接",
"listenBrainzLinkSuccess": "已成功以 %{user} 的身份連接 ListenBrainz 並開啟音樂記錄",
"listenBrainzLinkFailure": "無法連接 ListenBrainz%{error}",
"listenBrainzUnlinkSuccess": "已取消與 ListenBrainz 的連接並停用音樂記錄",
"listenBrainzUnlinkFailure": "無法取消與 ListenBrainz 的連接",
"openIn": {
"lastfm": "在 Last.fm 中開啟",
"musicbrainz": "在 MusicBrainz 中開啟"
},
"lastfmLink": "查看更多…",
"listenBrainzLinkSuccess": "已成功以 %{user} 的身份連接 ListenBrainz 並開啟音樂記錄",
"listenBrainzLinkFailure": "無法連接 ListenBrainz%{error}",
"listenBrainzUnlinkSuccess": "已取消與 ListenBrainz 的連接並停用音樂記錄",
"listenBrainzUnlinkFailure": "無法取消與 ListenBrainz 的連接",
"downloadOriginalFormat": "下載原始格式",
"shareOriginalFormat": "分享原始格式",
"shareDialogTitle": "分享 %{resource} '%{name}'",
"shareBatchDialogTitle": "分享 1 個%{resource} |||| 分享 %{smart_count} 個%{resource}",
"shareCopyToClipboard": "複製到剪貼簿Ctrl+C, Enter",
"shareSuccess": "分享成功,連結已複製到剪貼簿:%{url}",
"shareFailure": "分享連結複製失敗:%{url}",
"downloadDialogTitle": "下載 %{resource} '%{name}' (%{size})",
"downloadOriginalFormat": "下載原始格式"
"shareCopyToClipboard": "複製到剪貼簿Ctrl+C, Enter",
"remove_missing_title": "刪除遺失檔案",
"remove_missing_content": "您確定要從媒體庫中刪除所選的遺失的檔案嗎?這將永久刪除它們的所有相關資訊,包括其播放次數和評分。",
"remove_all_missing_title": "刪除所有遺失檔案",
"remove_all_missing_content": "您確定要從媒體庫中刪除所有遺失的檔案嗎?這將永久刪除它們的所有相關資訊,包括它們的播放次數和評分。",
"noSimilarSongsFound": "找不到相似歌曲",
"noTopSongsFound": "找不到熱門歌曲",
"startingInstantMix": "正在載入即時混音..."
},
"menu": {
"library": "媒體庫",
"librarySelector": {
"allLibraries": "所有媒體庫 (%{count})",
"multipleLibraries": "已選 %{selected} 共 %{total} 媒體庫",
"selectLibraries": "選取媒體庫",
"none": "無"
},
"settings": "設定",
"version": "版本",
"theme": "主題",
@@ -610,7 +602,6 @@
"language": "語言",
"defaultView": "預設畫面",
"desktop_notifications": "桌面通知",
"lastfmNotConfigured": "Last.fm API 金鑰未設定",
"lastfmScrobbling": "啟用 Last.fm 音樂記錄",
"listenBrainzScrobbling": "啟用 ListenBrainz 音樂記錄",
"replaygain": "重播增益模式",
@@ -619,13 +610,20 @@
"none": "無",
"album": "專輯增益",
"track": "曲目增益"
}
},
"lastfmNotConfigured": "Last.fm API 金鑰未設定"
}
},
"albumList": "專輯",
"about": "關於",
"playlists": "播放清單",
"sharedPlaylists": "分享的播放清單",
"about": "關於"
"librarySelector": {
"allLibraries": "所有媒體庫 (%{count})",
"multipleLibraries": "已選 %{selected} 共 %{total} 媒體庫",
"selectLibraries": "選取媒體庫",
"none": "無"
}
},
"player": {
"playListsText": "播放佇列",
@@ -676,7 +674,8 @@
"exportSuccess": "設定已以 TOML 格式匯出至剪貼簿",
"exportFailed": "設定複製失敗",
"devFlagsHeader": "開發旗標(可能會更改/刪除)",
"devFlagsComment": "這些是實驗性設定,可能會在未來版本中刪除"
"devFlagsComment": "這些是實驗性設定,可能會在未來版本中刪除",
"downloadToml": "下載設定檔 (TOML)"
}
},
"activity": {
@@ -684,17 +683,12 @@
"totalScanned": "已掃描的資料夾總數",
"quickScan": "快速掃描",
"fullScan": "完全掃描",
"selectiveScan": "選擇性掃描",
"serverUptime": "伺服器運作時間",
"serverDown": "伺服器已離線",
"scanType": "掃描類型",
"status": "掃描錯誤",
"elapsedTime": "經過時間"
},
"nowPlaying": {
"title": "正在播放",
"empty": "無播放內容",
"minutesAgo": "1 分鐘前 |||| %{smart_count} 分鐘前"
"elapsedTime": "經過時間",
"selectiveScan": "選擇性掃描"
},
"help": {
"title": "Navidrome 快捷鍵",
@@ -704,10 +698,15 @@
"toggle_play": "播放/暫停",
"prev_song": "上一首歌",
"next_song": "下一首歌",
"current_song": "前往目前歌曲",
"vol_up": "提高音量",
"vol_down": "降低音量",
"toggle_love": "新增此歌曲至收藏"
"toggle_love": "新增此歌曲至收藏",
"current_song": "前往目前歌曲"
}
},
"nowPlaying": {
"title": "正在播放",
"empty": "無播放內容",
"minutesAgo": "1 分鐘前 |||| %{smart_count} 分鐘前"
}
}
}

View File

@@ -29,7 +29,7 @@ type PluginManager interface {
ValidatePluginConfig(ctx context.Context, id, configJSON string) error
UpdatePluginConfig(ctx context.Context, id, configJSON string) error
UpdatePluginUsers(ctx context.Context, id, usersJSON string, allUsers bool) error
UpdatePluginLibraries(ctx context.Context, id, librariesJSON string, allLibraries, allowWriteAccess bool) error
UpdatePluginLibraries(ctx context.Context, id, librariesJSON string, allLibraries bool) error
RescanPlugins(ctx context.Context) error
UnloadDisabledPlugins(ctx context.Context)
}

View File

@@ -56,13 +56,12 @@ func pluginsEnabledMiddleware(next http.Handler) http.Handler {
// PluginUpdateRequest represents the fields that can be updated via the API
type PluginUpdateRequest struct {
Enabled *bool `json:"enabled,omitempty"`
Config *string `json:"config,omitempty"`
Users *string `json:"users,omitempty"`
AllUsers *bool `json:"allUsers,omitempty"`
Libraries *string `json:"libraries,omitempty"`
AllLibraries *bool `json:"allLibraries,omitempty"`
AllowWriteAccess *bool `json:"allowWriteAccess,omitempty"`
Enabled *bool `json:"enabled,omitempty"`
Config *string `json:"config,omitempty"`
Users *string `json:"users,omitempty"`
AllUsers *bool `json:"allUsers,omitempty"`
Libraries *string `json:"libraries,omitempty"`
AllLibraries *bool `json:"allLibraries,omitempty"`
}
func (api *Router) updatePlugin(w http.ResponseWriter, r *http.Request) {
@@ -110,7 +109,7 @@ func (api *Router) updatePlugin(w http.ResponseWriter, r *http.Request) {
}
// Handle libraries permission update (if provided)
if req.Libraries != nil || req.AllLibraries != nil || req.AllowWriteAccess != nil {
if req.Libraries != nil || req.AllLibraries != nil {
if err := validateAndUpdateLibraries(ctx, api.pluginManager, repo, id, req, w); err != nil {
log.Error(ctx, "Error updating plugin libraries", err)
return
@@ -246,7 +245,6 @@ func validateAndUpdateLibraries(ctx context.Context, pm PluginManager, repo mode
librariesJSON := plugin.Libraries
allLibraries := plugin.AllLibraries
allowWriteAccess := plugin.AllowWriteAccess
if req.Libraries != nil {
if *req.Libraries != "" && !isValidJSON(*req.Libraries) {
@@ -258,11 +256,8 @@ func validateAndUpdateLibraries(ctx context.Context, pm PluginManager, repo mode
if req.AllLibraries != nil {
allLibraries = *req.AllLibraries
}
if req.AllowWriteAccess != nil {
allowWriteAccess = *req.AllowWriteAccess
}
if err := pm.UpdatePluginLibraries(ctx, id, librariesJSON, allLibraries, allowWriteAccess); err != nil {
if err := pm.UpdatePluginLibraries(ctx, id, librariesJSON, allLibraries); err != nil {
log.Error(ctx, "Error updating plugin libraries", "id", id, err)
http.Error(w, "Error updating plugin libraries: "+err.Error(), http.StatusInternalServerError)
return err

View File

@@ -18,7 +18,7 @@ type MockPluginManager struct {
// UpdatePluginUsersFn is called when UpdatePluginUsers is invoked. If nil, returns UsersError.
UpdatePluginUsersFn func(ctx context.Context, id, usersJSON string, allUsers bool) error
// UpdatePluginLibrariesFn is called when UpdatePluginLibraries is invoked. If nil, returns LibrariesError.
UpdatePluginLibrariesFn func(ctx context.Context, id, librariesJSON string, allLibraries, allowWriteAccess bool) error
UpdatePluginLibrariesFn func(ctx context.Context, id, librariesJSON string, allLibraries bool) error
// RescanPluginsFn is called when RescanPlugins is invoked. If nil, returns RescanError.
RescanPluginsFn func(ctx context.Context) error
@@ -48,10 +48,9 @@ type MockPluginManager struct {
AllUsers bool
}
UpdatePluginLibrariesCalls []struct {
ID string
LibrariesJSON string
AllLibraries bool
AllowWriteAccess bool
ID string
LibrariesJSON string
AllLibraries bool
}
RescanPluginsCalls int
}
@@ -106,15 +105,14 @@ func (m *MockPluginManager) UpdatePluginUsers(ctx context.Context, id, usersJSON
return m.UsersError
}
func (m *MockPluginManager) UpdatePluginLibraries(ctx context.Context, id, librariesJSON string, allLibraries, allowWriteAccess bool) error {
func (m *MockPluginManager) UpdatePluginLibraries(ctx context.Context, id, librariesJSON string, allLibraries bool) error {
m.UpdatePluginLibrariesCalls = append(m.UpdatePluginLibrariesCalls, struct {
ID string
LibrariesJSON string
AllLibraries bool
AllowWriteAccess bool
}{ID: id, LibrariesJSON: librariesJSON, AllLibraries: allLibraries, AllowWriteAccess: allowWriteAccess})
ID string
LibrariesJSON string
AllLibraries bool
}{ID: id, LibrariesJSON: librariesJSON, AllLibraries: allLibraries})
if m.UpdatePluginLibrariesFn != nil {
return m.UpdatePluginLibrariesFn(ctx, id, librariesJSON, allLibraries, allowWriteAccess)
return m.UpdatePluginLibrariesFn(ctx, id, librariesJSON, allLibraries)
}
return m.LibrariesError
}

View File

@@ -355,8 +355,7 @@
"allUsers": "Allow all users",
"selectedUsers": "Selected users",
"allLibraries": "Allow all libraries",
"selectedLibraries": "Selected libraries",
"allowWriteAccess": "Allow write access"
"selectedLibraries": "Selected libraries"
},
"sections": {
"status": "Status",
@@ -401,7 +400,6 @@
"allLibrariesHelp": "When enabled, the plugin will have access to all libraries, including those created in the future.",
"noLibraries": "No libraries selected",
"librariesRequired": "This plugin requires access to library information. Select which libraries the plugin can access, or enable 'Allow all libraries'.",
"allowWriteAccessHelp": "When enabled, the plugin can modify files in the library directories. By default, plugins have read-only access.",
"requiredHosts": "Required hosts"
},
"placeholders": {

View File

@@ -23,10 +23,8 @@ export const LibraryPermissionCard = ({
classes,
selectedLibraries,
allLibraries,
allowWriteAccess,
onSelectedLibrariesChange,
onAllLibrariesChange,
onAllowWriteAccessChange,
}) => {
const translate = useTranslate()
@@ -60,17 +58,9 @@ export const LibraryPermissionCard = ({
[onAllLibrariesChange],
)
const handleAllowWriteAccessToggle = React.useCallback(
(event) => {
onAllowWriteAccessChange(event.target.checked)
},
[onAllowWriteAccessChange],
)
// Get permission reason from manifest
const libraryPermission = manifest?.permissions?.library
const reason = libraryPermission?.reason
const hasFilesystem = libraryPermission?.filesystem === true
// Check if permission is required but not configured
const isConfigurationRequired =
@@ -117,24 +107,6 @@ export const LibraryPermissionCard = ({
</Typography>
</Box>
{hasFilesystem && (
<Box mb={2}>
<FormControlLabel
control={
<Switch
checked={allowWriteAccess}
onChange={handleAllowWriteAccessToggle}
color="primary"
/>
}
label={translate('resources.plugin.fields.allowWriteAccess')}
/>
<Typography variant="body2" color="textSecondary">
{translate('resources.plugin.messages.allowWriteAccessHelp')}
</Typography>
</Box>
)}
{!allLibraries && (
<Box className={classes.usersList}>
<Typography variant="subtitle2" gutterBottom>
@@ -194,8 +166,6 @@ LibraryPermissionCard.propTypes = {
classes: PropTypes.object.isRequired,
selectedLibraries: PropTypes.array.isRequired,
allLibraries: PropTypes.bool.isRequired,
allowWriteAccess: PropTypes.bool.isRequired,
onSelectedLibrariesChange: PropTypes.func.isRequired,
onAllLibrariesChange: PropTypes.func.isRequired,
onAllowWriteAccessChange: PropTypes.func.isRequired,
}

View File

@@ -48,11 +48,8 @@ const PluginShowLayout = () => {
// Libraries permission state
const [selectedLibraries, setSelectedLibraries] = useState([])
const [allLibraries, setAllLibraries] = useState(false)
const [allowWriteAccess, setAllowWriteAccess] = useState(false)
const [lastRecordLibraries, setLastRecordLibraries] = useState(null)
const [lastRecordAllLibraries, setLastRecordAllLibraries] = useState(null)
const [lastRecordAllowWriteAccess, setLastRecordAllowWriteAccess] =
useState(null)
// Parse JSON config to object
const jsonToObject = useCallback((jsonString) => {
@@ -102,12 +99,10 @@ const PluginShowLayout = () => {
if (record && !isDirty) {
const recordLibraries = record.libraries || ''
const recordAllLibraries = record.allLibraries || false
const recordAllowWriteAccess = record.allowWriteAccess || false
if (
recordLibraries !== lastRecordLibraries ||
recordAllLibraries !== lastRecordAllLibraries ||
recordAllowWriteAccess !== lastRecordAllowWriteAccess
recordAllLibraries !== lastRecordAllLibraries
) {
try {
setSelectedLibraries(
@@ -117,19 +112,11 @@ const PluginShowLayout = () => {
setSelectedLibraries([])
}
setAllLibraries(recordAllLibraries)
setAllowWriteAccess(recordAllowWriteAccess)
setLastRecordLibraries(recordLibraries)
setLastRecordAllLibraries(recordAllLibraries)
setLastRecordAllowWriteAccess(recordAllowWriteAccess)
}
}
}, [
record,
lastRecordLibraries,
lastRecordAllLibraries,
lastRecordAllowWriteAccess,
isDirty,
])
}, [record, lastRecordLibraries, lastRecordAllLibraries, isDirty])
const handleConfigDataChange = useCallback(
(newData, errors) => {
@@ -165,11 +152,6 @@ const PluginShowLayout = () => {
setIsDirty(true)
}, [])
const handleAllowWriteAccessChange = useCallback((newAllowWriteAccess) => {
setAllowWriteAccess(newAllowWriteAccess)
setIsDirty(true)
}, [])
const [updatePlugin, { loading }] = useUpdate(
'plugin',
record?.id,
@@ -185,7 +167,6 @@ const PluginShowLayout = () => {
setLastRecordAllUsers(null)
setLastRecordLibraries(null)
setLastRecordAllLibraries(null)
setLastRecordAllowWriteAccess(null)
notify('resources.plugin.notifications.updated', 'info')
},
onFailure: (err) => {
@@ -218,7 +199,6 @@ const PluginShowLayout = () => {
if (parsedManifest?.permissions?.library) {
data.libraries = JSON.stringify(selectedLibraries)
data.allLibraries = allLibraries
data.allowWriteAccess = allowWriteAccess
}
updatePlugin('plugin', record.id, data, record)
@@ -230,7 +210,6 @@ const PluginShowLayout = () => {
allUsers,
selectedLibraries,
allLibraries,
allowWriteAccess,
])
// Parse manifest
@@ -315,10 +294,8 @@ const PluginShowLayout = () => {
classes={classes}
selectedLibraries={selectedLibraries}
allLibraries={allLibraries}
allowWriteAccess={allowWriteAccess}
onSelectedLibrariesChange={handleSelectedLibrariesChange}
onAllLibrariesChange={handleAllLibrariesChange}
onAllowWriteAccessChange={handleAllowWriteAccessChange}
/>
<Box display="flex" justifyContent="flex-end">