mirror of
https://github.com/navidrome/navidrome.git
synced 2026-02-28 04:46:18 -05:00
Compare commits
3 Commits
update-tra
...
rw-librari
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b545574c37 | ||
|
|
2c245e5446 | ||
|
|
bd8032b327 |
@@ -0,0 +1,5 @@
|
||||
-- +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;
|
||||
@@ -3,19 +3,20 @@ 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"`
|
||||
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"`
|
||||
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"`
|
||||
}
|
||||
|
||||
type Plugins []Plugin
|
||||
|
||||
@@ -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, 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})
|
||||
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})
|
||||
ON CONFLICT(id) DO UPDATE SET
|
||||
path = excluded.path,
|
||||
manifest = excluded.manifest,
|
||||
@@ -89,24 +89,26 @@ 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,
|
||||
"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,
|
||||
"allow_write_access": plugin.AllowWriteAccess,
|
||||
"enabled": plugin.Enabled,
|
||||
"last_error": plugin.LastError,
|
||||
"sha256": plugin.SHA256,
|
||||
"created_at": time.Now(),
|
||||
"updated_at": plugin.UpdatedAt,
|
||||
}).Execute()
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -282,9 +282,6 @@ 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() {
|
||||
|
||||
@@ -256,6 +256,15 @@ 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,
|
||||
}
|
||||
@@ -622,6 +631,15 @@ 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,
|
||||
|
||||
@@ -264,96 +264,6 @@ 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{
|
||||
@@ -717,49 +627,7 @@ var _ = Describe("Generator", func() {
|
||||
Expect(codeStr).To(ContainSubstring(`response.get("boolVal", False)`))
|
||||
})
|
||||
|
||||
It("should generate binary frame parsing for raw methods", func() {
|
||||
svc := Service{
|
||||
Name: "Stream",
|
||||
Permission: "stream",
|
||||
Interface: "StreamService",
|
||||
Methods: []Method{
|
||||
{
|
||||
Name: "GetStream",
|
||||
HasError: true,
|
||||
Raw: true,
|
||||
Params: []Param{NewParam("uri", "string")},
|
||||
Returns: []Param{
|
||||
NewParam("contentType", "string"),
|
||||
NewParam("data", "[]byte"),
|
||||
},
|
||||
Doc: "GetStream returns raw binary stream data.",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
code, err := GenerateClientPython(svc)
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
|
||||
codeStr := string(code)
|
||||
|
||||
// Should import Tuple and struct for raw methods
|
||||
Expect(codeStr).To(ContainSubstring("from typing import Any, Tuple"))
|
||||
Expect(codeStr).To(ContainSubstring("import struct"))
|
||||
|
||||
// Should return Tuple[str, bytes]
|
||||
Expect(codeStr).To(ContainSubstring("-> Tuple[str, bytes]:"))
|
||||
|
||||
// Should parse binary frame instead of JSON
|
||||
Expect(codeStr).To(ContainSubstring("response_bytes = response_mem.bytes()"))
|
||||
Expect(codeStr).To(ContainSubstring("response_bytes[0] == 0x01"))
|
||||
Expect(codeStr).To(ContainSubstring("struct.unpack"))
|
||||
Expect(codeStr).To(ContainSubstring("return content_type, data"))
|
||||
|
||||
// Should NOT use json.loads for response
|
||||
Expect(codeStr).NotTo(ContainSubstring("json.loads(extism.memory.string(response_mem))"))
|
||||
})
|
||||
|
||||
It("should not import Tuple or struct for non-raw services", func() {
|
||||
It("should not import base64 for non-byte services", func() {
|
||||
svc := Service{
|
||||
Name: "Test",
|
||||
Permission: "test",
|
||||
@@ -779,8 +647,37 @@ var _ = Describe("Generator", func() {
|
||||
|
||||
codeStr := string(code)
|
||||
|
||||
Expect(codeStr).NotTo(ContainSubstring("Tuple"))
|
||||
Expect(codeStr).NotTo(ContainSubstring("import struct"))
|
||||
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", ""))`))
|
||||
})
|
||||
})
|
||||
|
||||
@@ -939,46 +836,6 @@ 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() {
|
||||
@@ -1748,22 +1605,17 @@ var _ = Describe("Rust Generation", func() {
|
||||
Expect(codeStr).NotTo(ContainSubstring("Option<bool>"))
|
||||
})
|
||||
|
||||
It("should generate raw extern C import and binary frame parsing for raw methods", func() {
|
||||
It("should generate base64 serde for Vec<u8> fields", func() {
|
||||
svc := Service{
|
||||
Name: "Stream",
|
||||
Permission: "stream",
|
||||
Interface: "StreamService",
|
||||
Name: "Codec",
|
||||
Permission: "codec",
|
||||
Interface: "CodecService",
|
||||
Methods: []Method{
|
||||
{
|
||||
Name: "GetStream",
|
||||
Name: "Encode",
|
||||
HasError: true,
|
||||
Raw: true,
|
||||
Params: []Param{NewParam("uri", "string")},
|
||||
Returns: []Param{
|
||||
NewParam("contentType", "string"),
|
||||
NewParam("data", "[]byte"),
|
||||
},
|
||||
Doc: "GetStream returns raw binary stream data.",
|
||||
Params: []Param{NewParam("data", "[]byte")},
|
||||
Returns: []Param{NewParam("result", "[]byte")},
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -1773,24 +1625,36 @@ var _ = Describe("Rust Generation", func() {
|
||||
|
||||
codeStr := string(code)
|
||||
|
||||
// Should use extern "C" with wasm_import_module for raw methods, not #[host_fn] extern "ExtismHost"
|
||||
Expect(codeStr).To(ContainSubstring(`#[link(wasm_import_module = "extism:host/user")]`))
|
||||
Expect(codeStr).To(ContainSubstring(`extern "C"`))
|
||||
Expect(codeStr).To(ContainSubstring("fn stream_getstream(offset: u64) -> u64"))
|
||||
// Should generate base64_bytes serde module
|
||||
Expect(codeStr).To(ContainSubstring("mod base64_bytes"))
|
||||
Expect(codeStr).To(ContainSubstring("use base64::Engine as _"))
|
||||
|
||||
// Should NOT generate response type for raw methods
|
||||
Expect(codeStr).NotTo(ContainSubstring("StreamGetStreamResponse"))
|
||||
// Should add serde(with = "base64_bytes") on Vec<u8> fields
|
||||
Expect(codeStr).To(ContainSubstring(`#[serde(with = "base64_bytes")]`))
|
||||
})
|
||||
|
||||
// Should generate request type (request is still JSON)
|
||||
Expect(codeStr).To(ContainSubstring("struct StreamGetStreamRequest"))
|
||||
It("should not generate base64 module when no byte fields", func() {
|
||||
svc := Service{
|
||||
Name: "Test",
|
||||
Permission: "test",
|
||||
Interface: "TestService",
|
||||
Methods: []Method{
|
||||
{
|
||||
Name: "Call",
|
||||
HasError: true,
|
||||
Params: []Param{NewParam("uri", "string")},
|
||||
Returns: []Param{NewParam("response", "string")},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
// Should return Result<(String, Vec<u8>), Error>
|
||||
Expect(codeStr).To(ContainSubstring("Result<(String, Vec<u8>), Error>"))
|
||||
code, err := GenerateClientRust(svc)
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
|
||||
// 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"))
|
||||
codeStr := string(code)
|
||||
|
||||
Expect(codeStr).NotTo(ContainSubstring("mod base64_bytes"))
|
||||
Expect(codeStr).NotTo(ContainSubstring("use base64"))
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -761,7 +761,6 @@ func parseMethod(name string, funcType *ast.FuncType, annotation map[string]stri
|
||||
m := Method{
|
||||
Name: name,
|
||||
ExportName: annotation["name"],
|
||||
Raw: annotation["raw"] == "true",
|
||||
Doc: doc,
|
||||
}
|
||||
|
||||
@@ -800,13 +799,6 @@ 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
|
||||
}
|
||||
|
||||
|
||||
@@ -122,119 +122,6 @@ 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
|
||||
|
||||
|
||||
25
plugins/cmd/ndpgen/internal/templates/base64_bytes.rs.tmpl
Normal file
25
plugins/cmd/ndpgen/internal/templates/base64_bytes.rs.tmpl
Normal file
@@ -0,0 +1,25 @@
|
||||
{{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}}
|
||||
@@ -7,6 +7,7 @@ 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)]
|
||||
@@ -70,6 +71,9 @@ 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}}
|
||||
|
||||
@@ -8,9 +8,6 @@
|
||||
package {{.Package}}
|
||||
|
||||
import (
|
||||
{{- if .Service.HasRawMethods}}
|
||||
"encoding/binary"
|
||||
{{- end}}
|
||||
"encoding/json"
|
||||
{{- if .Service.HasErrors}}
|
||||
"errors"
|
||||
@@ -52,7 +49,7 @@ type {{requestType .}} struct {
|
||||
{{- end}}
|
||||
}
|
||||
{{- end}}
|
||||
{{- if and (not .IsErrorOnly) (not .Raw)}}
|
||||
{{- if not .IsErrorOnly}}
|
||||
|
||||
type {{responseType .}} struct {
|
||||
{{- range .Returns}}
|
||||
@@ -98,27 +95,7 @@ func {{$.Service.Name}}{{.Name}}({{range $i, $p := .Params}}{{if $i}}, {{end}}{{
|
||||
// Read the response from memory
|
||||
responseMem := pdk.FindMemory(responsePtr)
|
||||
responseBytes := responseMem.ReadBytes()
|
||||
{{- 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}}
|
||||
{{- if .IsErrorOnly}}
|
||||
|
||||
// Parse error-only response
|
||||
var response struct {
|
||||
|
||||
@@ -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{{- if .Service.HasRawMethods}}, Tuple{{end}}
|
||||
from typing import Any
|
||||
|
||||
import extism
|
||||
import json
|
||||
{{- if .Service.HasRawMethods}}
|
||||
import struct
|
||||
{{- if .Service.HasByteFields}}
|
||||
import base64
|
||||
{{- end}}
|
||||
|
||||
|
||||
@@ -32,7 +32,7 @@ def _{{exportName .}}(offset: int) -> int:
|
||||
{{- end}}
|
||||
{{- /* Generate dataclasses for multi-value returns */ -}}
|
||||
{{range .Service.Methods}}
|
||||
{{- if and .NeedsResultClass (not .Raw)}}
|
||||
{{- if .NeedsResultClass}}
|
||||
|
||||
|
||||
@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 .Raw}} -> Tuple[str, bytes]{{else 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 .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,11 +56,7 @@ def {{pythonFunc .}}({{range $i, $p := .Params}}{{if $i}}, {{end}}{{$p.PythonNam
|
||||
{{.PythonName}}: {{.PythonType}} parameter.
|
||||
{{- end}}
|
||||
{{- end}}
|
||||
{{- if .Raw}}
|
||||
|
||||
Returns:
|
||||
Tuple of (content_type, data) with the raw binary response.
|
||||
{{- else if .HasReturns}}
|
||||
{{- if .HasReturns}}
|
||||
|
||||
Returns:
|
||||
{{- if .NeedsResultClass}}
|
||||
@@ -76,7 +72,11 @@ 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,24 +86,6 @@ 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"):
|
||||
@@ -112,10 +94,17 @@ 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}}
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
|
||||
use extism_pdk::*;
|
||||
use serde::{Deserialize, Serialize};
|
||||
{{- if .Service.HasByteFields}}{{template "base64_bytes_module" .}}{{- end}}
|
||||
{{- /* Generate struct definitions */ -}}
|
||||
{{- range .Service.Structs}}
|
||||
{{if .Doc}}
|
||||
@@ -16,6 +17,9 @@ pub struct {{.Name}} {
|
||||
{{- range .Fields}}
|
||||
{{- if .NeedsDefault}}
|
||||
#[serde(default)]
|
||||
{{- end}}
|
||||
{{- if .IsByteSlice}}
|
||||
#[serde(with = "base64_bytes")]
|
||||
{{- end}}
|
||||
pub {{.RustName}}: {{fieldRustType .}},
|
||||
{{- end}}
|
||||
@@ -29,17 +33,22 @@ 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}}
|
||||
@@ -48,92 +57,16 @@ 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}}
|
||||
@@ -209,4 +142,3 @@ pub fn {{rustFunc .}}({{range $i, $p := .Params}}{{if $i}}, {{end}}{{$p.RustName
|
||||
}
|
||||
{{- end}}
|
||||
{{- end}}
|
||||
{{- end}}
|
||||
|
||||
@@ -4,9 +4,6 @@ package {{.Package}}
|
||||
|
||||
import (
|
||||
"context"
|
||||
{{- if .Service.HasRawMethods}}
|
||||
"encoding/binary"
|
||||
{{- end}}
|
||||
"encoding/json"
|
||||
|
||||
extism "github.com/extism/go-sdk"
|
||||
@@ -23,7 +20,6 @@ type {{requestType .}} struct {
|
||||
{{- end}}
|
||||
}
|
||||
{{- end}}
|
||||
{{- if not .Raw}}
|
||||
|
||||
// {{responseType .}} is the response type for {{$.Service.Name}}.{{.Name}}.
|
||||
type {{responseType .}} struct {
|
||||
@@ -34,7 +30,6 @@ type {{responseType .}} struct {
|
||||
Error string `json:"error,omitempty"`
|
||||
{{- end}}
|
||||
}
|
||||
{{- end}}
|
||||
{{end}}
|
||||
|
||||
// Register{{.Service.Name}}HostFunctions registers {{.Service.Name}} service host functions.
|
||||
@@ -56,48 +51,18 @@ 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 .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 .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 {
|
||||
@@ -162,16 +127,3 @@ 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}}
|
||||
|
||||
@@ -173,16 +173,6 @@ 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")
|
||||
@@ -191,7 +181,6 @@ 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.
|
||||
@@ -343,6 +332,52 @@ 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{
|
||||
|
||||
@@ -12,6 +12,7 @@ from typing import Any
|
||||
|
||||
import extism
|
||||
import json
|
||||
import base64
|
||||
|
||||
|
||||
class HostFunctionError(Exception):
|
||||
@@ -38,7 +39,7 @@ def codec_encode(data: bytes) -> bytes:
|
||||
HostFunctionError: If the host function returns an error.
|
||||
"""
|
||||
request = {
|
||||
"data": data,
|
||||
"data": base64.b64encode(data).decode("ascii"),
|
||||
}
|
||||
request_bytes = json.dumps(request).encode("utf-8")
|
||||
request_mem = extism.memory.alloc(request_bytes)
|
||||
@@ -49,4 +50,4 @@ def codec_encode(data: bytes) -> bytes:
|
||||
if response.get("error"):
|
||||
raise HostFunctionError(response["error"])
|
||||
|
||||
return response.get("result", b"")
|
||||
return base64.b64decode(response.get("result", ""))
|
||||
|
||||
@@ -5,10 +5,34 @@
|
||||
|
||||
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>,
|
||||
}
|
||||
|
||||
@@ -16,6 +40,7 @@ struct CodecEncodeRequest {
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct CodecEncodeResponse {
|
||||
#[serde(default)]
|
||||
#[serde(with = "base64_bytes")]
|
||||
result: Vec<u8>,
|
||||
#[serde(default)]
|
||||
error: Option<String>,
|
||||
|
||||
@@ -12,6 +12,7 @@ from typing import Any
|
||||
|
||||
import extism
|
||||
import json
|
||||
import base64
|
||||
|
||||
|
||||
class HostFunctionError(Exception):
|
||||
@@ -327,7 +328,7 @@ def comprehensive_byte_slice(data: bytes) -> bytes:
|
||||
HostFunctionError: If the host function returns an error.
|
||||
"""
|
||||
request = {
|
||||
"data": data,
|
||||
"data": base64.b64encode(data).decode("ascii"),
|
||||
}
|
||||
request_bytes = json.dumps(request).encode("utf-8")
|
||||
request_mem = extism.memory.alloc(request_bytes)
|
||||
@@ -338,4 +339,4 @@ def comprehensive_byte_slice(data: bytes) -> bytes:
|
||||
if response.get("error"):
|
||||
raise HostFunctionError(response["error"])
|
||||
|
||||
return response.get("result", b"")
|
||||
return base64.b64decode(response.get("result", ""))
|
||||
|
||||
@@ -5,6 +5,29 @@
|
||||
|
||||
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")]
|
||||
@@ -144,6 +167,7 @@ struct ComprehensiveMultipleReturnsResponse {
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct ComprehensiveByteSliceRequest {
|
||||
#[serde(with = "base64_bytes")]
|
||||
data: Vec<u8>,
|
||||
}
|
||||
|
||||
@@ -151,6 +175,7 @@ struct ComprehensiveByteSliceRequest {
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct ComprehensiveByteSliceResponse {
|
||||
#[serde(default)]
|
||||
#[serde(with = "base64_bytes")]
|
||||
result: Vec<u8>,
|
||||
#[serde(default)]
|
||||
error: Option<String>,
|
||||
|
||||
@@ -1,66 +0,0 @@
|
||||
// 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
|
||||
}
|
||||
@@ -1,63 +0,0 @@
|
||||
# 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
|
||||
@@ -1,73 +0,0 @@
|
||||
// Code generated by ndpgen. DO NOT EDIT.
|
||||
//
|
||||
// This file contains client wrappers for the Stream host service.
|
||||
// It is intended for use in Navidrome plugins built with extism-pdk.
|
||||
|
||||
use extism_pdk::*;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct StreamGetStreamRequest {
|
||||
uri: String,
|
||||
}
|
||||
|
||||
#[host_fn]
|
||||
extern "ExtismHost" {
|
||||
}
|
||||
|
||||
#[link(wasm_import_module = "extism:host/user")]
|
||||
extern "C" {
|
||||
fn stream_getstream(offset: u64) -> u64;
|
||||
}
|
||||
|
||||
/// GetStream returns raw binary stream data with content type.
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `uri` - String parameter.
|
||||
///
|
||||
/// # Returns
|
||||
/// A tuple of (content_type, data) with the raw binary response.
|
||||
///
|
||||
/// # Errors
|
||||
/// Returns an error if the host function call fails.
|
||||
pub fn get_stream(uri: &str) -> Result<(String, Vec<u8>), Error> {
|
||||
let req = StreamGetStreamRequest {
|
||||
uri: uri.to_owned(),
|
||||
};
|
||||
let input_bytes = serde_json::to_vec(&req).map_err(|e| Error::msg(e.to_string()))?;
|
||||
let input_mem = Memory::from_bytes(&input_bytes).map_err(|e| Error::msg(e.to_string()))?;
|
||||
|
||||
let response_offset = unsafe { stream_getstream(input_mem.offset()) };
|
||||
|
||||
let response_mem = Memory::find(response_offset)
|
||||
.ok_or_else(|| Error::msg("empty response from host"))?;
|
||||
let response_bytes = response_mem.to_vec();
|
||||
|
||||
if response_bytes.is_empty() {
|
||||
return Err(Error::msg("empty response from host"));
|
||||
}
|
||||
if response_bytes[0] == 0x01 {
|
||||
let msg = String::from_utf8_lossy(&response_bytes[1..]).to_string();
|
||||
return Err(Error::msg(msg));
|
||||
}
|
||||
if response_bytes[0] != 0x00 {
|
||||
return Err(Error::msg("unknown response status"));
|
||||
}
|
||||
if response_bytes.len() < 5 {
|
||||
return Err(Error::msg("malformed raw response: incomplete header"));
|
||||
}
|
||||
let ct_len = u32::from_be_bytes([
|
||||
response_bytes[1],
|
||||
response_bytes[2],
|
||||
response_bytes[3],
|
||||
response_bytes[4],
|
||||
]) as usize;
|
||||
if ct_len > response_bytes.len() - 5 {
|
||||
return Err(Error::msg("malformed raw response: content-type overflow"));
|
||||
}
|
||||
let ct_end = 5 + ct_len;
|
||||
let content_type = String::from_utf8_lossy(&response_bytes[5..ct_end]).to_string();
|
||||
let data = response_bytes[ct_end..].to_vec();
|
||||
Ok((content_type, data))
|
||||
}
|
||||
10
plugins/cmd/ndpgen/testdata/raw_service.go.txt
vendored
10
plugins/cmd/ndpgen/testdata/raw_service.go.txt
vendored
@@ -1,10 +0,0 @@
|
||||
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)
|
||||
}
|
||||
@@ -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.
|
||||
// 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
|
||||
// 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
|
||||
CallRaw(ctx context.Context, uri string) (contentType string, data []byte, err error)
|
||||
}
|
||||
|
||||
@@ -4,7 +4,6 @@ package host
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/binary"
|
||||
"encoding/json"
|
||||
|
||||
extism "github.com/extism/go-sdk"
|
||||
@@ -26,6 +25,13 @@ 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 {
|
||||
@@ -76,37 +82,28 @@ func newSubsonicAPICallRawHostFunction(service SubsonicAPIService) extism.HostFu
|
||||
// Read JSON request from plugin memory
|
||||
reqBytes, err := p.ReadBytes(stack[0])
|
||||
if err != nil {
|
||||
subsonicapiWriteRawError(p, stack, err)
|
||||
subsonicapiWriteError(p, stack, err)
|
||||
return
|
||||
}
|
||||
var req SubsonicAPICallRawRequest
|
||||
if err := json.Unmarshal(reqBytes, &req); err != nil {
|
||||
subsonicapiWriteRawError(p, stack, err)
|
||||
subsonicapiWriteError(p, stack, err)
|
||||
return
|
||||
}
|
||||
|
||||
// Call the service method
|
||||
contenttype, data, svcErr := service.CallRaw(ctx, req.Uri)
|
||||
if svcErr != nil {
|
||||
subsonicapiWriteRawError(p, stack, svcErr)
|
||||
subsonicapiWriteError(p, stack, svcErr)
|
||||
return
|
||||
}
|
||||
|
||||
// Write binary-framed response to plugin memory:
|
||||
// [0x00][4-byte content-type length (big-endian)][content-type string][raw data]
|
||||
ctBytes := []byte(contenttype)
|
||||
frame := make([]byte, 1+4+len(ctBytes)+len(data))
|
||||
frame[0] = 0x00 // success
|
||||
binary.BigEndian.PutUint32(frame[1:5], uint32(len(ctBytes)))
|
||||
copy(frame[5:5+len(ctBytes)], ctBytes)
|
||||
copy(frame[5+len(ctBytes):], data)
|
||||
|
||||
respPtr, err := p.WriteBytes(frame)
|
||||
if err != nil {
|
||||
stack[0] = 0
|
||||
return
|
||||
// Write JSON response to plugin memory
|
||||
resp := SubsonicAPICallRawResponse{
|
||||
ContentType: contenttype,
|
||||
Data: data,
|
||||
}
|
||||
stack[0] = respPtr
|
||||
subsonicapiWriteResponse(p, stack, resp)
|
||||
},
|
||||
[]extism.ValueType{extism.ValueTypePTR},
|
||||
[]extism.ValueType{extism.ValueTypePTR},
|
||||
@@ -137,14 +134,3 @@ 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
|
||||
}
|
||||
|
||||
@@ -428,10 +428,11 @@ 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 bool) error {
|
||||
func (m *Manager) UpdatePluginLibraries(ctx context.Context, id, librariesJSON string, allLibraries, allowWriteAccess bool) error {
|
||||
return m.updatePluginSettings(ctx, id, func(p *model.Plugin) {
|
||||
p.Libraries = librariesJSON
|
||||
p.AllLibraries = allLibraries
|
||||
p.AllowWriteAccess = allowWriteAccess
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -226,6 +226,8 @@ 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")
|
||||
}
|
||||
@@ -283,27 +285,13 @@ 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(m.ctx)
|
||||
adminCtx := adminContext(ctx)
|
||||
libraries, err := m.ds.Library(adminCtx).GetAll()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get libraries for filesystem access: %w", err)
|
||||
}
|
||||
|
||||
// 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))
|
||||
}
|
||||
}
|
||||
allowedPaths := buildAllowedPaths(ctx, libraries, allowedLibraries, p.AllLibraries, p.AllowWriteAccess)
|
||||
pluginManifest.AllowedPaths = allowedPaths
|
||||
}
|
||||
|
||||
@@ -339,7 +327,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(m.ctx, "Enabling experimental threads support", "plugin", p.ID)
|
||||
log.Debug(ctx, "Enabling experimental threads support")
|
||||
}
|
||||
|
||||
extismConfig := extism.PluginConfig{
|
||||
@@ -347,24 +335,24 @@ func (m *Manager) loadPluginWithConfig(p *model.Plugin) error {
|
||||
RuntimeConfig: runtimeConfig,
|
||||
EnableHttpResponseHeaders: true,
|
||||
}
|
||||
compiled, err := extism.NewCompiledPlugin(m.ctx, pluginManifest, extismConfig, hostFunctions)
|
||||
compiled, err := extism.NewCompiledPlugin(ctx, pluginManifest, extismConfig, hostFunctions)
|
||||
if err != nil {
|
||||
return fmt.Errorf("compiling plugin: %w", err)
|
||||
}
|
||||
|
||||
// Create instance to detect capabilities
|
||||
instance, err := compiled.Instance(m.ctx, extism.PluginInstanceConfig{})
|
||||
instance, err := compiled.Instance(ctx, extism.PluginInstanceConfig{})
|
||||
if err != nil {
|
||||
compiled.Close(m.ctx)
|
||||
compiled.Close(ctx)
|
||||
return fmt.Errorf("creating instance: %w", err)
|
||||
}
|
||||
instance.SetLogger(extismLogger(p.ID))
|
||||
capabilities := detectCapabilities(instance)
|
||||
instance.Close(m.ctx)
|
||||
instance.Close(ctx)
|
||||
|
||||
// Validate manifest against detected capabilities
|
||||
if err := ValidateWithCapabilities(pkg.Manifest, capabilities); err != nil {
|
||||
compiled.Close(m.ctx)
|
||||
compiled.Close(ctx)
|
||||
return fmt.Errorf("manifest validation: %w", err)
|
||||
}
|
||||
|
||||
@@ -383,7 +371,7 @@ func (m *Manager) loadPluginWithConfig(p *model.Plugin) error {
|
||||
m.mu.Unlock()
|
||||
|
||||
// Call plugin init function
|
||||
callPluginInit(m.ctx, m.plugins[p.ID])
|
||||
callPluginInit(ctx, m.plugins[p.ID])
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -414,3 +402,29 @@ 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
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
package plugins
|
||||
|
||||
import (
|
||||
"github.com/navidrome/navidrome/model"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
@@ -58,3 +59,66 @@ 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())
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -8,7 +8,6 @@
|
||||
package host
|
||||
|
||||
import (
|
||||
"encoding/binary"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
|
||||
@@ -38,6 +37,12 @@ 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.
|
||||
//
|
||||
@@ -78,8 +83,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.
|
||||
// Optimized for binary endpoints like getCoverArt and stream that return
|
||||
// non-JSON data. The response is returned as raw bytes without JSON encoding overhead.
|
||||
// Designed for binary endpoints like getCoverArt and stream that return
|
||||
// non-JSON data. The data is base64-encoded over JSON on the wire.
|
||||
func SubsonicAPICallRaw(uri string) (string, []byte, error) {
|
||||
// Marshal request to JSON
|
||||
req := subsonicAPICallRawRequest{
|
||||
@@ -99,22 +104,16 @@ func SubsonicAPICallRaw(uri string) (string, []byte, error) {
|
||||
responseMem := pdk.FindMemory(responsePtr)
|
||||
responseBytes := responseMem.ReadBytes()
|
||||
|
||||
// Parse binary-framed response
|
||||
if len(responseBytes) == 0 {
|
||||
return "", nil, errors.New("empty response from host")
|
||||
// Parse the response
|
||||
var response subsonicAPICallRawResponse
|
||||
if err := json.Unmarshal(responseBytes, &response); err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
if responseBytes[0] == 0x01 { // error
|
||||
return "", nil, errors.New(string(responseBytes[1:]))
|
||||
|
||||
// Convert Error field to Go error
|
||||
if response.Error != "" {
|
||||
return "", nil, errors.New(response.Error)
|
||||
}
|
||||
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
|
||||
|
||||
return response.ContentType, response.Data, nil
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
// Optimized for binary endpoints like getCoverArt and stream that return
|
||||
// non-JSON data. The response is returned as raw bytes without JSON encoding overhead.
|
||||
// Designed for binary endpoints like getCoverArt and stream that return
|
||||
// non-JSON data. The data is base64-encoded over JSON on the wire.
|
||||
func SubsonicAPICallRaw(uri string) (string, []byte, error) {
|
||||
return SubsonicAPIMock.CallRaw(uri)
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ from typing import Any
|
||||
|
||||
import extism
|
||||
import json
|
||||
import base64
|
||||
|
||||
|
||||
class HostFunctionError(Exception):
|
||||
@@ -337,7 +338,7 @@ Returns an error if the operation fails.
|
||||
"""
|
||||
request = {
|
||||
"key": key,
|
||||
"value": value,
|
||||
"value": base64.b64encode(value).decode("ascii"),
|
||||
"ttlSeconds": ttl_seconds,
|
||||
}
|
||||
request_bytes = json.dumps(request).encode("utf-8")
|
||||
@@ -382,7 +383,7 @@ or the stored value is not a byte slice, exists will be false.
|
||||
raise HostFunctionError(response["error"])
|
||||
|
||||
return CacheGetBytesResult(
|
||||
value=response.get("value", b""),
|
||||
value=base64.b64decode(response.get("value", "")),
|
||||
exists=response.get("exists", False),
|
||||
)
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@ from typing import Any
|
||||
|
||||
import extism
|
||||
import json
|
||||
import base64
|
||||
|
||||
|
||||
class HostFunctionError(Exception):
|
||||
|
||||
@@ -12,6 +12,7 @@ from typing import Any
|
||||
|
||||
import extism
|
||||
import json
|
||||
import base64
|
||||
|
||||
|
||||
class HostFunctionError(Exception):
|
||||
@@ -80,7 +81,7 @@ Returns an error if the storage limit would be exceeded or the operation fails.
|
||||
"""
|
||||
request = {
|
||||
"key": key,
|
||||
"value": value,
|
||||
"value": base64.b64encode(value).decode("ascii"),
|
||||
}
|
||||
request_bytes = json.dumps(request).encode("utf-8")
|
||||
request_mem = extism.memory.alloc(request_bytes)
|
||||
@@ -123,7 +124,7 @@ Returns the value and whether the key exists.
|
||||
raise HostFunctionError(response["error"])
|
||||
|
||||
return KVStoreGetResult(
|
||||
value=response.get("value", b""),
|
||||
value=base64.b64decode(response.get("value", "")),
|
||||
exists=response.get("exists", False),
|
||||
)
|
||||
|
||||
|
||||
@@ -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, Tuple
|
||||
from typing import Any
|
||||
|
||||
import extism
|
||||
import json
|
||||
import struct
|
||||
import base64
|
||||
|
||||
|
||||
class HostFunctionError(Exception):
|
||||
@@ -32,6 +32,13 @@ 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.
|
||||
|
||||
@@ -62,16 +69,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) -> Tuple[str, bytes]:
|
||||
def subsonicapi_call_raw(uri: str) -> SubsonicAPICallRawResult:
|
||||
"""CallRaw executes a Subsonic API request and returns the raw binary response.
|
||||
Optimized for binary endpoints like getCoverArt and stream that return
|
||||
non-JSON data. The response is returned as raw bytes without JSON encoding overhead.
|
||||
Designed for binary endpoints like getCoverArt and stream that return
|
||||
non-JSON data. The data is base64-encoded over JSON on the wire.
|
||||
|
||||
Args:
|
||||
uri: str parameter.
|
||||
|
||||
Returns:
|
||||
Tuple of (content_type, data) with the raw binary response.
|
||||
SubsonicAPICallRawResult containing content_type, data,.
|
||||
|
||||
Raises:
|
||||
HostFunctionError: If the host function returns an error.
|
||||
@@ -83,19 +90,12 @@ non-JSON data. The response is returned as raw bytes without JSON encoding overh
|
||||
request_mem = extism.memory.alloc(request_bytes)
|
||||
response_offset = _subsonicapi_callraw(request_mem.offset)
|
||||
response_mem = extism.memory.find(response_offset)
|
||||
response_bytes = response_mem.bytes()
|
||||
response = json.loads(extism.memory.string(response_mem))
|
||||
|
||||
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
|
||||
if response.get("error"):
|
||||
raise HostFunctionError(response["error"])
|
||||
|
||||
return SubsonicAPICallRawResult(
|
||||
content_type=response.get("contentType", ""),
|
||||
data=base64.b64decode(response.get("data", "")),
|
||||
)
|
||||
|
||||
@@ -12,6 +12,7 @@ from typing import Any
|
||||
|
||||
import extism
|
||||
import json
|
||||
import base64
|
||||
|
||||
|
||||
class HostFunctionError(Exception):
|
||||
@@ -134,7 +135,7 @@ Returns an error if the connection is not found or if sending fails.
|
||||
"""
|
||||
request = {
|
||||
"connectionId": connection_id,
|
||||
"data": data,
|
||||
"data": base64.b64encode(data).decode("ascii"),
|
||||
}
|
||||
request_bytes = json.dumps(request).encode("utf-8")
|
||||
request_mem = extism.memory.alloc(request_bytes)
|
||||
|
||||
@@ -11,6 +11,7 @@ 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"
|
||||
|
||||
@@ -11,6 +11,7 @@ readme = "README.md"
|
||||
crate-type = ["rlib"]
|
||||
|
||||
[dependencies]
|
||||
base64 = "0.22"
|
||||
extism-pdk = "1.2"
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
serde_json = "1.0"
|
||||
|
||||
@@ -5,6 +5,29 @@
|
||||
|
||||
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")]
|
||||
@@ -106,6 +129,7 @@ struct CacheGetFloatResponse {
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct CacheSetBytesRequest {
|
||||
key: String,
|
||||
#[serde(with = "base64_bytes")]
|
||||
value: Vec<u8>,
|
||||
ttl_seconds: i64,
|
||||
}
|
||||
@@ -127,6 +151,7 @@ struct CacheGetBytesRequest {
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct CacheGetBytesResponse {
|
||||
#[serde(default)]
|
||||
#[serde(with = "base64_bytes")]
|
||||
value: Vec<u8>,
|
||||
#[serde(default)]
|
||||
exists: bool,
|
||||
|
||||
@@ -5,6 +5,29 @@
|
||||
|
||||
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)]
|
||||
@@ -15,6 +38,7 @@ 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,
|
||||
@@ -28,6 +52,7 @@ pub struct HTTPResponse {
|
||||
#[serde(default)]
|
||||
pub headers: std::collections::HashMap<String, String>,
|
||||
#[serde(default)]
|
||||
#[serde(with = "base64_bytes")]
|
||||
pub body: Vec<u8>,
|
||||
}
|
||||
|
||||
|
||||
@@ -5,11 +5,35 @@
|
||||
|
||||
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>,
|
||||
}
|
||||
|
||||
@@ -30,6 +54,7 @@ struct KVStoreGetRequest {
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct KVStoreGetResponse {
|
||||
#[serde(default)]
|
||||
#[serde(with = "base64_bytes")]
|
||||
value: Vec<u8>,
|
||||
#[serde(default)]
|
||||
exists: bool,
|
||||
|
||||
@@ -5,6 +5,29 @@
|
||||
|
||||
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")]
|
||||
@@ -27,14 +50,22 @@ 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>;
|
||||
}
|
||||
|
||||
#[link(wasm_import_module = "extism:host/user")]
|
||||
extern "C" {
|
||||
fn subsonicapi_callraw(offset: u64) -> u64;
|
||||
fn subsonicapi_callraw(input: Json<SubsonicAPICallRawRequest>) -> Json<SubsonicAPICallRawResponse>;
|
||||
}
|
||||
|
||||
/// Call executes a Subsonic API request and returns the JSON response.
|
||||
@@ -65,54 +96,27 @@ pub fn call(uri: &str) -> Result<String, Error> {
|
||||
}
|
||||
|
||||
/// CallRaw executes a Subsonic API request and returns the raw binary response.
|
||||
/// Optimized for binary endpoints like getCoverArt and stream that return
|
||||
/// non-JSON data. The response is returned as raw bytes without JSON encoding overhead.
|
||||
/// Designed for binary endpoints like getCoverArt and stream that return
|
||||
/// non-JSON data. The data is base64-encoded over JSON on the wire.
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `uri` - String parameter.
|
||||
///
|
||||
/// # Returns
|
||||
/// A tuple of (content_type, data) with the raw binary response.
|
||||
/// A tuple of (content_type, data).
|
||||
///
|
||||
/// # Errors
|
||||
/// Returns an error if the host function call fails.
|
||||
pub fn call_raw(uri: &str) -> Result<(String, Vec<u8>), Error> {
|
||||
let req = SubsonicAPICallRawRequest {
|
||||
uri: uri.to_owned(),
|
||||
let response = unsafe {
|
||||
subsonicapi_callraw(Json(SubsonicAPICallRawRequest {
|
||||
uri: uri.to_owned(),
|
||||
}))?
|
||||
};
|
||||
let input_bytes = serde_json::to_vec(&req).map_err(|e| Error::msg(e.to_string()))?;
|
||||
let input_mem = Memory::from_bytes(&input_bytes).map_err(|e| Error::msg(e.to_string()))?;
|
||||
|
||||
let response_offset = unsafe { subsonicapi_callraw(input_mem.offset()) };
|
||||
if let Some(err) = response.0.error {
|
||||
return Err(Error::msg(err));
|
||||
}
|
||||
|
||||
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))
|
||||
Ok((response.0.content_type, response.0.data))
|
||||
}
|
||||
|
||||
@@ -5,6 +5,29 @@
|
||||
|
||||
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")]
|
||||
@@ -41,6 +64,7 @@ struct WebSocketSendTextResponse {
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct WebSocketSendBinaryRequest {
|
||||
connection_id: String,
|
||||
#[serde(with = "base64_bytes")]
|
||||
data: Vec<u8>,
|
||||
}
|
||||
|
||||
|
||||
@@ -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": "Натиснете Enter, за да създадете нов плейлист",
|
||||
"removeFromSelection": "Премахване от селекцията"
|
||||
"saveQueue": "",
|
||||
"searchOrCreate": "",
|
||||
"pressEnterToCreate": "",
|
||||
"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": "ID номер",
|
||||
"name": "Име",
|
||||
"description": "Описание",
|
||||
"version": "Версия",
|
||||
"author": "Автор",
|
||||
"website": "Уебсайт",
|
||||
"permissions": "Разрешения",
|
||||
"enabled": "Активирано",
|
||||
"status": "Статус",
|
||||
"path": "Път",
|
||||
"lastError": "Грешка",
|
||||
"hasError": "Грешка",
|
||||
"updatedAt": "Актуализирано",
|
||||
"createdAt": "Инсталирано",
|
||||
"configKey": "Ключ",
|
||||
"configValue": "Стойност",
|
||||
"allUsers": "Разрешаване на всички потребители",
|
||||
"selectedUsers": "Избрани потребители",
|
||||
"allLibraries": "Разрешаване на всички библиотеки",
|
||||
"selectedLibraries": "Избрани библиотеки"
|
||||
"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": "Конфигурацията трябва да е валиден JSON"
|
||||
"invalidJson": ""
|
||||
},
|
||||
"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": "Всички библиотеки (%{count})",
|
||||
"multipleLibraries": "%{selected} от %{total} библиотеки",
|
||||
"selectLibraries": "Изберете библиотеки",
|
||||
"none": "Няма"
|
||||
"allLibraries": "",
|
||||
"multipleLibraries": "",
|
||||
"selectLibraries": "",
|
||||
"none": ""
|
||||
}
|
||||
},
|
||||
"player": {
|
||||
@@ -655,7 +655,7 @@
|
||||
"homepage": "Начална страница",
|
||||
"source": "Програмен код",
|
||||
"featureRequests": "Заявете функционалност",
|
||||
"lastInsightsCollection": "Последна колекция от анализи",
|
||||
"lastInsightsCollection": "",
|
||||
"insights": {
|
||||
"disabled": "Деактивиран",
|
||||
"waiting": "Изчакване"
|
||||
@@ -669,13 +669,12 @@
|
||||
"configName": "Име на конфигурация",
|
||||
"environmentVariable": "Променлива на средата",
|
||||
"currentValue": "Текуща стойност",
|
||||
"configurationFile": "Конфигурационен файл",
|
||||
"configurationFile": "",
|
||||
"exportToml": "Експортиране на конфигурация (TOML)",
|
||||
"exportSuccess": "Конфигурация, експортирана в клипборда във формат TOML",
|
||||
"exportFailed": "Неуспешно копиране на конфигурация",
|
||||
"devFlagsHeader": "Флагове за разработка (подлежащи на промяна/премахване)",
|
||||
"devFlagsComment": "Това са експериментални настройки и е възможно да бъдат премахнати в бъдещи версии.",
|
||||
"downloadToml": "Изтегляне на конфигурация (TOML)"
|
||||
"devFlagsHeader": "",
|
||||
"devFlagsComment": ""
|
||||
}
|
||||
},
|
||||
"activity": {
|
||||
@@ -688,7 +687,7 @@
|
||||
"scanType": "Последно сканиране",
|
||||
"status": "Грешка при сканиране",
|
||||
"elapsedTime": "Изминало време",
|
||||
"selectiveScan": "Селективен"
|
||||
"selectiveScan": ""
|
||||
},
|
||||
"help": {
|
||||
"title": "Бързи клавиши на Navidrome",
|
||||
@@ -705,8 +704,8 @@
|
||||
}
|
||||
},
|
||||
"nowPlaying": {
|
||||
"title": "Сега свири",
|
||||
"empty": "Нищо не се възпроизвежда",
|
||||
"minutesAgo": "преди %{smart_count} минута |||| преди %{smart_count} минути"
|
||||
"title": "",
|
||||
"empty": "",
|
||||
"minutesAgo": ""
|
||||
}
|
||||
}
|
||||
@@ -674,8 +674,7 @@
|
||||
"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",
|
||||
"downloadToml": "Descarrega la configuració (TOML)"
|
||||
"devFlagsComment": "Aquests paràmetres són experimentals i és possible que s'eliminin en versions futures"
|
||||
}
|
||||
},
|
||||
"activity": {
|
||||
|
||||
@@ -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": "Download konfigurationen (TOML)"
|
||||
"downloadToml": ""
|
||||
}
|
||||
},
|
||||
"activity": {
|
||||
|
||||
@@ -674,8 +674,7 @@
|
||||
"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",
|
||||
"downloadToml": "Konfiguration Herunterladen (TOML)"
|
||||
"devFlagsComment": "Experimentelle Einstellungen, die eventuell in Zukunft entfernt oder geändert werden"
|
||||
}
|
||||
},
|
||||
"activity": {
|
||||
|
||||
@@ -674,8 +674,7 @@
|
||||
"exportSuccess": "Η διαμόρφωση εξήχθη στο πρόχειρο σε μορφή TOML",
|
||||
"exportFailed": "Η αντιγραφή της διαμόρφωσης απέτυχε",
|
||||
"devFlagsHeader": "Σημαίες Ανάπτυξης (υπόκειται σε αλλαγές / αφαίρεση)",
|
||||
"devFlagsComment": "Αυτές είναι πειραματικές ρυθμίσεις και ενδέχεται να καταργηθούν σε μελλοντικές εκδόσεις",
|
||||
"downloadToml": "Λήψη διαμόρφωσης (TOML)"
|
||||
"devFlagsComment": "Αυτές είναι πειραματικές ρυθμίσεις και ενδέχεται να καταργηθούν σε μελλοντικές εκδόσεις"
|
||||
}
|
||||
},
|
||||
"activity": {
|
||||
|
||||
@@ -674,8 +674,7 @@
|
||||
"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",
|
||||
"downloadToml": "Descargar la configuración (TOML)"
|
||||
"devFlagsComment": "Estas son configuraciones experimentales y pueden eliminarse en versiones futuras"
|
||||
}
|
||||
},
|
||||
"activity": {
|
||||
|
||||
@@ -674,8 +674,7 @@
|
||||
"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",
|
||||
"downloadToml": "Lataa määritykset (TOML)"
|
||||
"devFlagsComment": "Nämä ovat kokeellisia asetuksia ja ne voidaan poistaa tulevissa versioissa"
|
||||
}
|
||||
},
|
||||
"activity": {
|
||||
|
||||
@@ -674,8 +674,7 @@
|
||||
"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",
|
||||
"downloadToml": "Télécharger la configuration (TOML)"
|
||||
"devFlagsComment": "Ces paramètres sont expérimentaux et peuvent être amenés à changer dans le futur"
|
||||
}
|
||||
},
|
||||
"activity": {
|
||||
|
||||
@@ -674,8 +674,7 @@
|
||||
"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",
|
||||
"downloadToml": "Descargar configuración (TOML)"
|
||||
"devFlagsComment": "Son axustes experimentais e poden retirarse en futuras versións"
|
||||
}
|
||||
},
|
||||
"activity": {
|
||||
|
||||
@@ -353,7 +353,8 @@
|
||||
"allUsers": "Permitir todos os usuários",
|
||||
"selectedUsers": "Usuários selecionados",
|
||||
"allLibraries": "Permitir todas as bibliotecas",
|
||||
"selectedLibraries": "Bibliotecas selecionadas"
|
||||
"selectedLibraries": "Bibliotecas selecionadas",
|
||||
"allowWriteAccess": "Permitir acesso de escrita"
|
||||
},
|
||||
"sections": {
|
||||
"status": "Status",
|
||||
@@ -396,6 +397,7 @@
|
||||
"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."
|
||||
|
||||
@@ -674,8 +674,7 @@
|
||||
"exportSuccess": "Конфигурация экспортирована в буфер обмена в формате TOML",
|
||||
"exportFailed": "Не удалось скопировать конфигурацию",
|
||||
"devFlagsHeader": "Флаги разработки (могут быть изменены/удалены)",
|
||||
"devFlagsComment": "Это экспериментальные настройки, которые могут быть удалены в будущих версиях.",
|
||||
"downloadToml": "Скачать конфигурацию (TOML)"
|
||||
"devFlagsComment": "Это экспериментальные настройки, которые могут быть удалены в будущих версиях."
|
||||
}
|
||||
},
|
||||
"activity": {
|
||||
|
||||
@@ -48,7 +48,7 @@
|
||||
"playNext": "Naslednji",
|
||||
"info": "Več informacij",
|
||||
"showInPlaylist": "Prikaži na seznamu predvajanja",
|
||||
"instantMix": "Instant Mix"
|
||||
"instantMix": ""
|
||||
}
|
||||
},
|
||||
"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": "Validacija konfiguracije neuspešna:",
|
||||
"schemaRenderError": "Konfiguracijskega obrazca ni mogoče upodobiti. Shema vtičnika je morda neveljavna."
|
||||
"configValidationError": "",
|
||||
"schemaRenderError": ""
|
||||
},
|
||||
"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": "Nalaganje Instant Mix..."
|
||||
"startingInstantMix": ""
|
||||
},
|
||||
"menu": {
|
||||
"library": "Knjižnica",
|
||||
@@ -674,8 +674,7 @@
|
||||
"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",
|
||||
"downloadToml": "Naloži konfiguracijo (TOML)"
|
||||
"devFlagsComment": "To so eksperimentalne nastavitve in bodo morda odstranjene v prihodnjih različicah"
|
||||
}
|
||||
},
|
||||
"activity": {
|
||||
|
||||
@@ -674,8 +674,7 @@
|
||||
"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",
|
||||
"downloadToml": "Ladda ner konfiguration (TOML)"
|
||||
"devFlagsComment": "Dessa inställningar är experimentella och kan tas bort i framtida versioner"
|
||||
}
|
||||
},
|
||||
"activity": {
|
||||
|
||||
@@ -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,8 +674,7 @@
|
||||
"exportSuccess": "นำออกการตั้งค่าไปยังคลิปบอร์ดในรูปแบบ TOML แล้ว",
|
||||
"exportFailed": "คัดลอกการตั้งค่าล้มเหลว",
|
||||
"devFlagsHeader": "ปักธงการพัฒนา (อาจมีการเปลี่ยน/เอาออก)",
|
||||
"devFlagsComment": "การตั้งค่านี้อยู่ในช่วงทดลองและอาจจะมีการเอาออกในเวอร์ชั่นหลัง",
|
||||
"downloadToml": "ดาวน์โหลดการตั้งค่า (TOML)"
|
||||
"devFlagsComment": "การตั้งค่านี้อยู่ในช่วงทดลองและอาจจะมีการเอาออกในเวอร์ชั่นหลัง"
|
||||
}
|
||||
},
|
||||
"activity": {
|
||||
|
||||
@@ -10,14 +10,19 @@
|
||||
"playCount": "播放次數",
|
||||
"title": "標題",
|
||||
"artist": "藝人",
|
||||
"composer": "作曲者",
|
||||
"album": "專輯",
|
||||
"path": "檔案路徑",
|
||||
"libraryName": "媒體庫",
|
||||
"genre": "曲風",
|
||||
"compilation": "合輯",
|
||||
"year": "發行年份",
|
||||
"size": "檔案大小",
|
||||
"updatedAt": "更新於",
|
||||
"bitRate": "位元率",
|
||||
"bitDepth": "位元深度",
|
||||
"sampleRate": "取樣率",
|
||||
"channels": "聲道",
|
||||
"discSubtitle": "光碟副標題",
|
||||
"starred": "收藏",
|
||||
"comment": "註解",
|
||||
@@ -25,7 +30,6 @@
|
||||
"quality": "品質",
|
||||
"bpm": "BPM",
|
||||
"playDate": "上次播放",
|
||||
"channels": "聲道",
|
||||
"createdAt": "建立於",
|
||||
"grouping": "分組",
|
||||
"mood": "情緒",
|
||||
@@ -33,21 +37,17 @@
|
||||
"tags": "額外標籤",
|
||||
"mappedTags": "分類後標籤",
|
||||
"rawTags": "原始標籤",
|
||||
"bitDepth": "位元深度",
|
||||
"sampleRate": "取樣率",
|
||||
"missing": "遺失",
|
||||
"libraryName": "媒體庫",
|
||||
"composer": "作曲者"
|
||||
"missing": "遺失"
|
||||
},
|
||||
"actions": {
|
||||
"addToQueue": "加入至播放佇列",
|
||||
"playNow": "立即播放",
|
||||
"addToPlaylist": "加入至播放清單",
|
||||
"showInPlaylist": "在播放清單中顯示",
|
||||
"shuffleAll": "全部隨機播放",
|
||||
"download": "下載",
|
||||
"playNext": "下一首播放",
|
||||
"info": "取得資訊",
|
||||
"showInPlaylist": "在播放清單中顯示",
|
||||
"instantMix": "即時混音"
|
||||
}
|
||||
},
|
||||
@@ -59,38 +59,38 @@
|
||||
"duration": "長度",
|
||||
"songCount": "歌曲數",
|
||||
"playCount": "播放次數",
|
||||
"size": "檔案大小",
|
||||
"name": "名稱",
|
||||
"libraryName": "媒體庫",
|
||||
"genre": "曲風",
|
||||
"compilation": "合輯",
|
||||
"year": "發行年份",
|
||||
"updatedAt": "更新於",
|
||||
"comment": "註解",
|
||||
"rating": "評分",
|
||||
"createdAt": "建立於",
|
||||
"size": "檔案大小",
|
||||
"date": "錄製日期",
|
||||
"originalDate": "原始日期",
|
||||
"releaseDate": "發行日期",
|
||||
"releases": "發行",
|
||||
"released": "已發行",
|
||||
"updatedAt": "更新於",
|
||||
"comment": "註解",
|
||||
"rating": "評分",
|
||||
"createdAt": "建立於",
|
||||
"recordLabel": "唱片公司",
|
||||
"catalogNum": "目錄編號",
|
||||
"releaseType": "發行類型",
|
||||
"grouping": "分組",
|
||||
"media": "媒體類型",
|
||||
"mood": "情緒",
|
||||
"date": "錄製日期",
|
||||
"missing": "遺失",
|
||||
"libraryName": "媒體庫"
|
||||
"missing": "遺失"
|
||||
},
|
||||
"actions": {
|
||||
"playAll": "播放全部",
|
||||
"playNext": "下一首播放",
|
||||
"addToQueue": "加入至播放佇列",
|
||||
"share": "分享",
|
||||
"shuffle": "隨機播放",
|
||||
"addToPlaylist": "加入至播放清單",
|
||||
"download": "下載",
|
||||
"info": "取得資訊",
|
||||
"share": "分享"
|
||||
"info": "取得資訊"
|
||||
},
|
||||
"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": "電台",
|
||||
"topSongs": "熱門歌曲"
|
||||
"radio": "電台"
|
||||
}
|
||||
},
|
||||
"user": {
|
||||
@@ -143,6 +143,7 @@
|
||||
"userName": "使用者名稱",
|
||||
"isAdmin": "管理員",
|
||||
"lastLoginAt": "上次登入",
|
||||
"lastAccessAt": "上次存取",
|
||||
"updatedAt": "更新於",
|
||||
"name": "名稱",
|
||||
"password": "密碼",
|
||||
@@ -151,7 +152,6 @@
|
||||
"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,6 +246,7 @@
|
||||
"username": "分享者",
|
||||
"url": "網址",
|
||||
"description": "描述",
|
||||
"downloadable": "允許下載?",
|
||||
"contents": "內容",
|
||||
"expiresAt": "過期時間",
|
||||
"lastVisitedAt": "上次造訪時間",
|
||||
@@ -253,17 +254,19 @@
|
||||
"format": "格式",
|
||||
"maxBitRate": "最大位元率",
|
||||
"updatedAt": "更新於",
|
||||
"createdAt": "建立於",
|
||||
"downloadable": "允許下載?"
|
||||
}
|
||||
"createdAt": "建立於"
|
||||
},
|
||||
"notifications": {},
|
||||
"actions": {}
|
||||
},
|
||||
"missing": {
|
||||
"name": "遺失檔案 |||| 遺失檔案",
|
||||
"empty": "無遺失檔案",
|
||||
"fields": {
|
||||
"path": "路徑",
|
||||
"size": "檔案大小",
|
||||
"updatedAt": "遺失於",
|
||||
"libraryName": "媒體庫"
|
||||
"libraryName": "媒體庫",
|
||||
"updatedAt": "遺失於"
|
||||
},
|
||||
"actions": {
|
||||
"remove": "刪除",
|
||||
@@ -271,8 +274,7 @@
|
||||
},
|
||||
"notifications": {
|
||||
"removed": "遺失檔案已刪除"
|
||||
},
|
||||
"empty": "無遺失檔案"
|
||||
}
|
||||
},
|
||||
"library": {
|
||||
"name": "媒體庫 |||| 媒體庫",
|
||||
@@ -302,20 +304,20 @@
|
||||
},
|
||||
"actions": {
|
||||
"scan": "掃描媒體庫",
|
||||
"manageUsers": "管理使用者權限",
|
||||
"viewDetails": "查看詳細資料",
|
||||
"quickScan": "快速掃描",
|
||||
"fullScan": "完整掃描"
|
||||
"fullScan": "完整掃描",
|
||||
"manageUsers": "管理使用者權限",
|
||||
"viewDetails": "查看詳細資料"
|
||||
},
|
||||
"notifications": {
|
||||
"created": "成功建立媒體庫",
|
||||
"updated": "成功更新媒體庫",
|
||||
"deleted": "成功刪除媒體庫",
|
||||
"scanStarted": "開始掃描媒體庫",
|
||||
"scanCompleted": "媒體庫掃描完成",
|
||||
"quickScanStarted": "快速掃描已開始",
|
||||
"fullScanStarted": "完整掃描已開始",
|
||||
"scanError": "掃描啟動失敗,請檢查日誌"
|
||||
"scanError": "掃描啟動失敗,請檢查日誌",
|
||||
"scanCompleted": "媒體庫掃描完成"
|
||||
},
|
||||
"validation": {
|
||||
"nameRequired": "請輸入媒體庫名稱",
|
||||
@@ -387,6 +389,8 @@
|
||||
},
|
||||
"messages": {
|
||||
"configHelp": "使用鍵值對設定插件。若插件無需設定則留空。",
|
||||
"configValidationError": "設定驗證失敗:",
|
||||
"schemaRenderError": "無法顯示設定表單。插件的 schema 可能無效。",
|
||||
"clickPermissions": "點擊權限以查看詳細資訊",
|
||||
"noConfig": "無設定",
|
||||
"allUsersHelp": "啟用後,插件將可存取所有使用者,包含未來建立的使用者。",
|
||||
@@ -396,9 +400,7 @@
|
||||
"allLibrariesHelp": "啟用後,插件將可存取所有媒體庫,包含未來建立的媒體庫。",
|
||||
"noLibraries": "未選擇媒體庫",
|
||||
"librariesRequired": "此插件需要存取媒體庫資訊。請選擇插件可存取的媒體庫,或啟用「允許所有媒體庫」。",
|
||||
"requiredHosts": "必要的 Hosts",
|
||||
"configValidationError": "設定驗證失敗:",
|
||||
"schemaRenderError": "無法顯示設定表單。插件的 schema 可能無效。"
|
||||
"requiredHosts": "必要的 Hosts"
|
||||
},
|
||||
"placeholders": {
|
||||
"configKey": "鍵",
|
||||
@@ -441,6 +443,7 @@
|
||||
"add": "加入",
|
||||
"back": "返回",
|
||||
"bulk_actions": "選中 1 項 |||| 選中 %{smart_count} 項",
|
||||
"bulk_actions_mobile": "1 |||| %{smart_count}",
|
||||
"cancel": "取消",
|
||||
"clear_input_value": "清除",
|
||||
"clone": "複製",
|
||||
@@ -464,7 +467,6 @@
|
||||
"close_menu": "關閉選單",
|
||||
"unselect": "取消選取",
|
||||
"skip": "略過",
|
||||
"bulk_actions_mobile": "1 |||| %{smart_count}",
|
||||
"share": "分享",
|
||||
"download": "下載"
|
||||
},
|
||||
@@ -556,42 +558,48 @@
|
||||
"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})",
|
||||
"shareCopyToClipboard": "複製到剪貼簿:Ctrl+C, Enter",
|
||||
"remove_missing_title": "刪除遺失檔案",
|
||||
"remove_missing_content": "您確定要從媒體庫中刪除所選的遺失的檔案嗎?這將永久刪除它們的所有相關資訊,包括其播放次數和評分。",
|
||||
"remove_all_missing_title": "刪除所有遺失檔案",
|
||||
"remove_all_missing_content": "您確定要從媒體庫中刪除所有遺失的檔案嗎?這將永久刪除它們的所有相關資訊,包括它們的播放次數和評分。",
|
||||
"noSimilarSongsFound": "找不到相似歌曲",
|
||||
"noTopSongsFound": "找不到熱門歌曲",
|
||||
"startingInstantMix": "正在載入即時混音..."
|
||||
"downloadOriginalFormat": "下載原始格式"
|
||||
},
|
||||
"menu": {
|
||||
"library": "媒體庫",
|
||||
"librarySelector": {
|
||||
"allLibraries": "所有媒體庫 (%{count})",
|
||||
"multipleLibraries": "已選 %{selected} 共 %{total} 媒體庫",
|
||||
"selectLibraries": "選取媒體庫",
|
||||
"none": "無"
|
||||
},
|
||||
"settings": "設定",
|
||||
"version": "版本",
|
||||
"theme": "主題",
|
||||
@@ -602,6 +610,7 @@
|
||||
"language": "語言",
|
||||
"defaultView": "預設畫面",
|
||||
"desktop_notifications": "桌面通知",
|
||||
"lastfmNotConfigured": "Last.fm API 金鑰未設定",
|
||||
"lastfmScrobbling": "啟用 Last.fm 音樂記錄",
|
||||
"listenBrainzScrobbling": "啟用 ListenBrainz 音樂記錄",
|
||||
"replaygain": "重播增益模式",
|
||||
@@ -610,20 +619,13 @@
|
||||
"none": "無",
|
||||
"album": "專輯增益",
|
||||
"track": "曲目增益"
|
||||
},
|
||||
"lastfmNotConfigured": "Last.fm API 金鑰未設定"
|
||||
}
|
||||
}
|
||||
},
|
||||
"albumList": "專輯",
|
||||
"about": "關於",
|
||||
"playlists": "播放清單",
|
||||
"sharedPlaylists": "分享的播放清單",
|
||||
"librarySelector": {
|
||||
"allLibraries": "所有媒體庫 (%{count})",
|
||||
"multipleLibraries": "已選 %{selected} 共 %{total} 媒體庫",
|
||||
"selectLibraries": "選取媒體庫",
|
||||
"none": "無"
|
||||
}
|
||||
"about": "關於"
|
||||
},
|
||||
"player": {
|
||||
"playListsText": "播放佇列",
|
||||
@@ -674,8 +676,7 @@
|
||||
"exportSuccess": "設定已以 TOML 格式匯出至剪貼簿",
|
||||
"exportFailed": "設定複製失敗",
|
||||
"devFlagsHeader": "開發旗標(可能會更改/刪除)",
|
||||
"devFlagsComment": "這些是實驗性設定,可能會在未來版本中刪除",
|
||||
"downloadToml": "下載設定檔 (TOML)"
|
||||
"devFlagsComment": "這些是實驗性設定,可能會在未來版本中刪除"
|
||||
}
|
||||
},
|
||||
"activity": {
|
||||
@@ -683,12 +684,17 @@
|
||||
"totalScanned": "已掃描的資料夾總數",
|
||||
"quickScan": "快速掃描",
|
||||
"fullScan": "完全掃描",
|
||||
"selectiveScan": "選擇性掃描",
|
||||
"serverUptime": "伺服器運作時間",
|
||||
"serverDown": "伺服器已離線",
|
||||
"scanType": "掃描類型",
|
||||
"status": "掃描錯誤",
|
||||
"elapsedTime": "經過時間",
|
||||
"selectiveScan": "選擇性掃描"
|
||||
"elapsedTime": "經過時間"
|
||||
},
|
||||
"nowPlaying": {
|
||||
"title": "正在播放",
|
||||
"empty": "無播放內容",
|
||||
"minutesAgo": "1 分鐘前 |||| %{smart_count} 分鐘前"
|
||||
},
|
||||
"help": {
|
||||
"title": "Navidrome 快捷鍵",
|
||||
@@ -698,15 +704,10 @@
|
||||
"toggle_play": "播放/暫停",
|
||||
"prev_song": "上一首歌",
|
||||
"next_song": "下一首歌",
|
||||
"current_song": "前往目前歌曲",
|
||||
"vol_up": "提高音量",
|
||||
"vol_down": "降低音量",
|
||||
"toggle_love": "新增此歌曲至收藏",
|
||||
"current_song": "前往目前歌曲"
|
||||
"toggle_love": "新增此歌曲至收藏"
|
||||
}
|
||||
},
|
||||
"nowPlaying": {
|
||||
"title": "正在播放",
|
||||
"empty": "無播放內容",
|
||||
"minutesAgo": "1 分鐘前 |||| %{smart_count} 分鐘前"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 bool) error
|
||||
UpdatePluginLibraries(ctx context.Context, id, librariesJSON string, allLibraries, allowWriteAccess bool) error
|
||||
RescanPlugins(ctx context.Context) error
|
||||
UnloadDisabledPlugins(ctx context.Context)
|
||||
}
|
||||
|
||||
@@ -56,12 +56,13 @@ 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"`
|
||||
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"`
|
||||
}
|
||||
|
||||
func (api *Router) updatePlugin(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -109,7 +110,7 @@ func (api *Router) updatePlugin(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
// Handle libraries permission update (if provided)
|
||||
if req.Libraries != nil || req.AllLibraries != nil {
|
||||
if req.Libraries != nil || req.AllLibraries != nil || req.AllowWriteAccess != nil {
|
||||
if err := validateAndUpdateLibraries(ctx, api.pluginManager, repo, id, req, w); err != nil {
|
||||
log.Error(ctx, "Error updating plugin libraries", err)
|
||||
return
|
||||
@@ -245,6 +246,7 @@ 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) {
|
||||
@@ -256,8 +258,11 @@ 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); err != nil {
|
||||
if err := pm.UpdatePluginLibraries(ctx, id, librariesJSON, allLibraries, allowWriteAccess); 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
|
||||
|
||||
@@ -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 bool) error
|
||||
UpdatePluginLibrariesFn func(ctx context.Context, id, librariesJSON string, allLibraries, allowWriteAccess bool) error
|
||||
// RescanPluginsFn is called when RescanPlugins is invoked. If nil, returns RescanError.
|
||||
RescanPluginsFn func(ctx context.Context) error
|
||||
|
||||
@@ -48,9 +48,10 @@ type MockPluginManager struct {
|
||||
AllUsers bool
|
||||
}
|
||||
UpdatePluginLibrariesCalls []struct {
|
||||
ID string
|
||||
LibrariesJSON string
|
||||
AllLibraries bool
|
||||
ID string
|
||||
LibrariesJSON string
|
||||
AllLibraries bool
|
||||
AllowWriteAccess bool
|
||||
}
|
||||
RescanPluginsCalls int
|
||||
}
|
||||
@@ -105,14 +106,15 @@ func (m *MockPluginManager) UpdatePluginUsers(ctx context.Context, id, usersJSON
|
||||
return m.UsersError
|
||||
}
|
||||
|
||||
func (m *MockPluginManager) UpdatePluginLibraries(ctx context.Context, id, librariesJSON string, allLibraries bool) error {
|
||||
func (m *MockPluginManager) UpdatePluginLibraries(ctx context.Context, id, librariesJSON string, allLibraries, allowWriteAccess bool) error {
|
||||
m.UpdatePluginLibrariesCalls = append(m.UpdatePluginLibrariesCalls, struct {
|
||||
ID string
|
||||
LibrariesJSON string
|
||||
AllLibraries bool
|
||||
}{ID: id, LibrariesJSON: librariesJSON, AllLibraries: allLibraries})
|
||||
ID string
|
||||
LibrariesJSON string
|
||||
AllLibraries bool
|
||||
AllowWriteAccess bool
|
||||
}{ID: id, LibrariesJSON: librariesJSON, AllLibraries: allLibraries, AllowWriteAccess: allowWriteAccess})
|
||||
if m.UpdatePluginLibrariesFn != nil {
|
||||
return m.UpdatePluginLibrariesFn(ctx, id, librariesJSON, allLibraries)
|
||||
return m.UpdatePluginLibrariesFn(ctx, id, librariesJSON, allLibraries, allowWriteAccess)
|
||||
}
|
||||
return m.LibrariesError
|
||||
}
|
||||
|
||||
@@ -355,7 +355,8 @@
|
||||
"allUsers": "Allow all users",
|
||||
"selectedUsers": "Selected users",
|
||||
"allLibraries": "Allow all libraries",
|
||||
"selectedLibraries": "Selected libraries"
|
||||
"selectedLibraries": "Selected libraries",
|
||||
"allowWriteAccess": "Allow write access"
|
||||
},
|
||||
"sections": {
|
||||
"status": "Status",
|
||||
@@ -400,6 +401,7 @@
|
||||
"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": {
|
||||
|
||||
@@ -23,8 +23,10 @@ export const LibraryPermissionCard = ({
|
||||
classes,
|
||||
selectedLibraries,
|
||||
allLibraries,
|
||||
allowWriteAccess,
|
||||
onSelectedLibrariesChange,
|
||||
onAllLibrariesChange,
|
||||
onAllowWriteAccessChange,
|
||||
}) => {
|
||||
const translate = useTranslate()
|
||||
|
||||
@@ -58,9 +60,17 @@ 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 =
|
||||
@@ -107,6 +117,24 @@ 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>
|
||||
@@ -166,6 +194,8 @@ 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,
|
||||
}
|
||||
|
||||
@@ -48,8 +48,11 @@ 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) => {
|
||||
@@ -99,10 +102,12 @@ const PluginShowLayout = () => {
|
||||
if (record && !isDirty) {
|
||||
const recordLibraries = record.libraries || ''
|
||||
const recordAllLibraries = record.allLibraries || false
|
||||
const recordAllowWriteAccess = record.allowWriteAccess || false
|
||||
|
||||
if (
|
||||
recordLibraries !== lastRecordLibraries ||
|
||||
recordAllLibraries !== lastRecordAllLibraries
|
||||
recordAllLibraries !== lastRecordAllLibraries ||
|
||||
recordAllowWriteAccess !== lastRecordAllowWriteAccess
|
||||
) {
|
||||
try {
|
||||
setSelectedLibraries(
|
||||
@@ -112,11 +117,19 @@ const PluginShowLayout = () => {
|
||||
setSelectedLibraries([])
|
||||
}
|
||||
setAllLibraries(recordAllLibraries)
|
||||
setAllowWriteAccess(recordAllowWriteAccess)
|
||||
setLastRecordLibraries(recordLibraries)
|
||||
setLastRecordAllLibraries(recordAllLibraries)
|
||||
setLastRecordAllowWriteAccess(recordAllowWriteAccess)
|
||||
}
|
||||
}
|
||||
}, [record, lastRecordLibraries, lastRecordAllLibraries, isDirty])
|
||||
}, [
|
||||
record,
|
||||
lastRecordLibraries,
|
||||
lastRecordAllLibraries,
|
||||
lastRecordAllowWriteAccess,
|
||||
isDirty,
|
||||
])
|
||||
|
||||
const handleConfigDataChange = useCallback(
|
||||
(newData, errors) => {
|
||||
@@ -152,6 +165,11 @@ const PluginShowLayout = () => {
|
||||
setIsDirty(true)
|
||||
}, [])
|
||||
|
||||
const handleAllowWriteAccessChange = useCallback((newAllowWriteAccess) => {
|
||||
setAllowWriteAccess(newAllowWriteAccess)
|
||||
setIsDirty(true)
|
||||
}, [])
|
||||
|
||||
const [updatePlugin, { loading }] = useUpdate(
|
||||
'plugin',
|
||||
record?.id,
|
||||
@@ -167,6 +185,7 @@ const PluginShowLayout = () => {
|
||||
setLastRecordAllUsers(null)
|
||||
setLastRecordLibraries(null)
|
||||
setLastRecordAllLibraries(null)
|
||||
setLastRecordAllowWriteAccess(null)
|
||||
notify('resources.plugin.notifications.updated', 'info')
|
||||
},
|
||||
onFailure: (err) => {
|
||||
@@ -199,6 +218,7 @@ const PluginShowLayout = () => {
|
||||
if (parsedManifest?.permissions?.library) {
|
||||
data.libraries = JSON.stringify(selectedLibraries)
|
||||
data.allLibraries = allLibraries
|
||||
data.allowWriteAccess = allowWriteAccess
|
||||
}
|
||||
|
||||
updatePlugin('plugin', record.id, data, record)
|
||||
@@ -210,6 +230,7 @@ const PluginShowLayout = () => {
|
||||
allUsers,
|
||||
selectedLibraries,
|
||||
allLibraries,
|
||||
allowWriteAccess,
|
||||
])
|
||||
|
||||
// Parse manifest
|
||||
@@ -294,8 +315,10 @@ const PluginShowLayout = () => {
|
||||
classes={classes}
|
||||
selectedLibraries={selectedLibraries}
|
||||
allLibraries={allLibraries}
|
||||
allowWriteAccess={allowWriteAccess}
|
||||
onSelectedLibrariesChange={handleSelectedLibrariesChange}
|
||||
onAllLibrariesChange={handleAllLibrariesChange}
|
||||
onAllowWriteAccessChange={handleAllowWriteAccessChange}
|
||||
/>
|
||||
|
||||
<Box display="flex" justifyContent="flex-end">
|
||||
|
||||
Reference in New Issue
Block a user