mirror of
https://github.com/navidrome/navidrome.git
synced 2026-08-31 07:30:32 +00:00
feat(plugins): add Rust host function library and example implementation of Discord Rich Presence plugin in Rust
Signed-off-by: Deluan <deluan@navidrome.org>
This commit is contained in:
parent
2ec972cdc8
commit
8dad4f4a9c
@ -1,5 +1,4 @@
|
||||
# Rust build artifacts
|
||||
/target/
|
||||
|
||||
# Cargo.lock is not needed for library crates (this is a cdylib)
|
||||
Cargo.lock
|
||||
Cargo.lock
|
||||
target
|
||||
@ -758,16 +758,17 @@ Generated SDKs for calling host services are in `plugins/host/go/` and `plugins/
|
||||
|
||||
See [examples/](examples/) for complete working plugins:
|
||||
|
||||
| Plugin | Language | Capabilities | Host Services | Description |
|
||||
|----------------------------------------------------------|----------|-------------------------|---------------------------------------------|--------------------------------|
|
||||
| [minimal](examples/minimal/) | Go | MetadataAgent | – | Basic structure example |
|
||||
| [wikimedia](examples/wikimedia/) | Go | MetadataAgent | HTTP | Wikidata/Wikipedia integration |
|
||||
| [coverartarchive-py](examples/coverartarchive-py/) | Python | MetadataAgent | HTTP | Cover Art Archive |
|
||||
| [webhook-rs](examples/webhook-rs/) | Rust | Scrobbler | HTTP | HTTP webhooks |
|
||||
| [nowplaying-py](examples/nowplaying-py/) | Python | Lifecycle | Scheduler, SubsonicAPI | Periodic now-playing logger |
|
||||
| [library-inspector](examples/library-inspector/) | Rust | Lifecycle | Library, Scheduler | Periodic library stats logging |
|
||||
| [crypto-ticker](examples/crypto-ticker/) | Go | Lifecycle | WebSocket, Scheduler | Real-time crypto prices demo |
|
||||
| [discord-rich-presence](examples/discord-rich-presence/) | Go | Scrobbler | HTTP, WebSocket, Cache, Scheduler, Artwork | Discord integration |
|
||||
| Plugin | Language | Capabilities | Host Services | Description |
|
||||
|----------------------------------------------------------------|----------|---------------|--------------------------------------------|--------------------------------|
|
||||
| [minimal](examples/minimal/) | Go | MetadataAgent | – | Basic structure example |
|
||||
| [wikimedia](examples/wikimedia/) | Go | MetadataAgent | HTTP | Wikidata/Wikipedia integration |
|
||||
| [coverartarchive-py](examples/coverartarchive-py/) | Python | MetadataAgent | HTTP | Cover Art Archive |
|
||||
| [webhook-rs](examples/webhook-rs/) | Rust | Scrobbler | HTTP | HTTP webhooks |
|
||||
| [nowplaying-py](examples/nowplaying-py/) | Python | Lifecycle | Scheduler, SubsonicAPI | Periodic now-playing logger |
|
||||
| [library-inspector](examples/library-inspector/) | Rust | Lifecycle | Library, Scheduler | Periodic library stats logging |
|
||||
| [crypto-ticker](examples/crypto-ticker/) | Go | Lifecycle | WebSocket, Scheduler | Real-time crypto prices demo |
|
||||
| [discord-rich-presence](examples/discord-rich-presence/) | Go | Scrobbler | HTTP, WebSocket, Cache, Scheduler, Artwork | Discord integration |
|
||||
| [discord-rich-presence-rs](examples/discord-rich-presence-rs/) | Rust | Scrobbler | HTTP, WebSocket, Cache, Scheduler, Artwork | Discord integration (Rust) |
|
||||
|
||||
---
|
||||
|
||||
|
||||
@ -21,10 +21,11 @@ hostgen -input <dir> -output <dir> -package <name> [-v] [-dry-run] [-host-only]
|
||||
| `-plugin-only` | Generate only plugin/client-side wrapper code | `false` |
|
||||
| `-go` | Generate Go client wrappers | `true`* |
|
||||
| `-python` | Generate Python client wrappers | `false` |
|
||||
| `-rust` | Generate Rust client wrappers | `false` |
|
||||
|
||||
\* `-go` is enabled by default when `-python` is not specified. Use both `-go -python` to generate both.
|
||||
\* `-go` is enabled by default when neither `-python` nor `-rust` is specified. Use combinations like `-go -python -rust` to generate multiple languages.
|
||||
|
||||
By default, both host and Go plugin code are generated. Use `-host-only` or `-plugin-only` to generate only one type. Use `-python` to generate Python wrappers.
|
||||
By default, both host and Go plugin code are generated. Use `-host-only` or `-plugin-only` to generate only one type. Use `-python` to generate Python wrappers and `-rust` to generate Rust wrappers.
|
||||
|
||||
### Example
|
||||
|
||||
@ -198,8 +199,10 @@ output/
|
||||
├── subsonicapi_gen.go # Host-side code (for Navidrome)
|
||||
├── go/
|
||||
│ └── nd_host_subsonicapi.go # Plugin-side code (for TinyGo plugins)
|
||||
└── python/
|
||||
└── nd_host_subsonicapi.py # Plugin-side code (for Python plugins)
|
||||
├── python/
|
||||
│ └── nd_host_subsonicapi.py # Plugin-side code (for Python plugins)
|
||||
└── rust/
|
||||
└── nd_host_subsonicapi.rs # Plugin-side code (for Rust plugins)
|
||||
```
|
||||
|
||||
### Python Client Code (extism-py WASM)
|
||||
@ -280,6 +283,75 @@ def my_plugin_function():
|
||||
extism.log(extism.LogLevel.Error, f"API error: {e}")
|
||||
```
|
||||
|
||||
### Rust Client Code (extism-pdk WASM)
|
||||
|
||||
Generated files are named `nd_host_<servicename>.rs` (lowercase) and placed in the `rust/` subdirectory of the output directory. These files are intended for use in Navidrome plugins built with `extism-pdk`. Each file includes:
|
||||
|
||||
- `// Code generated by hostgen. DO NOT EDIT.` header
|
||||
- Required imports (`extism_pdk::*`, `serde`)
|
||||
- Request/response struct types with `#[derive(Serialize, Deserialize)]`
|
||||
- `#[host_fn]` extern blocks for raw host function imports
|
||||
- Public wrapper functions with `Result<T, Error>` returns and snake_case names
|
||||
|
||||
#### Rust Type Mapping
|
||||
|
||||
| Go Type | Rust Type | Notes |
|
||||
|-------------------------------|-------------------------------|------------------------------|
|
||||
| `string` | `String` / `&str` | `&str` for params |
|
||||
| `int`, `int32` | `i32` | |
|
||||
| `int64` | `i64` | |
|
||||
| `float32` | `f32` | |
|
||||
| `float64` | `f64` | |
|
||||
| `bool` | `bool` | |
|
||||
| `[]byte` | `Vec<u8>` | |
|
||||
| `[]T` | `Vec<T>` | |
|
||||
| `map[K]V` | `HashMap<K, V>` | From `std::collections` |
|
||||
| `*T` | `Option<T>` | |
|
||||
| `interface{}` / `any` | `serde_json::Value` | |
|
||||
|
||||
#### Rust Function Naming
|
||||
|
||||
Functions follow Rust snake_case convention:
|
||||
|
||||
| Go Method | Rust Function |
|
||||
|-------------------------------|----------------------------------|
|
||||
| `SubsonicAPI.Call` | `subsonicapi_call()` |
|
||||
| `Scheduler.ScheduleRecurring` | `scheduler_schedule_recurring()` |
|
||||
| `Cache.GetString` | `cache_get_string()` |
|
||||
|
||||
#### Rust Plugin Usage
|
||||
|
||||
The generated Rust wrappers form a library crate (`nd-host`) that plugins can depend on. Add the dependency to your plugin's `Cargo.toml`:
|
||||
|
||||
```toml
|
||||
[dependencies]
|
||||
extism-pdk = "1.2"
|
||||
nd-host = { path = "../../host/rust" }
|
||||
```
|
||||
|
||||
Then import and use the services:
|
||||
|
||||
```rust
|
||||
use nd_host::{cache, scheduler, artwork};
|
||||
|
||||
#[plugin_fn]
|
||||
pub fn my_callback(input: String) -> FnResult<String> {
|
||||
// Use cache service
|
||||
cache::cache_set_string("key", "value", 3600)?;
|
||||
let value = cache::cache_get_string("key")?;
|
||||
|
||||
// Schedule a task
|
||||
scheduler::scheduler_schedule_one_time(60, "payload", "task-id")?;
|
||||
|
||||
// Get artwork URL
|
||||
let url = artwork::artwork_get_track_url("track-id", 300)?;
|
||||
|
||||
Ok("done")
|
||||
}
|
||||
```
|
||||
|
||||
See [discord-rich-presence-rs](../examples/discord-rich-presence-rs/) for a complete example using all Rust host wrappers.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Annotations Not Detected
|
||||
|
||||
@ -197,16 +197,17 @@ type ServiceB interface {
|
||||
|
||||
Describe("code generation", func() {
|
||||
DescribeTable("generates correct host and client output",
|
||||
func(serviceFile, hostExpectedFile, goClientExpectedFile, pyClientExpectedFile string) {
|
||||
func(serviceFile, hostExpectedFile, goClientExpectedFile, pyClientExpectedFile, rsClientExpectedFile string) {
|
||||
serviceCode := readTestdata(serviceFile)
|
||||
hostExpected := readTestdata(hostExpectedFile)
|
||||
goClientExpected := readTestdata(goClientExpectedFile)
|
||||
pyClientExpected := readTestdata(pyClientExpectedFile)
|
||||
rsClientExpected := readTestdata(rsClientExpectedFile)
|
||||
|
||||
Expect(os.WriteFile(filepath.Join(testDir, "service.go"), []byte(serviceCode), 0600)).To(Succeed())
|
||||
|
||||
// Generate host and both Go and Python client code
|
||||
cmd := exec.Command(hostgenBin, "-input", testDir, "-output", outputDir, "-package", "testpkg", "-go", "-python")
|
||||
// Generate host and all client code (Go, Python, Rust)
|
||||
cmd := exec.Command(hostgenBin, "-input", testDir, "-output", outputDir, "-package", "testpkg", "-go", "-python", "-rust")
|
||||
output, err := cmd.CombinedOutput()
|
||||
Expect(err).ToNot(HaveOccurred(), "Command failed: %s", output)
|
||||
|
||||
@ -216,7 +217,7 @@ type ServiceB interface {
|
||||
|
||||
var hostFiles []string
|
||||
for _, e := range entries {
|
||||
if e.Name() != "go" && e.Name() != "python" && !e.IsDir() {
|
||||
if e.Name() != "go" && e.Name() != "python" && e.Name() != "rust" && !e.IsDir() {
|
||||
hostFiles = append(hostFiles, e.Name())
|
||||
}
|
||||
}
|
||||
@ -260,37 +261,48 @@ type ServiceB interface {
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
Expect(string(pyClientActual)).To(Equal(pyClientExpected), "Python client code mismatch")
|
||||
|
||||
// Verify Rust client code
|
||||
rustDir := filepath.Join(outputDir, "rust")
|
||||
rsClientEntries, err := os.ReadDir(rustDir)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(rsClientEntries).To(HaveLen(1), "Expected exactly one Rust client file")
|
||||
|
||||
rsClientActual, err := os.ReadFile(filepath.Join(rustDir, rsClientEntries[0].Name()))
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
Expect(string(rsClientActual)).To(Equal(rsClientExpected), "Rust client code mismatch")
|
||||
},
|
||||
|
||||
Entry("simple string params",
|
||||
"echo_service.go", "echo_expected.go", "echo_client_expected.go", "echo_client_expected.py"),
|
||||
"echo_service.go", "echo_expected.go", "echo_client_expected.go", "echo_client_expected.py", "echo_client_expected.rs"),
|
||||
|
||||
Entry("multiple simple params (int32)",
|
||||
"math_service.go", "math_expected.go", "math_client_expected.go", "math_client_expected.py"),
|
||||
"math_service.go", "math_expected.go", "math_client_expected.go", "math_client_expected.py", "math_client_expected.rs"),
|
||||
|
||||
Entry("struct param with request type",
|
||||
"store_service.go", "store_expected.go", "store_client_expected.go", "store_client_expected.py"),
|
||||
"store_service.go", "store_expected.go", "store_client_expected.go", "store_client_expected.py", "store_client_expected.rs"),
|
||||
|
||||
Entry("mixed simple and complex params",
|
||||
"list_service.go", "list_expected.go", "list_client_expected.go", "list_client_expected.py"),
|
||||
"list_service.go", "list_expected.go", "list_client_expected.go", "list_client_expected.py", "list_client_expected.rs"),
|
||||
|
||||
Entry("method without error",
|
||||
"counter_service.go", "counter_expected.go", "counter_client_expected.go", "counter_client_expected.py"),
|
||||
"counter_service.go", "counter_expected.go", "counter_client_expected.go", "counter_client_expected.py", "counter_client_expected.rs"),
|
||||
|
||||
Entry("no params, error only",
|
||||
"ping_service.go", "ping_expected.go", "ping_client_expected.go", "ping_client_expected.py"),
|
||||
"ping_service.go", "ping_expected.go", "ping_client_expected.go", "ping_client_expected.py", "ping_client_expected.rs"),
|
||||
|
||||
Entry("map and interface types",
|
||||
"meta_service.go", "meta_expected.go", "meta_client_expected.go", "meta_client_expected.py"),
|
||||
"meta_service.go", "meta_expected.go", "meta_client_expected.go", "meta_client_expected.py", "meta_client_expected.rs"),
|
||||
|
||||
Entry("pointer types",
|
||||
"users_service.go", "users_expected.go", "users_client_expected.go", "users_client_expected.py"),
|
||||
"users_service.go", "users_expected.go", "users_client_expected.go", "users_client_expected.py", "users_client_expected.rs"),
|
||||
|
||||
Entry("multiple returns",
|
||||
"search_service.go", "search_expected.go", "search_client_expected.go", "search_client_expected.py"),
|
||||
"search_service.go", "search_expected.go", "search_client_expected.go", "search_client_expected.py", "search_client_expected.rs"),
|
||||
|
||||
Entry("bytes",
|
||||
"codec_service.go", "codec_expected.go", "codec_client_expected.go", "codec_client_expected.py"),
|
||||
"codec_service.go", "codec_expected.go", "codec_client_expected.go", "codec_client_expected.py", "codec_client_expected.rs"),
|
||||
)
|
||||
|
||||
It("generates compilable host code for comprehensive service", func() {
|
||||
|
||||
@ -154,3 +154,39 @@ func pythonDefaultValue(p Param) string {
|
||||
return ", None"
|
||||
}
|
||||
}
|
||||
|
||||
// rustFuncMap returns the template functions for Rust client code generation.
|
||||
func rustFuncMap(svc Service) template.FuncMap {
|
||||
return template.FuncMap{
|
||||
"lower": strings.ToLower,
|
||||
"exportName": func(m Method) string { return m.FunctionName(svc.ExportPrefix()) },
|
||||
"requestType": func(m Method) string { return m.RequestTypeName(svc.Name) },
|
||||
"responseType": func(m Method) string { return m.ResponseTypeName(svc.Name) },
|
||||
"rustFunc": func(m Method) string { return m.RustFunctionName(svc.ExportPrefix()) },
|
||||
"rustDocComment": RustDocComment,
|
||||
}
|
||||
}
|
||||
|
||||
// GenerateClientRust generates Rust client wrapper code for plugins.
|
||||
func GenerateClientRust(svc Service) ([]byte, error) {
|
||||
tmplContent, err := templatesFS.ReadFile("templates/client_rs.rs.tmpl")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("reading Rust client template: %w", err)
|
||||
}
|
||||
|
||||
tmpl, err := template.New("client_rs").Funcs(rustFuncMap(svc)).Parse(string(tmplContent))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parsing template: %w", err)
|
||||
}
|
||||
|
||||
data := templateData{
|
||||
Service: svc,
|
||||
}
|
||||
|
||||
var buf bytes.Buffer
|
||||
if err := tmpl.Execute(&buf, data); err != nil {
|
||||
return nil, fmt.Errorf("executing template: %w", err)
|
||||
}
|
||||
|
||||
return buf.Bytes(), nil
|
||||
}
|
||||
|
||||
@ -290,10 +290,15 @@ var _ = Describe("Generator", func() {
|
||||
})
|
||||
|
||||
Describe("toJSONName", func() {
|
||||
It("should convert to camelCase", func() {
|
||||
It("should convert to camelCase matching Rust serde behavior", func() {
|
||||
Expect(toJSONName("InputValue")).To(Equal("inputValue"))
|
||||
Expect(toJSONName("URI")).To(Equal("uRI"))
|
||||
Expect(toJSONName("URI")).To(Equal("uri"))
|
||||
Expect(toJSONName("id")).To(Equal("id"))
|
||||
Expect(toJSONName("ID")).To(Equal("id"))
|
||||
Expect(toJSONName("ConnectionID")).To(Equal("connectionId"))
|
||||
Expect(toJSONName("NewConnectionID")).To(Equal("newConnectionId"))
|
||||
Expect(toJSONName("XMLHTTPRequest")).To(Equal("xmlhttpRequest"))
|
||||
Expect(toJSONName("APIKey")).To(Equal("apiKey"))
|
||||
})
|
||||
|
||||
It("should handle empty string", func() {
|
||||
@ -329,9 +334,16 @@ var _ = Describe("Generator", func() {
|
||||
It("should convert PascalCase to snake_case", func() {
|
||||
Expect(ToSnakeCase("ScheduleRecurring")).To(Equal("schedule_recurring"))
|
||||
Expect(ToSnakeCase("GetString")).To(Equal("get_string"))
|
||||
Expect(ToSnakeCase("ID")).To(Equal("i_d"))
|
||||
Expect(ToSnakeCase("simple")).To(Equal("simple"))
|
||||
})
|
||||
|
||||
It("should handle acronyms correctly", func() {
|
||||
Expect(ToSnakeCase("ID")).To(Equal("id"))
|
||||
Expect(ToSnakeCase("ScheduleID")).To(Equal("schedule_id"))
|
||||
Expect(ToSnakeCase("NewScheduleID")).To(Equal("new_schedule_id"))
|
||||
Expect(ToSnakeCase("XMLParser")).To(Equal("xml_parser"))
|
||||
Expect(ToSnakeCase("GetHTTPResponse")).To(Equal("get_http_response"))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("Method.PythonFunctionName", func() {
|
||||
|
||||
86
plugins/cmd/hostgen/internal/templates/client_rs.rs.tmpl
Normal file
86
plugins/cmd/hostgen/internal/templates/client_rs.rs.tmpl
Normal file
@ -0,0 +1,86 @@
|
||||
// Code generated by hostgen. DO NOT EDIT.
|
||||
//
|
||||
// This file contains client wrappers for the {{.Service.Name}} host service.
|
||||
// It is intended for use in Navidrome plugins built with extism-pdk.
|
||||
|
||||
use extism_pdk::*;
|
||||
use serde::{Deserialize, Serialize};
|
||||
{{- range .Service.Methods}}
|
||||
{{- if .HasParams}}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct {{requestType .}} {
|
||||
{{- range .Params}}
|
||||
{{.RustName}}: {{.RustType}},
|
||||
{{- end}}
|
||||
}
|
||||
{{- end}}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct {{responseType .}} {
|
||||
{{- range .Returns}}
|
||||
#[serde(default)]
|
||||
{{.RustName}}: {{.RustType}},
|
||||
{{- end}}
|
||||
#[serde(default)]
|
||||
error: Option<String>,
|
||||
}
|
||||
{{- end}}
|
||||
|
||||
#[host_fn]
|
||||
extern "ExtismHost" {
|
||||
{{- range .Service.Methods}}
|
||||
fn {{exportName .}}(input: Json<{{if .HasParams}}{{requestType .}}{{else}}serde_json::Value{{end}}>) -> Json<{{responseType .}}>;
|
||||
{{- end}}
|
||||
}
|
||||
|
||||
{{- /* Generate wrapper functions */ -}}
|
||||
{{range .Service.Methods}}
|
||||
|
||||
{{if .Doc}}{{rustDocComment .Doc}}{{else}}/// Calls the {{exportName .}} host function.{{end}}
|
||||
{{- if .HasParams}}
|
||||
///
|
||||
/// # Arguments
|
||||
{{- range .Params}}
|
||||
/// * `{{.RustName}}` - {{.RustType}} parameter.
|
||||
{{- end}}
|
||||
{{- end}}
|
||||
{{- if .HasReturns}}
|
||||
///
|
||||
/// # Returns
|
||||
{{- if eq (len .Returns) 1}}
|
||||
/// The {{(index .Returns 0).RustName}} value.
|
||||
{{- else}}
|
||||
/// A tuple of ({{range $i, $r := .Returns}}{{if $i}}, {{end}}{{$r.RustName}}{{end}}).
|
||||
{{- end}}
|
||||
{{- end}}
|
||||
///
|
||||
/// # Errors
|
||||
/// Returns an error if the host function call fails.
|
||||
pub fn {{rustFunc .}}({{range $i, $p := .Params}}{{if $i}}, {{end}}{{$p.RustName}}: {{$p.RustParamType}}{{end}}) -> Result<{{if eq (len .Returns) 0}}(){{else if eq (len .Returns) 1}}{{(index .Returns 0).RustType}}{{else}}({{range $i, $r := .Returns}}{{if $i}}, {{end}}{{$r.RustType}}{{end}}){{end}}, Error> {
|
||||
let response = unsafe {
|
||||
{{- if .HasParams}}
|
||||
{{exportName .}}(Json({{requestType .}} {
|
||||
{{- range .Params}}
|
||||
{{.RustName}}: {{.RustName}}{{if .NeedsToOwned}}.to_owned(){{end}},
|
||||
{{- end}}
|
||||
}))?
|
||||
{{- else}}
|
||||
{{exportName .}}(Json(serde_json::json!({})))?
|
||||
{{- end}}
|
||||
};
|
||||
|
||||
if let Some(err) = response.0.error {
|
||||
return Err(Error::msg(err));
|
||||
}
|
||||
{{if eq (len .Returns) 0}}
|
||||
Ok(())
|
||||
{{- else if eq (len .Returns) 1}}
|
||||
Ok(response.0.{{(index .Returns 0).RustName}})
|
||||
{{- else}}
|
||||
Ok(({{range $i, $r := .Returns}}{{if $i}}, {{end}}response.0.{{$r.RustName}}{{end}}))
|
||||
{{- end}}
|
||||
}
|
||||
{{- end}}
|
||||
@ -2,6 +2,7 @@ package internal
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"unicode"
|
||||
)
|
||||
|
||||
// Service represents a parsed host service interface.
|
||||
@ -78,12 +79,43 @@ func NewParam(name, typ string) Param {
|
||||
}
|
||||
|
||||
// toJSONName converts a Go identifier to camelCase JSON field name.
|
||||
// This matches Rust serde's rename_all = "camelCase" behavior.
|
||||
// Examples: "ConnectionID" -> "connectionId", "NewConnectionID" -> "newConnectionId"
|
||||
func toJSONName(name string) string {
|
||||
if name == "" {
|
||||
return ""
|
||||
}
|
||||
// Simple conversion: lowercase first letter
|
||||
return strings.ToLower(name[:1]) + name[1:]
|
||||
|
||||
runes := []rune(name)
|
||||
result := make([]rune, 0, len(runes))
|
||||
|
||||
for i, r := range runes {
|
||||
if i == 0 {
|
||||
// First character is always lowercase
|
||||
result = append(result, unicode.ToLower(r))
|
||||
} else if unicode.IsUpper(r) {
|
||||
// Check if this is part of an acronym (consecutive uppercase)
|
||||
// or a word boundary
|
||||
prevIsUpper := unicode.IsUpper(runes[i-1])
|
||||
nextIsLower := i+1 < len(runes) && unicode.IsLower(runes[i+1])
|
||||
|
||||
if prevIsUpper && !nextIsLower {
|
||||
// Middle of an acronym - lowercase it
|
||||
result = append(result, unicode.ToLower(r))
|
||||
} else if prevIsUpper && nextIsLower {
|
||||
// End of acronym followed by lowercase - this starts a new word
|
||||
// Keep uppercase
|
||||
result = append(result, r)
|
||||
} else {
|
||||
// Regular word boundary - keep uppercase
|
||||
result = append(result, r)
|
||||
}
|
||||
} else {
|
||||
result = append(result, r)
|
||||
}
|
||||
}
|
||||
|
||||
return string(result)
|
||||
}
|
||||
|
||||
// ToPythonType converts a Go type to its Python equivalent.
|
||||
@ -105,11 +137,23 @@ func ToPythonType(goType string) string {
|
||||
}
|
||||
|
||||
// ToSnakeCase converts a PascalCase or camelCase string to snake_case.
|
||||
// It handles consecutive uppercase letters correctly (e.g., "ScheduleID" -> "schedule_id").
|
||||
func ToSnakeCase(s string) string {
|
||||
var result strings.Builder
|
||||
for i, r := range s {
|
||||
runes := []rune(s)
|
||||
for i, r := range runes {
|
||||
if i > 0 && r >= 'A' && r <= 'Z' {
|
||||
result.WriteByte('_')
|
||||
// Add underscore before uppercase, but not if:
|
||||
// - Previous char was uppercase AND next char is uppercase or end of string
|
||||
// (this handles acronyms like "ID" in "NewScheduleID")
|
||||
prevUpper := runes[i-1] >= 'A' && runes[i-1] <= 'Z'
|
||||
nextUpper := i+1 < len(runes) && runes[i+1] >= 'A' && runes[i+1] <= 'Z'
|
||||
atEnd := i+1 == len(runes)
|
||||
|
||||
// Only skip underscore if we're in the middle of an acronym
|
||||
if !prevUpper || (!nextUpper && !atEnd) {
|
||||
result.WriteByte('_')
|
||||
}
|
||||
}
|
||||
result.WriteRune(r)
|
||||
}
|
||||
@ -140,3 +184,136 @@ func (p Param) PythonType() string {
|
||||
func (p Param) PythonName() string {
|
||||
return ToSnakeCase(p.Name)
|
||||
}
|
||||
|
||||
// ToRustType converts a Go type to its Rust equivalent.
|
||||
func ToRustType(goType string) string {
|
||||
// Handle pointer types
|
||||
if strings.HasPrefix(goType, "*") {
|
||||
inner := ToRustType(goType[1:])
|
||||
return "Option<" + inner + ">"
|
||||
}
|
||||
// Handle slice types
|
||||
if strings.HasPrefix(goType, "[]") {
|
||||
if goType == "[]byte" {
|
||||
return "Vec<u8>"
|
||||
}
|
||||
inner := ToRustType(goType[2:])
|
||||
return "Vec<" + inner + ">"
|
||||
}
|
||||
// Handle map types
|
||||
if strings.HasPrefix(goType, "map[") {
|
||||
// Extract key and value types from map[K]V
|
||||
rest := goType[4:] // Remove "map["
|
||||
depth := 1
|
||||
keyEnd := 0
|
||||
for i, r := range rest {
|
||||
if r == '[' {
|
||||
depth++
|
||||
} else if r == ']' {
|
||||
depth--
|
||||
if depth == 0 {
|
||||
keyEnd = i
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
keyType := rest[:keyEnd]
|
||||
valueType := rest[keyEnd+1:]
|
||||
return "std::collections::HashMap<" + ToRustType(keyType) + ", " + ToRustType(valueType) + ">"
|
||||
}
|
||||
|
||||
switch goType {
|
||||
case "string":
|
||||
return "String"
|
||||
case "int", "int32":
|
||||
return "i32"
|
||||
case "int64":
|
||||
return "i64"
|
||||
case "float32":
|
||||
return "f32"
|
||||
case "float64":
|
||||
return "f64"
|
||||
case "bool":
|
||||
return "bool"
|
||||
case "interface{}", "any":
|
||||
return "serde_json::Value"
|
||||
default:
|
||||
// For custom struct types, use Value as they need custom definition
|
||||
return "serde_json::Value"
|
||||
}
|
||||
}
|
||||
|
||||
// RustParamType returns the Rust type for a function parameter (uses &str for strings).
|
||||
func RustParamType(goType string) string {
|
||||
if goType == "string" {
|
||||
return "&str"
|
||||
}
|
||||
return ToRustType(goType)
|
||||
}
|
||||
|
||||
// RustDefaultValue returns the default value for a Rust type.
|
||||
func RustDefaultValue(goType string) string {
|
||||
switch goType {
|
||||
case "string":
|
||||
return `String::new()`
|
||||
case "int", "int32":
|
||||
return "0"
|
||||
case "int64":
|
||||
return "0"
|
||||
case "float32", "float64":
|
||||
return "0.0"
|
||||
case "bool":
|
||||
return "false"
|
||||
default:
|
||||
if strings.HasPrefix(goType, "[]") {
|
||||
return "Vec::new()"
|
||||
}
|
||||
if strings.HasPrefix(goType, "map[") {
|
||||
return "std::collections::HashMap::new()"
|
||||
}
|
||||
if strings.HasPrefix(goType, "*") {
|
||||
return "None"
|
||||
}
|
||||
return "serde_json::Value::Null"
|
||||
}
|
||||
}
|
||||
|
||||
// RustFunctionName returns the Rust function name for a method (snake_case).
|
||||
// Uses just the method name without service prefix since the module provides namespacing.
|
||||
func (m Method) RustFunctionName(_ string) string {
|
||||
return ToSnakeCase(m.Name)
|
||||
}
|
||||
|
||||
// RustDocComment returns a properly formatted Rust doc comment.
|
||||
// Each line of the input doc string is prefixed with "/// ".
|
||||
func RustDocComment(doc string) string {
|
||||
if doc == "" {
|
||||
return ""
|
||||
}
|
||||
lines := strings.Split(doc, "\n")
|
||||
var result []string
|
||||
for _, line := range lines {
|
||||
result = append(result, "/// "+line)
|
||||
}
|
||||
return strings.Join(result, "\n")
|
||||
}
|
||||
|
||||
// RustType returns the Rust type for this parameter.
|
||||
func (p Param) RustType() string {
|
||||
return ToRustType(p.Type)
|
||||
}
|
||||
|
||||
// RustParamType returns the Rust type for this parameter when used as a function argument.
|
||||
func (p Param) RustParamType() string {
|
||||
return RustParamType(p.Type)
|
||||
}
|
||||
|
||||
// RustName returns the snake_case Rust name for this parameter.
|
||||
func (p Param) RustName() string {
|
||||
return ToSnakeCase(p.Name)
|
||||
}
|
||||
|
||||
// NeedsToOwned returns true if the parameter needs .to_owned() when used.
|
||||
func (p Param) NeedsToOwned() bool {
|
||||
return p.Type == "string"
|
||||
}
|
||||
|
||||
@ -11,8 +11,9 @@
|
||||
// -package Output package name (default: inferred from output directory)
|
||||
// -host-only Generate only host-side code (default: false)
|
||||
// -plugin-only Generate only plugin/client-side code (default: false)
|
||||
// -go Generate Go client wrappers (default: true when not using -python)
|
||||
// -go Generate Go client wrappers (default: true when not using -python/-rust)
|
||||
// -python Generate Python client wrappers (default: false)
|
||||
// -rust Generate Rust client wrappers (default: false)
|
||||
// -v Verbose output
|
||||
// -dry-run Preview generated code without writing files
|
||||
package main
|
||||
@ -37,6 +38,7 @@ func main() {
|
||||
pluginOnly = flag.Bool("plugin-only", false, "Generate only plugin/client-side code")
|
||||
goClient = flag.Bool("go", false, "Generate Go client wrappers")
|
||||
pyClient = flag.Bool("python", false, "Generate Python client wrappers")
|
||||
rsClient = flag.Bool("rust", false, "Generate Rust client wrappers")
|
||||
verbose = flag.Bool("v", false, "Verbose output")
|
||||
dryRun = flag.Bool("dry-run", false, "Preview generated code without writing files")
|
||||
)
|
||||
@ -72,11 +74,13 @@ func main() {
|
||||
// Determine what to generate
|
||||
generateHost := !*pluginOnly
|
||||
// Default: generate Go clients if no language flag is specified
|
||||
// If -python is specified without -go, only generate Python
|
||||
// If -python or -rust is specified without -go, only generate those
|
||||
// If -go is specified, generate Go
|
||||
// If both are specified, generate both
|
||||
generateGoClient := !*hostOnly && (*goClient || !*pyClient)
|
||||
// If multiple are specified, generate all specified
|
||||
anyLangFlag := *goClient || *pyClient || *rsClient
|
||||
generateGoClient := !*hostOnly && (*goClient || !anyLangFlag)
|
||||
generatePyClient := !*hostOnly && *pyClient
|
||||
generateRsClient := !*hostOnly && *rsClient
|
||||
|
||||
if *verbose {
|
||||
fmt.Printf("Input directory: %s\n", absInput)
|
||||
@ -85,6 +89,7 @@ func main() {
|
||||
fmt.Printf("Generate host code: %v\n", generateHost)
|
||||
fmt.Printf("Generate Go client code: %v\n", generateGoClient)
|
||||
fmt.Printf("Generate Python client code: %v\n", generatePyClient)
|
||||
fmt.Printf("Generate Rust client code: %v\n", generateRsClient)
|
||||
}
|
||||
|
||||
// Parse source files
|
||||
@ -133,6 +138,14 @@ func main() {
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
// Generate Rust client-side code
|
||||
if generateRsClient {
|
||||
if err := generateRustClientCode(svc, absOutput, *dryRun, *verbose); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error generating Rust client code for %s: %v\n", svc.Name, err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -231,3 +244,34 @@ func generatePythonClientCode(svc internal.Service, outputDir string, dryRun, ve
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// generateRustClientCode generates Rust client-side code for a service.
|
||||
func generateRustClientCode(svc internal.Service, outputDir string, dryRun, verbose bool) error {
|
||||
code, err := internal.GenerateClientRust(svc)
|
||||
if err != nil {
|
||||
return fmt.Errorf("generating code: %w", err)
|
||||
}
|
||||
|
||||
// Rust code goes in rust/ subdirectory
|
||||
clientDir := filepath.Join(outputDir, "rust")
|
||||
clientFile := filepath.Join(clientDir, "nd_host_"+strings.ToLower(svc.Name)+".rs")
|
||||
|
||||
if dryRun {
|
||||
fmt.Printf("=== %s ===\n%s\n", clientFile, code)
|
||||
return nil
|
||||
}
|
||||
|
||||
// Create rust/ subdirectory if needed
|
||||
if err := os.MkdirAll(clientDir, 0755); err != nil {
|
||||
return fmt.Errorf("creating rust client directory: %w", err)
|
||||
}
|
||||
|
||||
if err := os.WriteFile(clientFile, code, 0600); err != nil {
|
||||
return fmt.Errorf("writing file: %w", err)
|
||||
}
|
||||
|
||||
if verbose {
|
||||
fmt.Printf("Generated Rust client code: %s\n", clientFile)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
51
plugins/cmd/hostgen/testdata/codec_client_expected.rs
vendored
Normal file
51
plugins/cmd/hostgen/testdata/codec_client_expected.rs
vendored
Normal file
@ -0,0 +1,51 @@
|
||||
// Code generated by hostgen. DO NOT EDIT.
|
||||
//
|
||||
// This file contains client wrappers for the Codec 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 CodecEncodeRequest {
|
||||
data: Vec<u8>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct CodecEncodeResponse {
|
||||
#[serde(default)]
|
||||
result: Vec<u8>,
|
||||
#[serde(default)]
|
||||
error: Option<String>,
|
||||
}
|
||||
|
||||
#[host_fn]
|
||||
extern "ExtismHost" {
|
||||
fn codec_encode(input: Json<CodecEncodeRequest>) -> Json<CodecEncodeResponse>;
|
||||
}
|
||||
|
||||
/// Calls the codec_encode host function.
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `data` - Vec<u8> parameter.
|
||||
///
|
||||
/// # Returns
|
||||
/// The result value.
|
||||
///
|
||||
/// # Errors
|
||||
/// Returns an error if the host function call fails.
|
||||
pub fn encode(data: Vec<u8>) -> Result<Vec<u8>, Error> {
|
||||
let response = unsafe {
|
||||
codec_encode(Json(CodecEncodeRequest {
|
||||
data: data,
|
||||
}))?
|
||||
};
|
||||
|
||||
if let Some(err) = response.0.error {
|
||||
return Err(Error::msg(err));
|
||||
}
|
||||
|
||||
Ok(response.0.result)
|
||||
}
|
||||
341
plugins/cmd/hostgen/testdata/comprehensive_client_expected.py
vendored
Normal file
341
plugins/cmd/hostgen/testdata/comprehensive_client_expected.py
vendored
Normal file
@ -0,0 +1,341 @@
|
||||
# Code generated by hostgen. DO NOT EDIT.
|
||||
#
|
||||
# This file contains client wrappers for the Comprehensive 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
|
||||
|
||||
import extism
|
||||
import json
|
||||
|
||||
|
||||
class HostFunctionError(Exception):
|
||||
"""Raised when a host function returns an error."""
|
||||
pass
|
||||
|
||||
|
||||
@extism.import_fn("extism:host/user", "comprehensive_simpleparams")
|
||||
def _comprehensive_simpleparams(offset: int) -> int:
|
||||
"""Raw host function - do not call directly."""
|
||||
...
|
||||
|
||||
|
||||
@extism.import_fn("extism:host/user", "comprehensive_structparam")
|
||||
def _comprehensive_structparam(offset: int) -> int:
|
||||
"""Raw host function - do not call directly."""
|
||||
...
|
||||
|
||||
|
||||
@extism.import_fn("extism:host/user", "comprehensive_mixedparams")
|
||||
def _comprehensive_mixedparams(offset: int) -> int:
|
||||
"""Raw host function - do not call directly."""
|
||||
...
|
||||
|
||||
|
||||
@extism.import_fn("extism:host/user", "comprehensive_noerror")
|
||||
def _comprehensive_noerror(offset: int) -> int:
|
||||
"""Raw host function - do not call directly."""
|
||||
...
|
||||
|
||||
|
||||
@extism.import_fn("extism:host/user", "comprehensive_noparams")
|
||||
def _comprehensive_noparams(offset: int) -> int:
|
||||
"""Raw host function - do not call directly."""
|
||||
...
|
||||
|
||||
|
||||
@extism.import_fn("extism:host/user", "comprehensive_noparamsnoreturns")
|
||||
def _comprehensive_noparamsnoreturns(offset: int) -> int:
|
||||
"""Raw host function - do not call directly."""
|
||||
...
|
||||
|
||||
|
||||
@extism.import_fn("extism:host/user", "comprehensive_pointerparams")
|
||||
def _comprehensive_pointerparams(offset: int) -> int:
|
||||
"""Raw host function - do not call directly."""
|
||||
...
|
||||
|
||||
|
||||
@extism.import_fn("extism:host/user", "comprehensive_mapparams")
|
||||
def _comprehensive_mapparams(offset: int) -> int:
|
||||
"""Raw host function - do not call directly."""
|
||||
...
|
||||
|
||||
|
||||
@extism.import_fn("extism:host/user", "comprehensive_multiplereturns")
|
||||
def _comprehensive_multiplereturns(offset: int) -> int:
|
||||
"""Raw host function - do not call directly."""
|
||||
...
|
||||
|
||||
|
||||
@extism.import_fn("extism:host/user", "comprehensive_byteslice")
|
||||
def _comprehensive_byteslice(offset: int) -> int:
|
||||
"""Raw host function - do not call directly."""
|
||||
...
|
||||
|
||||
|
||||
@dataclass
|
||||
class ComprehensiveMultipleReturnsResult:
|
||||
"""Result type for comprehensive_multiple_returns."""
|
||||
results: Any
|
||||
total: int
|
||||
|
||||
|
||||
def comprehensive_simple_params(name: str, count: int) -> str:
|
||||
"""Call the comprehensive_simpleparams host function.
|
||||
|
||||
Args:
|
||||
name: str parameter.
|
||||
count: int parameter.
|
||||
|
||||
Returns:
|
||||
str: The result value.
|
||||
|
||||
Raises:
|
||||
HostFunctionError: If the host function returns an error.
|
||||
"""
|
||||
request = {
|
||||
"name": name,
|
||||
"count": count,
|
||||
}
|
||||
request_bytes = json.dumps(request).encode("utf-8")
|
||||
request_mem = extism.memory.alloc(request_bytes)
|
||||
response_offset = _comprehensive_simpleparams(request_mem.offset)
|
||||
response_mem = extism.memory.find(response_offset)
|
||||
response = json.loads(extism.memory.string(response_mem))
|
||||
|
||||
if response.get("error"):
|
||||
raise HostFunctionError(response["error"])
|
||||
|
||||
return response.get("result", "")
|
||||
|
||||
|
||||
def comprehensive_struct_param(user: Any) -> None:
|
||||
"""Call the comprehensive_structparam host function.
|
||||
|
||||
Args:
|
||||
user: Any parameter.
|
||||
|
||||
Raises:
|
||||
HostFunctionError: If the host function returns an error.
|
||||
"""
|
||||
request = {
|
||||
"user": user,
|
||||
}
|
||||
request_bytes = json.dumps(request).encode("utf-8")
|
||||
request_mem = extism.memory.alloc(request_bytes)
|
||||
response_offset = _comprehensive_structparam(request_mem.offset)
|
||||
response_mem = extism.memory.find(response_offset)
|
||||
response = json.loads(extism.memory.string(response_mem))
|
||||
|
||||
if response.get("error"):
|
||||
raise HostFunctionError(response["error"])
|
||||
|
||||
|
||||
|
||||
def comprehensive_mixed_params(id: str, filter: Any) -> int:
|
||||
"""Call the comprehensive_mixedparams host function.
|
||||
|
||||
Args:
|
||||
id: str parameter.
|
||||
filter: Any parameter.
|
||||
|
||||
Returns:
|
||||
int: The result value.
|
||||
|
||||
Raises:
|
||||
HostFunctionError: If the host function returns an error.
|
||||
"""
|
||||
request = {
|
||||
"id": id,
|
||||
"filter": filter,
|
||||
}
|
||||
request_bytes = json.dumps(request).encode("utf-8")
|
||||
request_mem = extism.memory.alloc(request_bytes)
|
||||
response_offset = _comprehensive_mixedparams(request_mem.offset)
|
||||
response_mem = extism.memory.find(response_offset)
|
||||
response = json.loads(extism.memory.string(response_mem))
|
||||
|
||||
if response.get("error"):
|
||||
raise HostFunctionError(response["error"])
|
||||
|
||||
return response.get("result", 0)
|
||||
|
||||
|
||||
def comprehensive_no_error(name: str) -> str:
|
||||
"""Call the comprehensive_noerror host function.
|
||||
|
||||
Args:
|
||||
name: str parameter.
|
||||
|
||||
Returns:
|
||||
str: The result value.
|
||||
|
||||
Raises:
|
||||
HostFunctionError: If the host function returns an error.
|
||||
"""
|
||||
request = {
|
||||
"name": name,
|
||||
}
|
||||
request_bytes = json.dumps(request).encode("utf-8")
|
||||
request_mem = extism.memory.alloc(request_bytes)
|
||||
response_offset = _comprehensive_noerror(request_mem.offset)
|
||||
response_mem = extism.memory.find(response_offset)
|
||||
response = json.loads(extism.memory.string(response_mem))
|
||||
|
||||
if response.get("error"):
|
||||
raise HostFunctionError(response["error"])
|
||||
|
||||
return response.get("result", "")
|
||||
|
||||
|
||||
def comprehensive_no_params() -> None:
|
||||
"""Call the comprehensive_noparams host function.
|
||||
|
||||
Raises:
|
||||
HostFunctionError: If the host function returns an error.
|
||||
"""
|
||||
request_bytes = b"{}"
|
||||
request_mem = extism.memory.alloc(request_bytes)
|
||||
response_offset = _comprehensive_noparams(request_mem.offset)
|
||||
response_mem = extism.memory.find(response_offset)
|
||||
response = json.loads(extism.memory.string(response_mem))
|
||||
|
||||
if response.get("error"):
|
||||
raise HostFunctionError(response["error"])
|
||||
|
||||
|
||||
|
||||
def comprehensive_no_params_no_returns() -> None:
|
||||
"""Call the comprehensive_noparamsnoreturns host function.
|
||||
|
||||
Raises:
|
||||
HostFunctionError: If the host function returns an error.
|
||||
"""
|
||||
request_bytes = b"{}"
|
||||
request_mem = extism.memory.alloc(request_bytes)
|
||||
response_offset = _comprehensive_noparamsnoreturns(request_mem.offset)
|
||||
response_mem = extism.memory.find(response_offset)
|
||||
response = json.loads(extism.memory.string(response_mem))
|
||||
|
||||
if response.get("error"):
|
||||
raise HostFunctionError(response["error"])
|
||||
|
||||
|
||||
|
||||
def comprehensive_pointer_params(id: Any, user: Any) -> Any:
|
||||
"""Call the comprehensive_pointerparams host function.
|
||||
|
||||
Args:
|
||||
id: Any parameter.
|
||||
user: Any parameter.
|
||||
|
||||
Returns:
|
||||
Any: The result value.
|
||||
|
||||
Raises:
|
||||
HostFunctionError: If the host function returns an error.
|
||||
"""
|
||||
request = {
|
||||
"id": id,
|
||||
"user": user,
|
||||
}
|
||||
request_bytes = json.dumps(request).encode("utf-8")
|
||||
request_mem = extism.memory.alloc(request_bytes)
|
||||
response_offset = _comprehensive_pointerparams(request_mem.offset)
|
||||
response_mem = extism.memory.find(response_offset)
|
||||
response = json.loads(extism.memory.string(response_mem))
|
||||
|
||||
if response.get("error"):
|
||||
raise HostFunctionError(response["error"])
|
||||
|
||||
return response.get("result", None)
|
||||
|
||||
|
||||
def comprehensive_map_params(data: Any) -> Any:
|
||||
"""Call the comprehensive_mapparams host function.
|
||||
|
||||
Args:
|
||||
data: Any parameter.
|
||||
|
||||
Returns:
|
||||
Any: The result value.
|
||||
|
||||
Raises:
|
||||
HostFunctionError: If the host function returns an error.
|
||||
"""
|
||||
request = {
|
||||
"data": data,
|
||||
}
|
||||
request_bytes = json.dumps(request).encode("utf-8")
|
||||
request_mem = extism.memory.alloc(request_bytes)
|
||||
response_offset = _comprehensive_mapparams(request_mem.offset)
|
||||
response_mem = extism.memory.find(response_offset)
|
||||
response = json.loads(extism.memory.string(response_mem))
|
||||
|
||||
if response.get("error"):
|
||||
raise HostFunctionError(response["error"])
|
||||
|
||||
return response.get("result", None)
|
||||
|
||||
|
||||
def comprehensive_multiple_returns(query: str) -> ComprehensiveMultipleReturnsResult:
|
||||
"""Call the comprehensive_multiplereturns host function.
|
||||
|
||||
Args:
|
||||
query: str parameter.
|
||||
|
||||
Returns:
|
||||
ComprehensiveMultipleReturnsResult containing results, total,.
|
||||
|
||||
Raises:
|
||||
HostFunctionError: If the host function returns an error.
|
||||
"""
|
||||
request = {
|
||||
"query": query,
|
||||
}
|
||||
request_bytes = json.dumps(request).encode("utf-8")
|
||||
request_mem = extism.memory.alloc(request_bytes)
|
||||
response_offset = _comprehensive_multiplereturns(request_mem.offset)
|
||||
response_mem = extism.memory.find(response_offset)
|
||||
response = json.loads(extism.memory.string(response_mem))
|
||||
|
||||
if response.get("error"):
|
||||
raise HostFunctionError(response["error"])
|
||||
|
||||
return ComprehensiveMultipleReturnsResult(
|
||||
results=response.get("results", None),
|
||||
total=response.get("total", 0),
|
||||
)
|
||||
|
||||
|
||||
def comprehensive_byte_slice(data: bytes) -> bytes:
|
||||
"""Call the comprehensive_byteslice host function.
|
||||
|
||||
Args:
|
||||
data: bytes parameter.
|
||||
|
||||
Returns:
|
||||
bytes: The result value.
|
||||
|
||||
Raises:
|
||||
HostFunctionError: If the host function returns an error.
|
||||
"""
|
||||
request = {
|
||||
"data": data,
|
||||
}
|
||||
request_bytes = json.dumps(request).encode("utf-8")
|
||||
request_mem = extism.memory.alloc(request_bytes)
|
||||
response_offset = _comprehensive_byteslice(request_mem.offset)
|
||||
response_mem = extism.memory.find(response_offset)
|
||||
response = json.loads(extism.memory.string(response_mem))
|
||||
|
||||
if response.get("error"):
|
||||
raise HostFunctionError(response["error"])
|
||||
|
||||
return response.get("result", b"")
|
||||
385
plugins/cmd/hostgen/testdata/comprehensive_client_expected.rs
vendored
Normal file
385
plugins/cmd/hostgen/testdata/comprehensive_client_expected.rs
vendored
Normal file
@ -0,0 +1,385 @@
|
||||
// Code generated by hostgen. DO NOT EDIT.
|
||||
//
|
||||
// This file contains client wrappers for the Comprehensive 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 ComprehensiveSimpleParamsRequest {
|
||||
name: String,
|
||||
count: i32,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct ComprehensiveSimpleParamsResponse {
|
||||
#[serde(default)]
|
||||
result: String,
|
||||
#[serde(default)]
|
||||
error: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct ComprehensiveStructParamRequest {
|
||||
user: serde_json::Value,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct ComprehensiveStructParamResponse {
|
||||
#[serde(default)]
|
||||
error: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct ComprehensiveMixedParamsRequest {
|
||||
id: String,
|
||||
filter: serde_json::Value,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct ComprehensiveMixedParamsResponse {
|
||||
#[serde(default)]
|
||||
result: i32,
|
||||
#[serde(default)]
|
||||
error: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct ComprehensiveNoErrorRequest {
|
||||
name: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct ComprehensiveNoErrorResponse {
|
||||
#[serde(default)]
|
||||
result: String,
|
||||
#[serde(default)]
|
||||
error: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct ComprehensiveNoParamsResponse {
|
||||
#[serde(default)]
|
||||
error: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct ComprehensiveNoParamsNoReturnsResponse {
|
||||
#[serde(default)]
|
||||
error: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct ComprehensivePointerParamsRequest {
|
||||
id: Option<String>,
|
||||
user: Option<serde_json::Value>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct ComprehensivePointerParamsResponse {
|
||||
#[serde(default)]
|
||||
result: Option<serde_json::Value>,
|
||||
#[serde(default)]
|
||||
error: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct ComprehensiveMapParamsRequest {
|
||||
data: std::collections::HashMap<String, serde_json::Value>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct ComprehensiveMapParamsResponse {
|
||||
#[serde(default)]
|
||||
result: serde_json::Value,
|
||||
#[serde(default)]
|
||||
error: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct ComprehensiveMultipleReturnsRequest {
|
||||
query: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct ComprehensiveMultipleReturnsResponse {
|
||||
#[serde(default)]
|
||||
results: Vec<serde_json::Value>,
|
||||
#[serde(default)]
|
||||
total: i32,
|
||||
#[serde(default)]
|
||||
error: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct ComprehensiveByteSliceRequest {
|
||||
data: Vec<u8>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct ComprehensiveByteSliceResponse {
|
||||
#[serde(default)]
|
||||
result: Vec<u8>,
|
||||
#[serde(default)]
|
||||
error: Option<String>,
|
||||
}
|
||||
|
||||
#[host_fn]
|
||||
extern "ExtismHost" {
|
||||
fn comprehensive_simpleparams(input: Json<ComprehensiveSimpleParamsRequest>) -> Json<ComprehensiveSimpleParamsResponse>;
|
||||
fn comprehensive_structparam(input: Json<ComprehensiveStructParamRequest>) -> Json<ComprehensiveStructParamResponse>;
|
||||
fn comprehensive_mixedparams(input: Json<ComprehensiveMixedParamsRequest>) -> Json<ComprehensiveMixedParamsResponse>;
|
||||
fn comprehensive_noerror(input: Json<ComprehensiveNoErrorRequest>) -> Json<ComprehensiveNoErrorResponse>;
|
||||
fn comprehensive_noparams(input: Json<serde_json::Value>) -> Json<ComprehensiveNoParamsResponse>;
|
||||
fn comprehensive_noparamsnoreturns(input: Json<serde_json::Value>) -> Json<ComprehensiveNoParamsNoReturnsResponse>;
|
||||
fn comprehensive_pointerparams(input: Json<ComprehensivePointerParamsRequest>) -> Json<ComprehensivePointerParamsResponse>;
|
||||
fn comprehensive_mapparams(input: Json<ComprehensiveMapParamsRequest>) -> Json<ComprehensiveMapParamsResponse>;
|
||||
fn comprehensive_multiplereturns(input: Json<ComprehensiveMultipleReturnsRequest>) -> Json<ComprehensiveMultipleReturnsResponse>;
|
||||
fn comprehensive_byteslice(input: Json<ComprehensiveByteSliceRequest>) -> Json<ComprehensiveByteSliceResponse>;
|
||||
}
|
||||
|
||||
/// Calls the comprehensive_simpleparams host function.
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `name` - String parameter.
|
||||
/// * `count` - i32 parameter.
|
||||
///
|
||||
/// # Returns
|
||||
/// The result value.
|
||||
///
|
||||
/// # Errors
|
||||
/// Returns an error if the host function call fails.
|
||||
pub fn simple_params(name: &str, count: i32) -> Result<String, Error> {
|
||||
let response = unsafe {
|
||||
comprehensive_simpleparams(Json(ComprehensiveSimpleParamsRequest {
|
||||
name: name.to_owned(),
|
||||
count: count,
|
||||
}))?
|
||||
};
|
||||
|
||||
if let Some(err) = response.0.error {
|
||||
return Err(Error::msg(err));
|
||||
}
|
||||
|
||||
Ok(response.0.result)
|
||||
}
|
||||
|
||||
/// Calls the comprehensive_structparam host function.
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `user` - serde_json::Value parameter.
|
||||
///
|
||||
/// # Errors
|
||||
/// Returns an error if the host function call fails.
|
||||
pub fn struct_param(user: serde_json::Value) -> Result<(), Error> {
|
||||
let response = unsafe {
|
||||
comprehensive_structparam(Json(ComprehensiveStructParamRequest {
|
||||
user: user,
|
||||
}))?
|
||||
};
|
||||
|
||||
if let Some(err) = response.0.error {
|
||||
return Err(Error::msg(err));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Calls the comprehensive_mixedparams host function.
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `id` - String parameter.
|
||||
/// * `filter` - serde_json::Value parameter.
|
||||
///
|
||||
/// # Returns
|
||||
/// The result value.
|
||||
///
|
||||
/// # Errors
|
||||
/// Returns an error if the host function call fails.
|
||||
pub fn mixed_params(id: &str, filter: serde_json::Value) -> Result<i32, Error> {
|
||||
let response = unsafe {
|
||||
comprehensive_mixedparams(Json(ComprehensiveMixedParamsRequest {
|
||||
id: id.to_owned(),
|
||||
filter: filter,
|
||||
}))?
|
||||
};
|
||||
|
||||
if let Some(err) = response.0.error {
|
||||
return Err(Error::msg(err));
|
||||
}
|
||||
|
||||
Ok(response.0.result)
|
||||
}
|
||||
|
||||
/// Calls the comprehensive_noerror host function.
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `name` - String parameter.
|
||||
///
|
||||
/// # Returns
|
||||
/// The result value.
|
||||
///
|
||||
/// # Errors
|
||||
/// Returns an error if the host function call fails.
|
||||
pub fn no_error(name: &str) -> Result<String, Error> {
|
||||
let response = unsafe {
|
||||
comprehensive_noerror(Json(ComprehensiveNoErrorRequest {
|
||||
name: name.to_owned(),
|
||||
}))?
|
||||
};
|
||||
|
||||
if let Some(err) = response.0.error {
|
||||
return Err(Error::msg(err));
|
||||
}
|
||||
|
||||
Ok(response.0.result)
|
||||
}
|
||||
|
||||
/// Calls the comprehensive_noparams host function.
|
||||
///
|
||||
/// # Errors
|
||||
/// Returns an error if the host function call fails.
|
||||
pub fn no_params() -> Result<(), Error> {
|
||||
let response = unsafe {
|
||||
comprehensive_noparams(Json(serde_json::json!({})))?
|
||||
};
|
||||
|
||||
if let Some(err) = response.0.error {
|
||||
return Err(Error::msg(err));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Calls the comprehensive_noparamsnoreturns host function.
|
||||
///
|
||||
/// # Errors
|
||||
/// Returns an error if the host function call fails.
|
||||
pub fn no_params_no_returns() -> Result<(), Error> {
|
||||
let response = unsafe {
|
||||
comprehensive_noparamsnoreturns(Json(serde_json::json!({})))?
|
||||
};
|
||||
|
||||
if let Some(err) = response.0.error {
|
||||
return Err(Error::msg(err));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Calls the comprehensive_pointerparams host function.
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `id` - Option<String> parameter.
|
||||
/// * `user` - Option<serde_json::Value> parameter.
|
||||
///
|
||||
/// # Returns
|
||||
/// The result value.
|
||||
///
|
||||
/// # Errors
|
||||
/// Returns an error if the host function call fails.
|
||||
pub fn pointer_params(id: Option<String>, user: Option<serde_json::Value>) -> Result<Option<serde_json::Value>, Error> {
|
||||
let response = unsafe {
|
||||
comprehensive_pointerparams(Json(ComprehensivePointerParamsRequest {
|
||||
id: id,
|
||||
user: user,
|
||||
}))?
|
||||
};
|
||||
|
||||
if let Some(err) = response.0.error {
|
||||
return Err(Error::msg(err));
|
||||
}
|
||||
|
||||
Ok(response.0.result)
|
||||
}
|
||||
|
||||
/// Calls the comprehensive_mapparams host function.
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `data` - std::collections::HashMap<String, serde_json::Value> parameter.
|
||||
///
|
||||
/// # Returns
|
||||
/// The result value.
|
||||
///
|
||||
/// # Errors
|
||||
/// Returns an error if the host function call fails.
|
||||
pub fn map_params(data: std::collections::HashMap<String, serde_json::Value>) -> Result<serde_json::Value, Error> {
|
||||
let response = unsafe {
|
||||
comprehensive_mapparams(Json(ComprehensiveMapParamsRequest {
|
||||
data: data,
|
||||
}))?
|
||||
};
|
||||
|
||||
if let Some(err) = response.0.error {
|
||||
return Err(Error::msg(err));
|
||||
}
|
||||
|
||||
Ok(response.0.result)
|
||||
}
|
||||
|
||||
/// Calls the comprehensive_multiplereturns host function.
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `query` - String parameter.
|
||||
///
|
||||
/// # Returns
|
||||
/// A tuple of (results, total).
|
||||
///
|
||||
/// # Errors
|
||||
/// Returns an error if the host function call fails.
|
||||
pub fn multiple_returns(query: &str) -> Result<(Vec<serde_json::Value>, i32), Error> {
|
||||
let response = unsafe {
|
||||
comprehensive_multiplereturns(Json(ComprehensiveMultipleReturnsRequest {
|
||||
query: query.to_owned(),
|
||||
}))?
|
||||
};
|
||||
|
||||
if let Some(err) = response.0.error {
|
||||
return Err(Error::msg(err));
|
||||
}
|
||||
|
||||
Ok((response.0.results, response.0.total))
|
||||
}
|
||||
|
||||
/// Calls the comprehensive_byteslice host function.
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `data` - Vec<u8> parameter.
|
||||
///
|
||||
/// # Returns
|
||||
/// The result value.
|
||||
///
|
||||
/// # Errors
|
||||
/// Returns an error if the host function call fails.
|
||||
pub fn byte_slice(data: Vec<u8>) -> Result<Vec<u8>, Error> {
|
||||
let response = unsafe {
|
||||
comprehensive_byteslice(Json(ComprehensiveByteSliceRequest {
|
||||
data: data,
|
||||
}))?
|
||||
};
|
||||
|
||||
if let Some(err) = response.0.error {
|
||||
return Err(Error::msg(err));
|
||||
}
|
||||
|
||||
Ok(response.0.result)
|
||||
}
|
||||
51
plugins/cmd/hostgen/testdata/counter_client_expected.rs
vendored
Normal file
51
plugins/cmd/hostgen/testdata/counter_client_expected.rs
vendored
Normal file
@ -0,0 +1,51 @@
|
||||
// Code generated by hostgen. DO NOT EDIT.
|
||||
//
|
||||
// This file contains client wrappers for the Counter 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 CounterCountRequest {
|
||||
name: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct CounterCountResponse {
|
||||
#[serde(default)]
|
||||
value: i32,
|
||||
#[serde(default)]
|
||||
error: Option<String>,
|
||||
}
|
||||
|
||||
#[host_fn]
|
||||
extern "ExtismHost" {
|
||||
fn counter_count(input: Json<CounterCountRequest>) -> Json<CounterCountResponse>;
|
||||
}
|
||||
|
||||
/// Calls the counter_count host function.
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `name` - String parameter.
|
||||
///
|
||||
/// # Returns
|
||||
/// The value value.
|
||||
///
|
||||
/// # Errors
|
||||
/// Returns an error if the host function call fails.
|
||||
pub fn count(name: &str) -> Result<i32, Error> {
|
||||
let response = unsafe {
|
||||
counter_count(Json(CounterCountRequest {
|
||||
name: name.to_owned(),
|
||||
}))?
|
||||
};
|
||||
|
||||
if let Some(err) = response.0.error {
|
||||
return Err(Error::msg(err));
|
||||
}
|
||||
|
||||
Ok(response.0.value)
|
||||
}
|
||||
51
plugins/cmd/hostgen/testdata/echo_client_expected.rs
vendored
Normal file
51
plugins/cmd/hostgen/testdata/echo_client_expected.rs
vendored
Normal file
@ -0,0 +1,51 @@
|
||||
// Code generated by hostgen. DO NOT EDIT.
|
||||
//
|
||||
// This file contains client wrappers for the Echo 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 EchoEchoRequest {
|
||||
message: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct EchoEchoResponse {
|
||||
#[serde(default)]
|
||||
reply: String,
|
||||
#[serde(default)]
|
||||
error: Option<String>,
|
||||
}
|
||||
|
||||
#[host_fn]
|
||||
extern "ExtismHost" {
|
||||
fn echo_echo(input: Json<EchoEchoRequest>) -> Json<EchoEchoResponse>;
|
||||
}
|
||||
|
||||
/// Calls the echo_echo host function.
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `message` - String parameter.
|
||||
///
|
||||
/// # Returns
|
||||
/// The reply value.
|
||||
///
|
||||
/// # Errors
|
||||
/// Returns an error if the host function call fails.
|
||||
pub fn echo(message: &str) -> Result<String, Error> {
|
||||
let response = unsafe {
|
||||
echo_echo(Json(EchoEchoRequest {
|
||||
message: message.to_owned(),
|
||||
}))?
|
||||
};
|
||||
|
||||
if let Some(err) = response.0.error {
|
||||
return Err(Error::msg(err));
|
||||
}
|
||||
|
||||
Ok(response.0.reply)
|
||||
}
|
||||
54
plugins/cmd/hostgen/testdata/list_client_expected.rs
vendored
Normal file
54
plugins/cmd/hostgen/testdata/list_client_expected.rs
vendored
Normal file
@ -0,0 +1,54 @@
|
||||
// Code generated by hostgen. DO NOT EDIT.
|
||||
//
|
||||
// This file contains client wrappers for the List 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 ListItemsRequest {
|
||||
name: String,
|
||||
filter: serde_json::Value,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct ListItemsResponse {
|
||||
#[serde(default)]
|
||||
count: i32,
|
||||
#[serde(default)]
|
||||
error: Option<String>,
|
||||
}
|
||||
|
||||
#[host_fn]
|
||||
extern "ExtismHost" {
|
||||
fn list_items(input: Json<ListItemsRequest>) -> Json<ListItemsResponse>;
|
||||
}
|
||||
|
||||
/// Calls the list_items host function.
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `name` - String parameter.
|
||||
/// * `filter` - serde_json::Value parameter.
|
||||
///
|
||||
/// # Returns
|
||||
/// The count value.
|
||||
///
|
||||
/// # Errors
|
||||
/// Returns an error if the host function call fails.
|
||||
pub fn items(name: &str, filter: serde_json::Value) -> Result<i32, Error> {
|
||||
let response = unsafe {
|
||||
list_items(Json(ListItemsRequest {
|
||||
name: name.to_owned(),
|
||||
filter: filter,
|
||||
}))?
|
||||
};
|
||||
|
||||
if let Some(err) = response.0.error {
|
||||
return Err(Error::msg(err));
|
||||
}
|
||||
|
||||
Ok(response.0.count)
|
||||
}
|
||||
54
plugins/cmd/hostgen/testdata/math_client_expected.rs
vendored
Normal file
54
plugins/cmd/hostgen/testdata/math_client_expected.rs
vendored
Normal file
@ -0,0 +1,54 @@
|
||||
// Code generated by hostgen. DO NOT EDIT.
|
||||
//
|
||||
// This file contains client wrappers for the Math 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 MathAddRequest {
|
||||
a: i32,
|
||||
b: i32,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct MathAddResponse {
|
||||
#[serde(default)]
|
||||
result: i32,
|
||||
#[serde(default)]
|
||||
error: Option<String>,
|
||||
}
|
||||
|
||||
#[host_fn]
|
||||
extern "ExtismHost" {
|
||||
fn math_add(input: Json<MathAddRequest>) -> Json<MathAddResponse>;
|
||||
}
|
||||
|
||||
/// Calls the math_add host function.
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `a` - i32 parameter.
|
||||
/// * `b` - i32 parameter.
|
||||
///
|
||||
/// # Returns
|
||||
/// The result value.
|
||||
///
|
||||
/// # Errors
|
||||
/// Returns an error if the host function call fails.
|
||||
pub fn add(a: i32, b: i32) -> Result<i32, Error> {
|
||||
let response = unsafe {
|
||||
math_add(Json(MathAddRequest {
|
||||
a: a,
|
||||
b: b,
|
||||
}))?
|
||||
};
|
||||
|
||||
if let Some(err) = response.0.error {
|
||||
return Err(Error::msg(err));
|
||||
}
|
||||
|
||||
Ok(response.0.result)
|
||||
}
|
||||
86
plugins/cmd/hostgen/testdata/meta_client_expected.rs
vendored
Normal file
86
plugins/cmd/hostgen/testdata/meta_client_expected.rs
vendored
Normal file
@ -0,0 +1,86 @@
|
||||
// Code generated by hostgen. DO NOT EDIT.
|
||||
//
|
||||
// This file contains client wrappers for the Meta 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 MetaGetRequest {
|
||||
key: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct MetaGetResponse {
|
||||
#[serde(default)]
|
||||
value: serde_json::Value,
|
||||
#[serde(default)]
|
||||
error: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct MetaSetRequest {
|
||||
data: std::collections::HashMap<String, serde_json::Value>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct MetaSetResponse {
|
||||
#[serde(default)]
|
||||
error: Option<String>,
|
||||
}
|
||||
|
||||
#[host_fn]
|
||||
extern "ExtismHost" {
|
||||
fn meta_get(input: Json<MetaGetRequest>) -> Json<MetaGetResponse>;
|
||||
fn meta_set(input: Json<MetaSetRequest>) -> Json<MetaSetResponse>;
|
||||
}
|
||||
|
||||
/// Calls the meta_get host function.
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `key` - String parameter.
|
||||
///
|
||||
/// # Returns
|
||||
/// The value value.
|
||||
///
|
||||
/// # Errors
|
||||
/// Returns an error if the host function call fails.
|
||||
pub fn get(key: &str) -> Result<serde_json::Value, Error> {
|
||||
let response = unsafe {
|
||||
meta_get(Json(MetaGetRequest {
|
||||
key: key.to_owned(),
|
||||
}))?
|
||||
};
|
||||
|
||||
if let Some(err) = response.0.error {
|
||||
return Err(Error::msg(err));
|
||||
}
|
||||
|
||||
Ok(response.0.value)
|
||||
}
|
||||
|
||||
/// Calls the meta_set host function.
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `data` - std::collections::HashMap<String, serde_json::Value> parameter.
|
||||
///
|
||||
/// # Errors
|
||||
/// Returns an error if the host function call fails.
|
||||
pub fn set(data: std::collections::HashMap<String, serde_json::Value>) -> Result<(), Error> {
|
||||
let response = unsafe {
|
||||
meta_set(Json(MetaSetRequest {
|
||||
data: data,
|
||||
}))?
|
||||
};
|
||||
|
||||
if let Some(err) = response.0.error {
|
||||
return Err(Error::msg(err));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
35
plugins/cmd/hostgen/testdata/ping_client_expected.rs
vendored
Normal file
35
plugins/cmd/hostgen/testdata/ping_client_expected.rs
vendored
Normal file
@ -0,0 +1,35 @@
|
||||
// Code generated by hostgen. DO NOT EDIT.
|
||||
//
|
||||
// This file contains client wrappers for the Ping host service.
|
||||
// It is intended for use in Navidrome plugins built with extism-pdk.
|
||||
|
||||
use extism_pdk::*;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct PingPingResponse {
|
||||
#[serde(default)]
|
||||
error: Option<String>,
|
||||
}
|
||||
|
||||
#[host_fn]
|
||||
extern "ExtismHost" {
|
||||
fn ping_ping(input: Json<serde_json::Value>) -> Json<PingPingResponse>;
|
||||
}
|
||||
|
||||
/// Calls the ping_ping host function.
|
||||
///
|
||||
/// # Errors
|
||||
/// Returns an error if the host function call fails.
|
||||
pub fn ping() -> Result<(), Error> {
|
||||
let response = unsafe {
|
||||
ping_ping(Json(serde_json::json!({})))?
|
||||
};
|
||||
|
||||
if let Some(err) = response.0.error {
|
||||
return Err(Error::msg(err));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
53
plugins/cmd/hostgen/testdata/search_client_expected.rs
vendored
Normal file
53
plugins/cmd/hostgen/testdata/search_client_expected.rs
vendored
Normal file
@ -0,0 +1,53 @@
|
||||
// Code generated by hostgen. DO NOT EDIT.
|
||||
//
|
||||
// This file contains client wrappers for the Search 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 SearchFindRequest {
|
||||
query: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct SearchFindResponse {
|
||||
#[serde(default)]
|
||||
results: Vec<serde_json::Value>,
|
||||
#[serde(default)]
|
||||
total: i32,
|
||||
#[serde(default)]
|
||||
error: Option<String>,
|
||||
}
|
||||
|
||||
#[host_fn]
|
||||
extern "ExtismHost" {
|
||||
fn search_find(input: Json<SearchFindRequest>) -> Json<SearchFindResponse>;
|
||||
}
|
||||
|
||||
/// Calls the search_find host function.
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `query` - String parameter.
|
||||
///
|
||||
/// # Returns
|
||||
/// A tuple of (results, total).
|
||||
///
|
||||
/// # Errors
|
||||
/// Returns an error if the host function call fails.
|
||||
pub fn find(query: &str) -> Result<(Vec<serde_json::Value>, i32), Error> {
|
||||
let response = unsafe {
|
||||
search_find(Json(SearchFindRequest {
|
||||
query: query.to_owned(),
|
||||
}))?
|
||||
};
|
||||
|
||||
if let Some(err) = response.0.error {
|
||||
return Err(Error::msg(err));
|
||||
}
|
||||
|
||||
Ok((response.0.results, response.0.total))
|
||||
}
|
||||
51
plugins/cmd/hostgen/testdata/store_client_expected.rs
vendored
Normal file
51
plugins/cmd/hostgen/testdata/store_client_expected.rs
vendored
Normal file
@ -0,0 +1,51 @@
|
||||
// Code generated by hostgen. DO NOT EDIT.
|
||||
//
|
||||
// This file contains client wrappers for the Store 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 StoreSaveRequest {
|
||||
item: serde_json::Value,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct StoreSaveResponse {
|
||||
#[serde(default)]
|
||||
id: String,
|
||||
#[serde(default)]
|
||||
error: Option<String>,
|
||||
}
|
||||
|
||||
#[host_fn]
|
||||
extern "ExtismHost" {
|
||||
fn store_save(input: Json<StoreSaveRequest>) -> Json<StoreSaveResponse>;
|
||||
}
|
||||
|
||||
/// Calls the store_save host function.
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `item` - serde_json::Value parameter.
|
||||
///
|
||||
/// # Returns
|
||||
/// The id value.
|
||||
///
|
||||
/// # Errors
|
||||
/// Returns an error if the host function call fails.
|
||||
pub fn save(item: serde_json::Value) -> Result<String, Error> {
|
||||
let response = unsafe {
|
||||
store_save(Json(StoreSaveRequest {
|
||||
item: item,
|
||||
}))?
|
||||
};
|
||||
|
||||
if let Some(err) = response.0.error {
|
||||
return Err(Error::msg(err));
|
||||
}
|
||||
|
||||
Ok(response.0.id)
|
||||
}
|
||||
54
plugins/cmd/hostgen/testdata/users_client_expected.rs
vendored
Normal file
54
plugins/cmd/hostgen/testdata/users_client_expected.rs
vendored
Normal file
@ -0,0 +1,54 @@
|
||||
// Code generated by hostgen. DO NOT EDIT.
|
||||
//
|
||||
// This file contains client wrappers for the Users 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 UsersGetRequest {
|
||||
id: Option<String>,
|
||||
filter: Option<serde_json::Value>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct UsersGetResponse {
|
||||
#[serde(default)]
|
||||
result: Option<serde_json::Value>,
|
||||
#[serde(default)]
|
||||
error: Option<String>,
|
||||
}
|
||||
|
||||
#[host_fn]
|
||||
extern "ExtismHost" {
|
||||
fn users_get(input: Json<UsersGetRequest>) -> Json<UsersGetResponse>;
|
||||
}
|
||||
|
||||
/// Calls the users_get host function.
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `id` - Option<String> parameter.
|
||||
/// * `filter` - Option<serde_json::Value> parameter.
|
||||
///
|
||||
/// # Returns
|
||||
/// The result value.
|
||||
///
|
||||
/// # Errors
|
||||
/// Returns an error if the host function call fails.
|
||||
pub fn get(id: Option<String>, filter: Option<serde_json::Value>) -> Result<Option<serde_json::Value>, Error> {
|
||||
let response = unsafe {
|
||||
users_get(Json(UsersGetRequest {
|
||||
id: id,
|
||||
filter: filter,
|
||||
}))?
|
||||
};
|
||||
|
||||
if let Some(err) = response.0.error {
|
||||
return Err(Error::msg(err));
|
||||
}
|
||||
|
||||
Ok(response.0.result)
|
||||
}
|
||||
@ -4,16 +4,17 @@ This folder contains example plugins demonstrating various capabilities and lang
|
||||
|
||||
## Available Examples
|
||||
|
||||
| Plugin | Language | Capabilities | Description |
|
||||
|-------------------------------------------------|----------|-------------------------------------------------|--------------------------------|
|
||||
| [minimal](minimal/) | Go | MetadataAgent | Basic plugin structure |
|
||||
| [wikimedia](wikimedia/) | Go | MetadataAgent | Wikidata/Wikipedia metadata |
|
||||
| [crypto-ticker](crypto-ticker/) | Go | Scheduler, WebSocket, Cache | Real-time crypto prices (demo) |
|
||||
| [discord-rich-presence](discord-rich-presence/) | Go | Scrobbler, Scheduler, WebSocket, Cache, Artwork | Discord integration |
|
||||
| [coverartarchive-py](coverartarchive-py/) | Python | MetadataAgent | Cover Art Archive |
|
||||
| [nowplaying-py](nowplaying-py/) | Python | Scheduler, SubsonicAPI | Now playing logger |
|
||||
| [webhook-rs](webhook-rs/) | Rust | Scrobbler | HTTP webhook on scrobble |
|
||||
| [library-inspector](library-inspector/) | Rust | Library, Scheduler | Periodic library stats logging |
|
||||
| Plugin | Language | Capabilities | Description |
|
||||
|-------------------------------------------------------|----------|-------------------------------------------------|--------------------------------|
|
||||
| [minimal](minimal/) | Go | MetadataAgent | Basic plugin structure |
|
||||
| [wikimedia](wikimedia/) | Go | MetadataAgent | Wikidata/Wikipedia metadata |
|
||||
| [crypto-ticker](crypto-ticker/) | Go | Scheduler, WebSocket, Cache | Real-time crypto prices (demo) |
|
||||
| [discord-rich-presence](discord-rich-presence/) | Go | Scrobbler, Scheduler, WebSocket, Cache, Artwork | Discord integration |
|
||||
| [coverartarchive-py](coverartarchive-py/) | Python | MetadataAgent | Cover Art Archive |
|
||||
| [nowplaying-py](nowplaying-py/) | Python | Scheduler, SubsonicAPI | Now playing logger |
|
||||
| [webhook-rs](webhook-rs/) | Rust | Scrobbler | HTTP webhook on scrobble |
|
||||
| [library-inspector](library-inspector/) | Rust | Library, Scheduler | Periodic library stats logging |
|
||||
| [discord-rich-presence-rs](discord-rich-presence-rs/) | Rust | Scrobbler, Scheduler, WebSocket, Cache, Artwork | Discord integration (Rust) |
|
||||
|
||||
## Building
|
||||
|
||||
|
||||
@ -33,12 +33,12 @@ func scheduler_cancelschedule(uint64) uint64
|
||||
type SchedulerScheduleOneTimeRequest struct {
|
||||
DelaySeconds int32 `json:"delaySeconds"`
|
||||
Payload string `json:"payload"`
|
||||
ScheduleID string `json:"scheduleID"`
|
||||
ScheduleID string `json:"scheduleId"`
|
||||
}
|
||||
|
||||
// SchedulerScheduleOneTimeResponse is the response type for Scheduler.ScheduleOneTime.
|
||||
type SchedulerScheduleOneTimeResponse struct {
|
||||
NewScheduleID string `json:"newScheduleID,omitempty"`
|
||||
NewScheduleID string `json:"newScheduleId,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
@ -46,18 +46,18 @@ type SchedulerScheduleOneTimeResponse struct {
|
||||
type SchedulerScheduleRecurringRequest struct {
|
||||
CronExpression string `json:"cronExpression"`
|
||||
Payload string `json:"payload"`
|
||||
ScheduleID string `json:"scheduleID"`
|
||||
ScheduleID string `json:"scheduleId"`
|
||||
}
|
||||
|
||||
// SchedulerScheduleRecurringResponse is the response type for Scheduler.ScheduleRecurring.
|
||||
type SchedulerScheduleRecurringResponse struct {
|
||||
NewScheduleID string `json:"newScheduleID,omitempty"`
|
||||
NewScheduleID string `json:"newScheduleId,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
// SchedulerCancelScheduleRequest is the request type for Scheduler.CancelSchedule.
|
||||
type SchedulerCancelScheduleRequest struct {
|
||||
ScheduleID string `json:"scheduleID"`
|
||||
ScheduleID string `json:"scheduleId"`
|
||||
}
|
||||
|
||||
// SchedulerCancelScheduleResponse is the response type for Scheduler.CancelSchedule.
|
||||
|
||||
@ -38,18 +38,18 @@ func websocket_closeconnection(uint64) uint64
|
||||
type WebSocketConnectRequest struct {
|
||||
Url string `json:"url"`
|
||||
Headers map[string]string `json:"headers"`
|
||||
ConnectionID string `json:"connectionID"`
|
||||
ConnectionID string `json:"connectionId"`
|
||||
}
|
||||
|
||||
// WebSocketConnectResponse is the response type for WebSocket.Connect.
|
||||
type WebSocketConnectResponse struct {
|
||||
NewConnectionID string `json:"newConnectionID,omitempty"`
|
||||
NewConnectionID string `json:"newConnectionId,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
// WebSocketSendTextRequest is the request type for WebSocket.SendText.
|
||||
type WebSocketSendTextRequest struct {
|
||||
ConnectionID string `json:"connectionID"`
|
||||
ConnectionID string `json:"connectionId"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
@ -60,7 +60,7 @@ type WebSocketSendTextResponse struct {
|
||||
|
||||
// WebSocketSendBinaryRequest is the request type for WebSocket.SendBinary.
|
||||
type WebSocketSendBinaryRequest struct {
|
||||
ConnectionID string `json:"connectionID"`
|
||||
ConnectionID string `json:"connectionId"`
|
||||
Data []byte `json:"data"`
|
||||
}
|
||||
|
||||
@ -71,7 +71,7 @@ type WebSocketSendBinaryResponse struct {
|
||||
|
||||
// WebSocketCloseConnectionRequest is the request type for WebSocket.CloseConnection.
|
||||
type WebSocketCloseConnectionRequest struct {
|
||||
ConnectionID string `json:"connectionID"`
|
||||
ConnectionID string `json:"connectionId"`
|
||||
Code int32 `json:"code"`
|
||||
Reason string `json:"reason"`
|
||||
}
|
||||
|
||||
@ -0,0 +1,2 @@
|
||||
[build]
|
||||
target = "wasm32-wasip1"
|
||||
16
plugins/examples/discord-rich-presence-rs/Cargo.toml
Normal file
16
plugins/examples/discord-rich-presence-rs/Cargo.toml
Normal file
@ -0,0 +1,16 @@
|
||||
[package]
|
||||
name = "discord-rich-presence-rs"
|
||||
version = "1.0.0"
|
||||
edition = "2021"
|
||||
description = "Discord Rich Presence plugin for Navidrome - Rust implementation"
|
||||
authors = ["Navidrome Team"]
|
||||
license = "GPL-3.0"
|
||||
|
||||
[lib]
|
||||
crate-type = ["cdylib"]
|
||||
|
||||
[dependencies]
|
||||
extism-pdk = "1.2"
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
serde_json = "1.0"
|
||||
nd-host = { path = "../../host/rust" }
|
||||
94
plugins/examples/discord-rich-presence-rs/README.md
Normal file
94
plugins/examples/discord-rich-presence-rs/README.md
Normal file
@ -0,0 +1,94 @@
|
||||
# Discord Rich Presence Plugin (Rust)
|
||||
|
||||
A Navidrome plugin that displays your currently playing track on Discord using Rich Presence. This is the Rust implementation demonstrating how to use the generated `nd-host` library.
|
||||
|
||||
## ⚠️ Warning
|
||||
|
||||
This plugin is for **demonstration purposes only**. It requires storing your Discord token in the Navidrome configuration file, which:
|
||||
|
||||
1. Is not secure (tokens should never be stored in plain text)
|
||||
2. May violate Discord's Terms of Service
|
||||
|
||||
**Use at your own risk.**
|
||||
|
||||
## Features
|
||||
|
||||
- Shows currently playing track on Discord Rich Presence
|
||||
- Displays album artwork
|
||||
- Shows track progress with start/end timestamps
|
||||
- Automatically clears presence when track finishes
|
||||
- Supports multiple users
|
||||
|
||||
## Capabilities
|
||||
|
||||
This plugin implements three capabilities to demonstrate the nd-host library:
|
||||
|
||||
- **Scrobbler**: Receives now-playing events from Navidrome
|
||||
- **SchedulerCallback**: Handles heartbeat and activity clearing timers
|
||||
- **WebSocketCallback**: Communicates with Discord gateway
|
||||
|
||||
## Configuration
|
||||
|
||||
Configure in the Navidrome UI (Settings → Plugins → discord-rich-presence):
|
||||
|
||||
| Key | Description | Example |
|
||||
|------------|----------------------------------------------------------|--------------------------------|
|
||||
| `clientid` | Your Discord application ID | `123456789012345678` |
|
||||
| `users` | Comma-separated list of `username:token` pairs | `alice:token123,bob:token456` |
|
||||
|
||||
|
||||
### Getting Configuration Values
|
||||
|
||||
1. **Client ID**: Create a Discord Application at https://discord.com/developers/applications and copy the Application ID
|
||||
|
||||
2. **Discord Token**: This requires extracting your user token from Discord (not recommended for security reasons)
|
||||
|
||||
3. **Multiple Users**: Separate user mappings with commas:
|
||||
```properties
|
||||
users = "user1:token1,user2:token2"
|
||||
```
|
||||
|
||||
## Building
|
||||
|
||||
```bash
|
||||
# From the plugins/examples directory
|
||||
make discord-rich-presence-rs.ndp
|
||||
|
||||
# This creates discord-rich-presence-rs.ndp containing:
|
||||
# - manifest.json
|
||||
# - plugin.wasm
|
||||
```
|
||||
|
||||
## Installation
|
||||
|
||||
1. Build the plugin using the command above
|
||||
2. Copy the `.ndp` file to your Navidrome plugins directory
|
||||
3. Enable and configure the plugin in the Navidrome UI (Settings → Plugins)
|
||||
4. Restart Navidrome if needed
|
||||
|
||||
## Using nd-host Library
|
||||
|
||||
This plugin demonstrates how to use the generated Rust host function wrappers:
|
||||
|
||||
```rust
|
||||
use nd_host::{artwork, cache, scheduler, websocket};
|
||||
|
||||
// Get artwork URL
|
||||
let (url, _) = artwork::artwork_get_track_url(track_id, 300)?;
|
||||
|
||||
// Cache operations
|
||||
cache::cache_set_string("key", "value", 3600)?;
|
||||
let (value, exists) = cache::cache_get_string("key")?;
|
||||
|
||||
// Schedule tasks
|
||||
scheduler::scheduler_schedule_one_time(60, "payload", "task-id")?;
|
||||
scheduler::scheduler_schedule_recurring("@every 30s", "heartbeat", "heartbeat-task")?;
|
||||
|
||||
// WebSocket operations
|
||||
let conn_id = websocket::websocket_connect("wss://example.com/socket")?;
|
||||
websocket::websocket_send_text(&conn_id, "Hello")?;
|
||||
```
|
||||
|
||||
## License
|
||||
|
||||
GPL-3.0
|
||||
26
plugins/examples/discord-rich-presence-rs/manifest.json
Normal file
26
plugins/examples/discord-rich-presence-rs/manifest.json
Normal file
@ -0,0 +1,26 @@
|
||||
{
|
||||
"name": "Discord Rich Presence (Rust)",
|
||||
"author": "Navidrome Team",
|
||||
"version": "1.0.0",
|
||||
"description": "Discord Rich Presence integration for Navidrome - Rust implementation",
|
||||
"website": "https://github.com/navidrome/navidrome/tree/master/plugins/examples/discord-rich-presence-rs",
|
||||
"permissions": {
|
||||
"http": {
|
||||
"reason": "To communicate with Discord API for gateway discovery and image uploads",
|
||||
"allowedHosts": ["discord.com"]
|
||||
},
|
||||
"websocket": {
|
||||
"reason": "To maintain real-time connection with Discord gateway",
|
||||
"allowedHosts": ["gateway.discord.gg"]
|
||||
},
|
||||
"cache": {
|
||||
"reason": "To store connection state and sequence numbers"
|
||||
},
|
||||
"scheduler": {
|
||||
"reason": "To schedule heartbeat messages and activity clearing"
|
||||
},
|
||||
"artwork": {
|
||||
"reason": "To get track artwork URLs for rich presence display"
|
||||
}
|
||||
}
|
||||
}
|
||||
419
plugins/examples/discord-rich-presence-rs/src/lib.rs
Normal file
419
plugins/examples/discord-rich-presence-rs/src/lib.rs
Normal file
@ -0,0 +1,419 @@
|
||||
//! Discord Rich Presence Plugin for Navidrome - Rust Implementation
|
||||
//!
|
||||
//! This plugin integrates Navidrome with Discord Rich Presence. It demonstrates how to:
|
||||
//! - Use the generated nd-host wrappers for host service calls
|
||||
//! - Implement the Scrobbler capability for now-playing updates
|
||||
//! - Implement SchedulerCallback for heartbeat and activity clearing
|
||||
//! - Implement WebSocketCallback for Discord gateway communication
|
||||
//!
|
||||
//! ## Configuration
|
||||
//!
|
||||
//! ```toml
|
||||
//! [PluginConfig.discord-rich-presence-rs]
|
||||
//! clientid = "YOUR_DISCORD_APPLICATION_ID"
|
||||
//! users = "username1:discord_token1,username2:discord_token2"
|
||||
//! ```
|
||||
//!
|
||||
//! **WARNING**: This plugin is for demonstration purposes only. Storing Discord tokens
|
||||
//! in configuration files is not secure and may violate Discord's terms of service.
|
||||
|
||||
use extism_pdk::*;
|
||||
use nd_host::{artwork, scheduler};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
mod rpc;
|
||||
|
||||
// ============================================================================
|
||||
// Constants
|
||||
// ============================================================================
|
||||
|
||||
const CLIENT_ID_KEY: &str = "clientid";
|
||||
const USERS_KEY: &str = "users";
|
||||
const PAYLOAD_HEARTBEAT: &str = "heartbeat";
|
||||
const PAYLOAD_CLEAR_ACTIVITY: &str = "clear-activity";
|
||||
|
||||
// ============================================================================
|
||||
// Configuration
|
||||
// ============================================================================
|
||||
|
||||
fn get_config() -> Result<(String, std::collections::HashMap<String, String>), Error> {
|
||||
let client_id = config::get(CLIENT_ID_KEY)?
|
||||
.filter(|s| !s.is_empty())
|
||||
.ok_or_else(|| Error::msg("missing clientid in configuration"))?;
|
||||
|
||||
let users_config = config::get(USERS_KEY)?
|
||||
.filter(|s| !s.is_empty())
|
||||
.unwrap_or_default();
|
||||
|
||||
let mut users = std::collections::HashMap::new();
|
||||
for user in users_config.split(',') {
|
||||
let parts: Vec<&str> = user.split(':').collect();
|
||||
if parts.len() == 2 {
|
||||
users.insert(parts[0].trim().to_string(), parts[1].trim().to_string());
|
||||
}
|
||||
}
|
||||
|
||||
Ok((client_id, users))
|
||||
}
|
||||
|
||||
fn get_image_url(track_id: &str) -> String {
|
||||
match artwork::get_track_url(track_id, 300) {
|
||||
Ok(url) => {
|
||||
if url.starts_with("http://localhost") {
|
||||
String::new()
|
||||
} else {
|
||||
url
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
warn!("Failed to get artwork URL: {:?}", e);
|
||||
String::new()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Scrobbler Types
|
||||
// ============================================================================
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct AuthInput {
|
||||
#[allow(dead_code)]
|
||||
user_id: String,
|
||||
username: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct AuthOutput {
|
||||
authorized: bool,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[allow(dead_code)]
|
||||
struct TrackInfo {
|
||||
id: String,
|
||||
title: String,
|
||||
album: String,
|
||||
artist: String,
|
||||
album_artist: String,
|
||||
duration: f32,
|
||||
track_number: i32,
|
||||
disc_number: i32,
|
||||
#[serde(default)]
|
||||
mbz_recording_id: Option<String>,
|
||||
#[serde(default)]
|
||||
mbz_album_id: Option<String>,
|
||||
#[serde(default)]
|
||||
mbz_artist_id: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[allow(dead_code)]
|
||||
struct NowPlayingInput {
|
||||
user_id: String,
|
||||
username: String,
|
||||
track: TrackInfo,
|
||||
position: i32,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[allow(dead_code)]
|
||||
struct ScrobbleInput {
|
||||
user_id: String,
|
||||
username: String,
|
||||
track: TrackInfo,
|
||||
timestamp: i64,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Default)]
|
||||
struct ScrobblerOutput {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
error: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
error_type: Option<String>,
|
||||
}
|
||||
|
||||
const ERROR_TYPE_NOT_AUTHORIZED: &str = "not_authorized";
|
||||
const ERROR_TYPE_RETRY_LATER: &str = "retry_later";
|
||||
|
||||
// ============================================================================
|
||||
// Scheduler Callback Types
|
||||
// ============================================================================
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[allow(dead_code)]
|
||||
struct SchedulerCallbackInput {
|
||||
schedule_id: String,
|
||||
payload: String,
|
||||
is_recurring: bool,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Default)]
|
||||
struct SchedulerCallbackOutput {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
error: Option<String>,
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// WebSocket Callback Types
|
||||
// ============================================================================
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[allow(dead_code)]
|
||||
struct OnTextMessageInput {
|
||||
connection_id: String,
|
||||
message: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Default)]
|
||||
struct OnTextMessageOutput {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
error: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[allow(dead_code)]
|
||||
struct OnBinaryMessageInput {
|
||||
connection_id: String,
|
||||
message: Vec<u8>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Default)]
|
||||
struct OnBinaryMessageOutput {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
error: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[allow(dead_code)]
|
||||
struct OnErrorInput {
|
||||
connection_id: String,
|
||||
error: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Default)]
|
||||
struct OnErrorOutput {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
error: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[allow(dead_code)]
|
||||
struct OnCloseInput {
|
||||
connection_id: String,
|
||||
code: i32,
|
||||
reason: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Default)]
|
||||
struct OnCloseOutput {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
error: Option<String>,
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Scrobbler Plugin Exports
|
||||
// ============================================================================
|
||||
|
||||
/// Checks if a user is authorized for Discord Rich Presence.
|
||||
#[plugin_fn]
|
||||
pub fn nd_scrobbler_is_authorized(Json(input): Json<AuthInput>) -> FnResult<Json<AuthOutput>> {
|
||||
let (_, users) = match get_config() {
|
||||
Ok(config) => config,
|
||||
Err(e) => {
|
||||
error!("Failed to get config: {:?}", e);
|
||||
return Ok(Json(AuthOutput { authorized: false }));
|
||||
}
|
||||
};
|
||||
|
||||
let authorized = users.contains_key(&input.username);
|
||||
info!(
|
||||
"IsAuthorized for user {}: {}",
|
||||
input.username, authorized
|
||||
);
|
||||
Ok(Json(AuthOutput { authorized }))
|
||||
}
|
||||
|
||||
/// Sends a now playing notification to Discord.
|
||||
#[plugin_fn]
|
||||
pub fn nd_scrobbler_now_playing(
|
||||
Json(input): Json<NowPlayingInput>,
|
||||
) -> FnResult<Json<ScrobblerOutput>> {
|
||||
info!(
|
||||
"Setting presence for user {}, track: {}",
|
||||
input.username, input.track.title
|
||||
);
|
||||
|
||||
// Load configuration
|
||||
let (client_id, users) = match get_config() {
|
||||
Ok(config) => config,
|
||||
Err(e) => {
|
||||
let err_msg = format!("failed to get config: {:?}", e);
|
||||
return Ok(Json(ScrobblerOutput {
|
||||
error: Some(err_msg),
|
||||
error_type: Some(ERROR_TYPE_RETRY_LATER.to_string()),
|
||||
}));
|
||||
}
|
||||
};
|
||||
|
||||
// Check authorization
|
||||
let user_token = match users.get(&input.username) {
|
||||
Some(token) => token.clone(),
|
||||
None => {
|
||||
let err_msg = format!("user '{}' not authorized", input.username);
|
||||
return Ok(Json(ScrobblerOutput {
|
||||
error: Some(err_msg),
|
||||
error_type: Some(ERROR_TYPE_NOT_AUTHORIZED.to_string()),
|
||||
}));
|
||||
}
|
||||
};
|
||||
|
||||
// Connect to Discord
|
||||
if let Err(e) = rpc::connect(&input.username, &user_token) {
|
||||
let err_msg = format!("failed to connect to Discord: {:?}", e);
|
||||
return Ok(Json(ScrobblerOutput {
|
||||
error: Some(err_msg),
|
||||
error_type: Some(ERROR_TYPE_RETRY_LATER.to_string()),
|
||||
}));
|
||||
}
|
||||
|
||||
// Cancel any existing completion schedule
|
||||
let _ = scheduler::cancel_schedule(&format!("{}-clear", input.username));
|
||||
|
||||
// Calculate timestamps
|
||||
let now = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map(|d| d.as_secs() as i64)
|
||||
.unwrap_or(0);
|
||||
let start_time = (now - input.position as i64) * 1000;
|
||||
let end_time = start_time + (input.track.duration as i64) * 1000;
|
||||
|
||||
// Send activity update
|
||||
if let Err(e) = rpc::send_activity(
|
||||
&client_id,
|
||||
&input.username,
|
||||
&user_token,
|
||||
rpc::Activity {
|
||||
application: client_id.clone(),
|
||||
name: "Navidrome".to_string(),
|
||||
activity_type: 2, // Listening
|
||||
details: input.track.title.clone(),
|
||||
state: input.track.artist.clone(),
|
||||
timestamps: rpc::ActivityTimestamps {
|
||||
start: start_time,
|
||||
end: end_time,
|
||||
},
|
||||
assets: rpc::ActivityAssets {
|
||||
large_image: get_image_url(&input.track.id),
|
||||
large_text: input.track.album.clone(),
|
||||
},
|
||||
},
|
||||
) {
|
||||
let err_msg = format!("failed to send activity: {:?}", e);
|
||||
return Ok(Json(ScrobblerOutput {
|
||||
error: Some(err_msg),
|
||||
error_type: Some(ERROR_TYPE_RETRY_LATER.to_string()),
|
||||
}));
|
||||
}
|
||||
|
||||
// Schedule a timer to clear the activity after the track completes
|
||||
let remaining_seconds = (input.track.duration as i32) - input.position + 5;
|
||||
if let Err(e) = scheduler::schedule_one_time(
|
||||
remaining_seconds,
|
||||
PAYLOAD_CLEAR_ACTIVITY,
|
||||
&format!("{}-clear", input.username),
|
||||
) {
|
||||
warn!("Failed to schedule completion timer: {:?}", e);
|
||||
}
|
||||
|
||||
Ok(Json(ScrobblerOutput::default()))
|
||||
}
|
||||
|
||||
/// Handles scrobble requests (no-op for Discord Rich Presence).
|
||||
#[plugin_fn]
|
||||
pub fn nd_scrobbler_scrobble(_input: Json<ScrobbleInput>) -> FnResult<Json<ScrobblerOutput>> {
|
||||
// Discord Rich Presence doesn't need scrobble events
|
||||
Ok(Json(ScrobblerOutput::default()))
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Scheduler Callback Export
|
||||
// ============================================================================
|
||||
|
||||
/// Handles scheduler callbacks for heartbeat and activity clearing.
|
||||
#[plugin_fn]
|
||||
pub fn nd_scheduler_callback(
|
||||
Json(input): Json<SchedulerCallbackInput>,
|
||||
) -> FnResult<Json<SchedulerCallbackOutput>> {
|
||||
|
||||
match input.payload.as_str() {
|
||||
PAYLOAD_HEARTBEAT => {
|
||||
// Heartbeat callback - schedule_id is the username
|
||||
if let Err(e) = rpc::handle_heartbeat_callback(&input.schedule_id) {
|
||||
return Ok(Json(SchedulerCallbackOutput {
|
||||
error: Some(e.to_string()),
|
||||
}));
|
||||
}
|
||||
}
|
||||
PAYLOAD_CLEAR_ACTIVITY => {
|
||||
// Clear activity callback - schedule_id is "username-clear"
|
||||
let username = input.schedule_id.trim_end_matches("-clear");
|
||||
if let Err(e) = rpc::handle_clear_activity_callback(username) {
|
||||
return Ok(Json(SchedulerCallbackOutput {
|
||||
error: Some(e.to_string()),
|
||||
}));
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
warn!("Unknown scheduler callback payload: {}", input.payload);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(Json(SchedulerCallbackOutput::default()))
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// WebSocket Callback Exports
|
||||
// ============================================================================
|
||||
|
||||
/// Handles incoming WebSocket text messages.
|
||||
#[plugin_fn]
|
||||
pub fn nd_websocket_on_text_message(
|
||||
Json(input): Json<OnTextMessageInput>,
|
||||
) -> FnResult<Json<OnTextMessageOutput>> {
|
||||
if let Err(e) = rpc::handle_websocket_message(&input.connection_id, &input.message) {
|
||||
return Ok(Json(OnTextMessageOutput {
|
||||
error: Some(e.to_string()),
|
||||
}));
|
||||
}
|
||||
Ok(Json(OnTextMessageOutput::default()))
|
||||
}
|
||||
|
||||
/// Handles incoming WebSocket binary messages.
|
||||
#[plugin_fn]
|
||||
pub fn nd_websocket_on_binary_message(
|
||||
Json(_input): Json<OnBinaryMessageInput>,
|
||||
) -> FnResult<Json<OnBinaryMessageOutput>> {
|
||||
// Binary messages are not expected from Discord
|
||||
Ok(Json(OnBinaryMessageOutput::default()))
|
||||
}
|
||||
|
||||
/// Handles WebSocket errors.
|
||||
#[plugin_fn]
|
||||
pub fn nd_websocket_on_error(Json(input): Json<OnErrorInput>) -> FnResult<Json<OnErrorOutput>> {
|
||||
warn!(
|
||||
"WebSocket error for connection '{}': {}",
|
||||
input.connection_id, input.error
|
||||
);
|
||||
Ok(Json(OnErrorOutput::default()))
|
||||
}
|
||||
|
||||
/// Handles WebSocket connection closure.
|
||||
#[plugin_fn]
|
||||
pub fn nd_websocket_on_close(Json(input): Json<OnCloseInput>) -> FnResult<Json<OnCloseOutput>> {
|
||||
info!(
|
||||
"WebSocket connection '{}' closed with code {}: {}",
|
||||
input.connection_id, input.code, input.reason
|
||||
);
|
||||
Ok(Json(OnCloseOutput::default()))
|
||||
}
|
||||
480
plugins/examples/discord-rich-presence-rs/src/rpc.rs
Normal file
480
plugins/examples/discord-rich-presence-rs/src/rpc.rs
Normal file
@ -0,0 +1,480 @@
|
||||
//! Discord Rich Presence Plugin - RPC Communication
|
||||
//!
|
||||
//! This module handles all Discord gateway communication including WebSocket connections,
|
||||
//! presence updates, and heartbeat management.
|
||||
|
||||
use extism_pdk::*;
|
||||
use nd_host::{cache, scheduler, websocket};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
// ============================================================================
|
||||
// Constants
|
||||
// ============================================================================
|
||||
|
||||
const HEARTBEAT_OP_CODE: i32 = 1;
|
||||
const GATE_OP_CODE: i32 = 2;
|
||||
const PRESENCE_OP_CODE: i32 = 3;
|
||||
const HEARTBEAT_INTERVAL: i32 = 41;
|
||||
const DEFAULT_IMAGE: &str = "https://i.imgur.com/hb3XPzA.png";
|
||||
|
||||
const PAYLOAD_HEARTBEAT: &str = "heartbeat";
|
||||
|
||||
// ============================================================================
|
||||
// Discord Types
|
||||
// ============================================================================
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct Activity {
|
||||
pub name: String,
|
||||
#[serde(rename = "type")]
|
||||
pub activity_type: i32,
|
||||
pub details: String,
|
||||
pub state: String,
|
||||
#[serde(rename = "application_id")]
|
||||
pub application: String,
|
||||
pub timestamps: ActivityTimestamps,
|
||||
pub assets: ActivityAssets,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct ActivityTimestamps {
|
||||
pub start: i64,
|
||||
pub end: i64,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct ActivityAssets {
|
||||
pub large_image: String,
|
||||
pub large_text: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct PresencePayload {
|
||||
activities: Vec<Activity>,
|
||||
since: i64,
|
||||
status: String,
|
||||
afk: bool,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct IdentifyPayload {
|
||||
token: String,
|
||||
intents: i32,
|
||||
properties: IdentifyProperties,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct IdentifyProperties {
|
||||
os: String,
|
||||
browser: String,
|
||||
device: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct GatewayMessage<T> {
|
||||
op: i32,
|
||||
d: T,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct HelloMessage {
|
||||
#[allow(dead_code)]
|
||||
heartbeat_interval: i64,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct GatewayResponse {
|
||||
op: i32,
|
||||
#[serde(default)]
|
||||
d: Option<serde_json::Value>,
|
||||
#[serde(default)]
|
||||
s: Option<i64>,
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Cache Keys
|
||||
// ============================================================================
|
||||
|
||||
fn connection_key(username: &str) -> String {
|
||||
format!("discord.connection.{}", username)
|
||||
}
|
||||
|
||||
fn token_key(username: &str) -> String {
|
||||
format!("discord.token.{}", username)
|
||||
}
|
||||
|
||||
fn sequence_key(username: &str) -> String {
|
||||
format!("discord.sequence.{}", username)
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Connection Management
|
||||
// ============================================================================
|
||||
|
||||
/// Tests if the connection is still valid by trying to send a heartbeat.
|
||||
fn is_connected(username: &str) -> bool {
|
||||
match send_heartbeat(username) {
|
||||
Ok(_) => true,
|
||||
Err(e) => {
|
||||
trace!("Connection test failed for user {}: {:?}", username, e);
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Cleans up a failed connection for a user.
|
||||
fn cleanup_connection(username: &str) {
|
||||
info!("Cleaning up failed connection for user {}", username);
|
||||
|
||||
// Cancel the heartbeat schedule
|
||||
if let Err(e) = scheduler::cancel_schedule(username) {
|
||||
warn!("Failed to cancel heartbeat schedule for user {}: {:?}", username, e);
|
||||
}
|
||||
|
||||
// Try to close the WebSocket connection
|
||||
let conn_key = connection_key(username);
|
||||
if let Ok((conn_id, exists)) = cache::get_string(&conn_key) {
|
||||
if exists && !conn_id.is_empty() {
|
||||
if let Err(e) = websocket::close_connection(&conn_id, 1000, "Reconnecting") {
|
||||
trace!("Failed to close WebSocket for user {}: {:?}", username, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Clean up cache entries
|
||||
let _ = cache::remove(&conn_key);
|
||||
let _ = cache::remove(&sequence_key(username));
|
||||
|
||||
info!("Cleaned up connection for user {}", username);
|
||||
}
|
||||
|
||||
/// Connects to the Discord gateway for a user.
|
||||
pub fn connect(username: &str, token: &str) -> Result<(), Error> {
|
||||
// Check if already connected and connection is valid
|
||||
if is_connected(username) {
|
||||
info!("Reusing existing connection for user {}", username);
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// Clean up any stale connection state
|
||||
cleanup_connection(username);
|
||||
|
||||
info!("Connecting to Discord gateway for user {}", username);
|
||||
|
||||
// Store token for later use
|
||||
cache::set_string(&token_key(username), token, 86400)?;
|
||||
|
||||
// Connect to Discord gateway
|
||||
let headers = std::collections::HashMap::new();
|
||||
let conn_id = websocket::connect(
|
||||
"wss://gateway.discord.gg/?v=10&encoding=json",
|
||||
headers,
|
||||
username, // Use username as connection ID for easy lookup
|
||||
)?;
|
||||
info!("WebSocket connection established: {}", conn_id);
|
||||
|
||||
// Store connection ID
|
||||
let conn_key = connection_key(username);
|
||||
cache::set_string(&conn_key, &conn_id, 86400)?;
|
||||
|
||||
// Send identify immediately (don't wait for Hello)
|
||||
identify(username)?;
|
||||
|
||||
info!("Successfully connected and identified user {}", username);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Handles a WebSocket message from Discord.
|
||||
pub fn handle_websocket_message(connection_id: &str, message: &str) -> Result<(), Error> {
|
||||
let response: GatewayResponse = serde_json::from_str(message)
|
||||
.map_err(|e| Error::msg(format!("Failed to parse gateway message: {}", e)))?;
|
||||
|
||||
// Update sequence number if present
|
||||
if let Some(seq) = response.s {
|
||||
// Find username for this connection
|
||||
if let Some(username) = find_username_for_connection(connection_id)? {
|
||||
cache::set_string(&sequence_key(&username), &seq.to_string(), 86400)?;
|
||||
}
|
||||
}
|
||||
|
||||
match response.op {
|
||||
10 => {
|
||||
// Hello - we already identified in connect(), nothing to do
|
||||
}
|
||||
11 => {
|
||||
// Heartbeat ACK - no action needed
|
||||
}
|
||||
1 => {
|
||||
// Heartbeat request - send heartbeat
|
||||
if let Some(username) = find_username_for_connection(connection_id)? {
|
||||
send_heartbeat(&username)?;
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
trace!("Received Discord gateway op: {}", response.op);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Handles heartbeat callback from scheduler.
|
||||
pub fn handle_heartbeat_callback(username: &str) -> Result<(), Error> {
|
||||
send_heartbeat(username)
|
||||
}
|
||||
|
||||
/// Handles clear activity callback from scheduler.
|
||||
pub fn handle_clear_activity_callback(username: &str) -> Result<(), Error> {
|
||||
info!("Clearing activity for user {}", username);
|
||||
|
||||
let conn_key = connection_key(username);
|
||||
if let Ok((conn_id, exists)) = cache::get_string(&conn_key) {
|
||||
if exists && !conn_id.is_empty() {
|
||||
// Send empty presence to clear activity
|
||||
let msg = GatewayMessage {
|
||||
op: PRESENCE_OP_CODE,
|
||||
d: PresencePayload {
|
||||
activities: vec![],
|
||||
since: 0,
|
||||
status: "online".to_string(),
|
||||
afk: false,
|
||||
},
|
||||
};
|
||||
|
||||
let json = serde_json::to_string(&msg)
|
||||
.map_err(|e| Error::msg(format!("Failed to serialize message: {}", e)))?;
|
||||
|
||||
websocket::send_text(&conn_id, &json)?;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Sends an activity update to Discord.
|
||||
pub fn send_activity(
|
||||
client_id: &str,
|
||||
username: &str,
|
||||
token: &str,
|
||||
mut activity: Activity,
|
||||
) -> Result<(), Error> {
|
||||
let conn_key = connection_key(username);
|
||||
let (conn_id, exists) = cache::get_string(&conn_key)?;
|
||||
if !exists || conn_id.is_empty() {
|
||||
return Err(Error::msg("Not connected to Discord"));
|
||||
}
|
||||
|
||||
// Process image URL
|
||||
activity.assets.large_image = process_image(&activity.assets.large_image, client_id, token)?;
|
||||
|
||||
// Send presence update
|
||||
let msg = GatewayMessage {
|
||||
op: PRESENCE_OP_CODE,
|
||||
d: PresencePayload {
|
||||
activities: vec![activity],
|
||||
since: 0,
|
||||
status: "online".to_string(),
|
||||
afk: false,
|
||||
},
|
||||
};
|
||||
|
||||
let json = serde_json::to_string(&msg)
|
||||
.map_err(|e| Error::msg(format!("Failed to serialize message: {}", e)))?;
|
||||
|
||||
websocket::send_text(&conn_id, &json)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Internal Functions
|
||||
// ============================================================================
|
||||
|
||||
fn find_username_for_connection(connection_id: &str) -> Result<Option<String>, Error> {
|
||||
// This is a simple approach - in production you might want to maintain a proper mapping
|
||||
// For now, we'll use a known pattern to find the username
|
||||
// The connection ID is stored as cache value, so we need to scan for it
|
||||
// Since we can't iterate cache, we'll use a workaround with a reverse mapping
|
||||
let reverse_key = format!("discord.reverse.{}", connection_id);
|
||||
if let Ok((username, exists)) = cache::get_string(&reverse_key) {
|
||||
if exists && !username.is_empty() {
|
||||
return Ok(Some(username));
|
||||
}
|
||||
}
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
fn identify(username: &str) -> Result<(), Error> {
|
||||
info!("Identifying with Discord for user {}", username);
|
||||
|
||||
let conn_key = connection_key(username);
|
||||
let (conn_id, exists) = cache::get_string(&conn_key)?;
|
||||
if !exists || conn_id.is_empty() {
|
||||
return Err(Error::msg("No connection found"));
|
||||
}
|
||||
|
||||
let token_k = token_key(username);
|
||||
let (token, exists) = cache::get_string(&token_k)?;
|
||||
if !exists || token.is_empty() {
|
||||
return Err(Error::msg("No token found"));
|
||||
}
|
||||
|
||||
// Store reverse mapping for connection -> username
|
||||
let reverse_key = format!("discord.reverse.{}", conn_id);
|
||||
cache::set_string(&reverse_key, username, 86400)?;
|
||||
|
||||
// Send identify
|
||||
let msg = GatewayMessage {
|
||||
op: GATE_OP_CODE,
|
||||
d: IdentifyPayload {
|
||||
token,
|
||||
intents: 0,
|
||||
properties: IdentifyProperties {
|
||||
os: "navidrome".to_string(),
|
||||
browser: "navidrome".to_string(),
|
||||
device: "navidrome".to_string(),
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
let json = serde_json::to_string(&msg)
|
||||
.map_err(|e| Error::msg(format!("Failed to serialize message: {}", e)))?;
|
||||
|
||||
websocket::send_text(&conn_id, &json)?;
|
||||
|
||||
// Schedule heartbeat
|
||||
scheduler::schedule_recurring(
|
||||
&format!("@every {}s", HEARTBEAT_INTERVAL),
|
||||
PAYLOAD_HEARTBEAT,
|
||||
username,
|
||||
)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn send_heartbeat(username: &str) -> Result<(), Error> {
|
||||
let conn_key = connection_key(username);
|
||||
let (conn_id, exists) = cache::get_string(&conn_key)?;
|
||||
if !exists || conn_id.is_empty() {
|
||||
return Err(Error::msg("No connection found"));
|
||||
}
|
||||
|
||||
// Get sequence number
|
||||
let seq_key = sequence_key(username);
|
||||
let (seq_str, exists) = cache::get_string(&seq_key)?;
|
||||
let seq: Option<i64> = if exists && !seq_str.is_empty() {
|
||||
seq_str.parse().ok()
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
// Send heartbeat
|
||||
let msg = GatewayMessage {
|
||||
op: HEARTBEAT_OP_CODE,
|
||||
d: seq,
|
||||
};
|
||||
|
||||
let json = serde_json::to_string(&msg)
|
||||
.map_err(|e| Error::msg(format!("Failed to serialize message: {}", e)))?;
|
||||
|
||||
websocket::send_text(&conn_id, &json)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn process_image(image_url: &str, client_id: &str, token: &str) -> Result<String, Error> {
|
||||
process_image_inner(image_url, client_id, token, false)
|
||||
}
|
||||
|
||||
fn process_image_inner(
|
||||
image_url: &str,
|
||||
client_id: &str,
|
||||
token: &str,
|
||||
is_default: bool,
|
||||
) -> Result<String, Error> {
|
||||
let url = if image_url.is_empty() {
|
||||
if is_default {
|
||||
return Err(Error::msg("default image URL is empty"));
|
||||
}
|
||||
return process_image_inner(DEFAULT_IMAGE, client_id, token, true);
|
||||
} else {
|
||||
image_url
|
||||
};
|
||||
|
||||
// Already processed
|
||||
if url.starts_with("mp:") {
|
||||
return Ok(url.to_string());
|
||||
}
|
||||
|
||||
// Check cache
|
||||
let cache_key = format!("discord.image.{:x}", md5_hash(url));
|
||||
if let Ok((cached, exists)) = cache::get_string(&cache_key) {
|
||||
if exists && !cached.is_empty() {
|
||||
return Ok(cached);
|
||||
}
|
||||
}
|
||||
|
||||
// Process via Discord API
|
||||
let body = format!(r#"{{"urls":["{}"]}}"#, url);
|
||||
let api_url = format!(
|
||||
"https://discord.com/api/v9/applications/{}/external-assets",
|
||||
client_id
|
||||
);
|
||||
|
||||
let req = HttpRequest::new(&api_url)
|
||||
.with_method("POST")
|
||||
.with_header("Authorization", token)
|
||||
.with_header("Content-Type", "application/json");
|
||||
|
||||
let resp = http::request::<String>(&req, Some(body))?;
|
||||
if resp.status_code() >= 400 {
|
||||
if is_default {
|
||||
return Err(Error::msg(format!(
|
||||
"failed to process default image: HTTP {}",
|
||||
resp.status_code()
|
||||
)));
|
||||
}
|
||||
return process_image_inner(DEFAULT_IMAGE, client_id, token, true);
|
||||
}
|
||||
|
||||
let body = resp.body();
|
||||
let data: Vec<std::collections::HashMap<String, String>> = serde_json::from_slice(&body)
|
||||
.map_err(|e| Error::msg(format!("Failed to parse image response: {}", e)))?;
|
||||
|
||||
if data.is_empty() {
|
||||
if is_default {
|
||||
return Err(Error::msg("no data returned for default image"));
|
||||
}
|
||||
return process_image_inner(DEFAULT_IMAGE, client_id, token, true);
|
||||
}
|
||||
|
||||
let asset_path = data[0]
|
||||
.get("external_asset_path")
|
||||
.map(|s| s.as_str())
|
||||
.unwrap_or("");
|
||||
|
||||
if asset_path.is_empty() {
|
||||
if is_default {
|
||||
return Err(Error::msg("empty external_asset_path for default image"));
|
||||
}
|
||||
return process_image_inner(DEFAULT_IMAGE, client_id, token, true);
|
||||
}
|
||||
|
||||
let processed = format!("mp:{}", asset_path);
|
||||
|
||||
// Cache the result
|
||||
let ttl = if is_default { 48 * 60 * 60 } else { 4 * 60 * 60 };
|
||||
let _ = cache::set_string(&cache_key, &processed, ttl);
|
||||
|
||||
Ok(processed)
|
||||
}
|
||||
|
||||
/// Simple hash function for cache keys.
|
||||
fn md5_hash(input: &str) -> u64 {
|
||||
// A simple hash - not actual MD5, but sufficient for cache keys
|
||||
let mut hash: u64 = 0;
|
||||
for (i, byte) in input.bytes().enumerate() {
|
||||
hash = hash.wrapping_add((byte as u64).wrapping_mul((i as u64).wrapping_add(1)));
|
||||
hash = hash.wrapping_mul(31);
|
||||
}
|
||||
hash
|
||||
}
|
||||
@ -33,12 +33,12 @@ func scheduler_cancelschedule(uint64) uint64
|
||||
type SchedulerScheduleOneTimeRequest struct {
|
||||
DelaySeconds int32 `json:"delaySeconds"`
|
||||
Payload string `json:"payload"`
|
||||
ScheduleID string `json:"scheduleID"`
|
||||
ScheduleID string `json:"scheduleId"`
|
||||
}
|
||||
|
||||
// SchedulerScheduleOneTimeResponse is the response type for Scheduler.ScheduleOneTime.
|
||||
type SchedulerScheduleOneTimeResponse struct {
|
||||
NewScheduleID string `json:"newScheduleID,omitempty"`
|
||||
NewScheduleID string `json:"newScheduleId,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
@ -46,18 +46,18 @@ type SchedulerScheduleOneTimeResponse struct {
|
||||
type SchedulerScheduleRecurringRequest struct {
|
||||
CronExpression string `json:"cronExpression"`
|
||||
Payload string `json:"payload"`
|
||||
ScheduleID string `json:"scheduleID"`
|
||||
ScheduleID string `json:"scheduleId"`
|
||||
}
|
||||
|
||||
// SchedulerScheduleRecurringResponse is the response type for Scheduler.ScheduleRecurring.
|
||||
type SchedulerScheduleRecurringResponse struct {
|
||||
NewScheduleID string `json:"newScheduleID,omitempty"`
|
||||
NewScheduleID string `json:"newScheduleId,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
// SchedulerCancelScheduleRequest is the request type for Scheduler.CancelSchedule.
|
||||
type SchedulerCancelScheduleRequest struct {
|
||||
ScheduleID string `json:"scheduleID"`
|
||||
ScheduleID string `json:"scheduleId"`
|
||||
}
|
||||
|
||||
// SchedulerCancelScheduleResponse is the response type for Scheduler.CancelSchedule.
|
||||
|
||||
@ -38,18 +38,18 @@ func websocket_closeconnection(uint64) uint64
|
||||
type WebSocketConnectRequest struct {
|
||||
Url string `json:"url"`
|
||||
Headers map[string]string `json:"headers"`
|
||||
ConnectionID string `json:"connectionID"`
|
||||
ConnectionID string `json:"connectionId"`
|
||||
}
|
||||
|
||||
// WebSocketConnectResponse is the response type for WebSocket.Connect.
|
||||
type WebSocketConnectResponse struct {
|
||||
NewConnectionID string `json:"newConnectionID,omitempty"`
|
||||
NewConnectionID string `json:"newConnectionId,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
// WebSocketSendTextRequest is the request type for WebSocket.SendText.
|
||||
type WebSocketSendTextRequest struct {
|
||||
ConnectionID string `json:"connectionID"`
|
||||
ConnectionID string `json:"connectionId"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
@ -60,7 +60,7 @@ type WebSocketSendTextResponse struct {
|
||||
|
||||
// WebSocketSendBinaryRequest is the request type for WebSocket.SendBinary.
|
||||
type WebSocketSendBinaryRequest struct {
|
||||
ConnectionID string `json:"connectionID"`
|
||||
ConnectionID string `json:"connectionId"`
|
||||
Data []byte `json:"data"`
|
||||
}
|
||||
|
||||
@ -71,7 +71,7 @@ type WebSocketSendBinaryResponse struct {
|
||||
|
||||
// WebSocketCloseConnectionRequest is the request type for WebSocket.CloseConnection.
|
||||
type WebSocketCloseConnectionRequest struct {
|
||||
ConnectionID string `json:"connectionID"`
|
||||
ConnectionID string `json:"connectionId"`
|
||||
Code int32 `json:"code"`
|
||||
Reason string `json:"reason"`
|
||||
}
|
||||
|
||||
@ -58,7 +58,7 @@ def scheduler_schedule_recurring(cron_expression: str, payload: str, schedule_id
|
||||
request = {
|
||||
"cronExpression": cron_expression,
|
||||
"payload": payload,
|
||||
"scheduleID": schedule_id
|
||||
"scheduleId": schedule_id
|
||||
}
|
||||
request_bytes = json.dumps(request).encode('utf-8')
|
||||
request_mem = extism.memory.alloc(request_bytes)
|
||||
@ -69,7 +69,7 @@ def scheduler_schedule_recurring(cron_expression: str, payload: str, schedule_id
|
||||
if response.get("error"):
|
||||
raise Exception(response["error"])
|
||||
|
||||
return response.get("newScheduleID", schedule_id)
|
||||
return response.get("newScheduleId", schedule_id)
|
||||
|
||||
|
||||
def subsonicapi_call(uri: str) -> dict:
|
||||
@ -92,7 +92,7 @@ def subsonicapi_call(uri: str) -> dict:
|
||||
raise Exception(response["error"])
|
||||
|
||||
# Parse the nested JSON response
|
||||
response_json = response.get("responseJSON", "{}")
|
||||
response_json = response.get("responseJson", "{}")
|
||||
return json.loads(response_json)
|
||||
|
||||
|
||||
|
||||
@ -37,5 +37,5 @@
|
||||
// Generated files follow the pattern <servicename>_gen.go and include a header comment
|
||||
// indicating they should not be edited manually.
|
||||
//
|
||||
//go:generate go run ../cmd/hostgen -input=. -output=. -python -go
|
||||
//go:generate go run ../cmd/hostgen -input=. -output=. -python -go -rust
|
||||
package host
|
||||
|
||||
@ -33,12 +33,12 @@ func scheduler_cancelschedule(uint64) uint64
|
||||
type SchedulerScheduleOneTimeRequest struct {
|
||||
DelaySeconds int32 `json:"delaySeconds"`
|
||||
Payload string `json:"payload"`
|
||||
ScheduleID string `json:"scheduleID"`
|
||||
ScheduleID string `json:"scheduleId"`
|
||||
}
|
||||
|
||||
// SchedulerScheduleOneTimeResponse is the response type for Scheduler.ScheduleOneTime.
|
||||
type SchedulerScheduleOneTimeResponse struct {
|
||||
NewScheduleID string `json:"newScheduleID,omitempty"`
|
||||
NewScheduleID string `json:"newScheduleId,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
@ -46,18 +46,18 @@ type SchedulerScheduleOneTimeResponse struct {
|
||||
type SchedulerScheduleRecurringRequest struct {
|
||||
CronExpression string `json:"cronExpression"`
|
||||
Payload string `json:"payload"`
|
||||
ScheduleID string `json:"scheduleID"`
|
||||
ScheduleID string `json:"scheduleId"`
|
||||
}
|
||||
|
||||
// SchedulerScheduleRecurringResponse is the response type for Scheduler.ScheduleRecurring.
|
||||
type SchedulerScheduleRecurringResponse struct {
|
||||
NewScheduleID string `json:"newScheduleID,omitempty"`
|
||||
NewScheduleID string `json:"newScheduleId,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
// SchedulerCancelScheduleRequest is the request type for Scheduler.CancelSchedule.
|
||||
type SchedulerCancelScheduleRequest struct {
|
||||
ScheduleID string `json:"scheduleID"`
|
||||
ScheduleID string `json:"scheduleId"`
|
||||
}
|
||||
|
||||
// SchedulerCancelScheduleResponse is the response type for Scheduler.CancelSchedule.
|
||||
|
||||
@ -26,7 +26,7 @@ type SubsonicAPICallRequest struct {
|
||||
|
||||
// SubsonicAPICallResponse is the response type for SubsonicAPI.Call.
|
||||
type SubsonicAPICallResponse struct {
|
||||
ResponseJSON string `json:"responseJSON,omitempty"`
|
||||
ResponseJSON string `json:"responseJson,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
|
||||
@ -38,18 +38,18 @@ func websocket_closeconnection(uint64) uint64
|
||||
type WebSocketConnectRequest struct {
|
||||
Url string `json:"url"`
|
||||
Headers map[string]string `json:"headers"`
|
||||
ConnectionID string `json:"connectionID"`
|
||||
ConnectionID string `json:"connectionId"`
|
||||
}
|
||||
|
||||
// WebSocketConnectResponse is the response type for WebSocket.Connect.
|
||||
type WebSocketConnectResponse struct {
|
||||
NewConnectionID string `json:"newConnectionID,omitempty"`
|
||||
NewConnectionID string `json:"newConnectionId,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
// WebSocketSendTextRequest is the request type for WebSocket.SendText.
|
||||
type WebSocketSendTextRequest struct {
|
||||
ConnectionID string `json:"connectionID"`
|
||||
ConnectionID string `json:"connectionId"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
@ -60,7 +60,7 @@ type WebSocketSendTextResponse struct {
|
||||
|
||||
// WebSocketSendBinaryRequest is the request type for WebSocket.SendBinary.
|
||||
type WebSocketSendBinaryRequest struct {
|
||||
ConnectionID string `json:"connectionID"`
|
||||
ConnectionID string `json:"connectionId"`
|
||||
Data []byte `json:"data"`
|
||||
}
|
||||
|
||||
@ -71,7 +71,7 @@ type WebSocketSendBinaryResponse struct {
|
||||
|
||||
// WebSocketCloseConnectionRequest is the request type for WebSocket.CloseConnection.
|
||||
type WebSocketCloseConnectionRequest struct {
|
||||
ConnectionID string `json:"connectionID"`
|
||||
ConnectionID string `json:"connectionId"`
|
||||
Code int32 `json:"code"`
|
||||
Reason string `json:"reason"`
|
||||
}
|
||||
|
||||
@ -37,7 +37,7 @@ def _scheduler_cancelschedule(offset: int) -> int:
|
||||
...
|
||||
|
||||
|
||||
def scheduler_schedule_one_time(delay_seconds: int, payload: str, schedule_i_d: str) -> str:
|
||||
def scheduler_schedule_one_time(delay_seconds: int, payload: str, schedule_id: str) -> str:
|
||||
"""ScheduleOneTime schedules a one-time event to be triggered after the specified delay.
|
||||
Plugins that use this function must also implement the SchedulerCallback capability
|
||||
|
||||
@ -51,7 +51,7 @@ Returns the schedule ID that can be used to cancel the job, or an error if sched
|
||||
Args:
|
||||
delay_seconds: int parameter.
|
||||
payload: str parameter.
|
||||
schedule_i_d: str parameter.
|
||||
schedule_id: str parameter.
|
||||
|
||||
Returns:
|
||||
str: The result value.
|
||||
@ -62,7 +62,7 @@ Returns the schedule ID that can be used to cancel the job, or an error if sched
|
||||
request = {
|
||||
"delaySeconds": delay_seconds,
|
||||
"payload": payload,
|
||||
"scheduleID": schedule_i_d,
|
||||
"scheduleId": schedule_id,
|
||||
}
|
||||
request_bytes = json.dumps(request).encode("utf-8")
|
||||
request_mem = extism.memory.alloc(request_bytes)
|
||||
@ -73,10 +73,10 @@ Returns the schedule ID that can be used to cancel the job, or an error if sched
|
||||
if response.get("error"):
|
||||
raise HostFunctionError(response["error"])
|
||||
|
||||
return response.get("newScheduleID", "")
|
||||
return response.get("newScheduleId", "")
|
||||
|
||||
|
||||
def scheduler_schedule_recurring(cron_expression: str, payload: str, schedule_i_d: str) -> str:
|
||||
def scheduler_schedule_recurring(cron_expression: str, payload: str, schedule_id: str) -> str:
|
||||
"""ScheduleRecurring schedules a recurring event using a cron expression.
|
||||
Plugins that use this function must also implement the SchedulerCallback capability
|
||||
|
||||
@ -90,7 +90,7 @@ Returns the schedule ID that can be used to cancel the job, or an error if sched
|
||||
Args:
|
||||
cron_expression: str parameter.
|
||||
payload: str parameter.
|
||||
schedule_i_d: str parameter.
|
||||
schedule_id: str parameter.
|
||||
|
||||
Returns:
|
||||
str: The result value.
|
||||
@ -101,7 +101,7 @@ Returns the schedule ID that can be used to cancel the job, or an error if sched
|
||||
request = {
|
||||
"cronExpression": cron_expression,
|
||||
"payload": payload,
|
||||
"scheduleID": schedule_i_d,
|
||||
"scheduleId": schedule_id,
|
||||
}
|
||||
request_bytes = json.dumps(request).encode("utf-8")
|
||||
request_mem = extism.memory.alloc(request_bytes)
|
||||
@ -112,10 +112,10 @@ Returns the schedule ID that can be used to cancel the job, or an error if sched
|
||||
if response.get("error"):
|
||||
raise HostFunctionError(response["error"])
|
||||
|
||||
return response.get("newScheduleID", "")
|
||||
return response.get("newScheduleId", "")
|
||||
|
||||
|
||||
def scheduler_cancel_schedule(schedule_i_d: str) -> None:
|
||||
def scheduler_cancel_schedule(schedule_id: str) -> None:
|
||||
"""CancelSchedule cancels a scheduled job identified by its schedule ID.
|
||||
|
||||
This works for both one-time and recurring schedules. Once cancelled, the job will not trigger
|
||||
@ -124,13 +124,13 @@ any future events.
|
||||
Returns an error if the schedule ID is not found or if cancellation fails.
|
||||
|
||||
Args:
|
||||
schedule_i_d: str parameter.
|
||||
schedule_id: str parameter.
|
||||
|
||||
Raises:
|
||||
HostFunctionError: If the host function returns an error.
|
||||
"""
|
||||
request = {
|
||||
"scheduleID": schedule_i_d,
|
||||
"scheduleId": schedule_id,
|
||||
}
|
||||
request_bytes = json.dumps(request).encode("utf-8")
|
||||
request_mem = extism.memory.alloc(request_bytes)
|
||||
|
||||
@ -52,4 +52,4 @@ e.g., "getAlbumList2?type=random&size=10". The response is returned as raw JSON.
|
||||
if response.get("error"):
|
||||
raise HostFunctionError(response["error"])
|
||||
|
||||
return response.get("responseJSON", "")
|
||||
return response.get("responseJson", "")
|
||||
|
||||
@ -43,7 +43,7 @@ def _websocket_closeconnection(offset: int) -> int:
|
||||
...
|
||||
|
||||
|
||||
def websocket_connect(url: str, headers: Any, connection_i_d: str) -> str:
|
||||
def websocket_connect(url: str, headers: Any, connection_id: str) -> str:
|
||||
"""Connect establishes a WebSocket connection to the specified URL.
|
||||
|
||||
Plugins that use this function must also implement the WebSocketCallback capability
|
||||
@ -60,7 +60,7 @@ or an error if the connection fails.
|
||||
Args:
|
||||
url: str parameter.
|
||||
headers: Any parameter.
|
||||
connection_i_d: str parameter.
|
||||
connection_id: str parameter.
|
||||
|
||||
Returns:
|
||||
str: The result value.
|
||||
@ -71,7 +71,7 @@ or an error if the connection fails.
|
||||
request = {
|
||||
"url": url,
|
||||
"headers": headers,
|
||||
"connectionID": connection_i_d,
|
||||
"connectionId": connection_id,
|
||||
}
|
||||
request_bytes = json.dumps(request).encode("utf-8")
|
||||
request_mem = extism.memory.alloc(request_bytes)
|
||||
@ -82,10 +82,10 @@ or an error if the connection fails.
|
||||
if response.get("error"):
|
||||
raise HostFunctionError(response["error"])
|
||||
|
||||
return response.get("newConnectionID", "")
|
||||
return response.get("newConnectionId", "")
|
||||
|
||||
|
||||
def websocket_send_text(connection_i_d: str, message: str) -> None:
|
||||
def websocket_send_text(connection_id: str, message: str) -> None:
|
||||
"""SendText sends a text message over an established WebSocket connection.
|
||||
|
||||
Parameters:
|
||||
@ -95,14 +95,14 @@ Parameters:
|
||||
Returns an error if the connection is not found or if sending fails.
|
||||
|
||||
Args:
|
||||
connection_i_d: str parameter.
|
||||
connection_id: str parameter.
|
||||
message: str parameter.
|
||||
|
||||
Raises:
|
||||
HostFunctionError: If the host function returns an error.
|
||||
"""
|
||||
request = {
|
||||
"connectionID": connection_i_d,
|
||||
"connectionId": connection_id,
|
||||
"message": message,
|
||||
}
|
||||
request_bytes = json.dumps(request).encode("utf-8")
|
||||
@ -116,7 +116,7 @@ Returns an error if the connection is not found or if sending fails.
|
||||
|
||||
|
||||
|
||||
def websocket_send_binary(connection_i_d: str, data: bytes) -> None:
|
||||
def websocket_send_binary(connection_id: str, data: bytes) -> None:
|
||||
"""SendBinary sends binary data over an established WebSocket connection.
|
||||
|
||||
Parameters:
|
||||
@ -126,14 +126,14 @@ Parameters:
|
||||
Returns an error if the connection is not found or if sending fails.
|
||||
|
||||
Args:
|
||||
connection_i_d: str parameter.
|
||||
connection_id: str parameter.
|
||||
data: bytes parameter.
|
||||
|
||||
Raises:
|
||||
HostFunctionError: If the host function returns an error.
|
||||
"""
|
||||
request = {
|
||||
"connectionID": connection_i_d,
|
||||
"connectionId": connection_id,
|
||||
"data": data,
|
||||
}
|
||||
request_bytes = json.dumps(request).encode("utf-8")
|
||||
@ -147,7 +147,7 @@ Returns an error if the connection is not found or if sending fails.
|
||||
|
||||
|
||||
|
||||
def websocket_close_connection(connection_i_d: str, code: int, reason: str) -> None:
|
||||
def websocket_close_connection(connection_id: str, code: int, reason: str) -> None:
|
||||
"""CloseConnection gracefully closes a WebSocket connection.
|
||||
|
||||
Parameters:
|
||||
@ -158,7 +158,7 @@ Parameters:
|
||||
Returns an error if the connection is not found or if closing fails.
|
||||
|
||||
Args:
|
||||
connection_i_d: str parameter.
|
||||
connection_id: str parameter.
|
||||
code: int parameter.
|
||||
reason: str parameter.
|
||||
|
||||
@ -166,7 +166,7 @@ Returns an error if the connection is not found or if closing fails.
|
||||
HostFunctionError: If the host function returns an error.
|
||||
"""
|
||||
request = {
|
||||
"connectionID": connection_i_d,
|
||||
"connectionId": connection_id,
|
||||
"code": code,
|
||||
"reason": reason,
|
||||
}
|
||||
|
||||
1
plugins/host/rust/.gitignore
vendored
Normal file
1
plugins/host/rust/.gitignore
vendored
Normal file
@ -0,0 +1 @@
|
||||
!Cargo.lock
|
||||
380
plugins/host/rust/Cargo.lock
generated
Normal file
380
plugins/host/rust/Cargo.lock
generated
Normal file
@ -0,0 +1,380 @@
|
||||
# This file is automatically @generated by Cargo.
|
||||
# It is not intended for manual editing.
|
||||
version = 4
|
||||
|
||||
[[package]]
|
||||
name = "anyhow"
|
||||
version = "1.0.100"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a23eb6b1614318a8071c9b2521f36b424b2c83db5eb3a0fead4a6c0809af6e61"
|
||||
|
||||
[[package]]
|
||||
name = "autocfg"
|
||||
version = "1.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8"
|
||||
|
||||
[[package]]
|
||||
name = "base64"
|
||||
version = "0.22.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
|
||||
|
||||
[[package]]
|
||||
name = "bytemuck"
|
||||
version = "1.24.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1fbdf580320f38b612e485521afda1ee26d10cc9884efaaa750d383e13e3c5f4"
|
||||
|
||||
[[package]]
|
||||
name = "bytes"
|
||||
version = "1.11.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b35204fbdc0b3f4446b89fc1ac2cf84a8a68971995d0bf2e925ec7cd960f9cb3"
|
||||
|
||||
[[package]]
|
||||
name = "either"
|
||||
version = "1.15.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719"
|
||||
|
||||
[[package]]
|
||||
name = "equivalent"
|
||||
version = "1.0.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f"
|
||||
|
||||
[[package]]
|
||||
name = "extism-convert"
|
||||
version = "1.13.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4f6612b4e92559eeb4c2dac88a53ee8b4729bea64025befcdeb2b3677e62fc1d"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"base64",
|
||||
"bytemuck",
|
||||
"extism-convert-macros",
|
||||
"prost",
|
||||
"rmp-serde",
|
||||
"serde",
|
||||
"serde_json",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "extism-convert-macros"
|
||||
version = "1.13.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "525831f1f15079a7c43514905579aac10f90fee46bc6353b683ed632029dd945"
|
||||
dependencies = [
|
||||
"manyhow",
|
||||
"proc-macro-crate",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "extism-manifest"
|
||||
version = "1.13.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e60e36345a96ad0d74adfca64dc22d93eb4979ab15a6c130cded5e0585f31b10"
|
||||
dependencies = [
|
||||
"base64",
|
||||
"serde",
|
||||
"serde_json",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "extism-pdk"
|
||||
version = "1.4.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "352fcb5a66eb74145a1c4a01f2bd15d59c62c85be73aac8471880c65b26b798f"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"base64",
|
||||
"extism-convert",
|
||||
"extism-manifest",
|
||||
"extism-pdk-derive",
|
||||
"serde",
|
||||
"serde_json",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "extism-pdk-derive"
|
||||
version = "1.4.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d086daea5fd844e3c5ac69ddfe36df4a9a43e7218cf7d1f888182b089b09806c"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hashbrown"
|
||||
version = "0.16.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100"
|
||||
|
||||
[[package]]
|
||||
name = "indexmap"
|
||||
version = "2.12.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0ad4bb2b565bca0645f4d68c5c9af97fba094e9791da685bf83cb5f3ce74acf2"
|
||||
dependencies = [
|
||||
"equivalent",
|
||||
"hashbrown",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "itertools"
|
||||
version = "0.14.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285"
|
||||
dependencies = [
|
||||
"either",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "itoa"
|
||||
version = "1.0.17"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2"
|
||||
|
||||
[[package]]
|
||||
name = "manyhow"
|
||||
version = "0.11.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b33efb3ca6d3b07393750d4030418d594ab1139cee518f0dc88db70fec873587"
|
||||
dependencies = [
|
||||
"manyhow-macros",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "manyhow-macros"
|
||||
version = "0.11.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "46fce34d199b78b6e6073abf984c9cf5fd3e9330145a93ee0738a7443e371495"
|
||||
dependencies = [
|
||||
"proc-macro-utils",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "memchr"
|
||||
version = "2.7.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f52b00d39961fc5b2736ea853c9cc86238e165017a493d1d5c8eac6bdc4cc273"
|
||||
|
||||
[[package]]
|
||||
name = "nd-host"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"extism-pdk",
|
||||
"serde",
|
||||
"serde_json",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "num-traits"
|
||||
version = "0.2.19"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841"
|
||||
dependencies = [
|
||||
"autocfg",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "proc-macro-crate"
|
||||
version = "3.4.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "219cb19e96be00ab2e37d6e299658a0cfa83e52429179969b0f0121b4ac46983"
|
||||
dependencies = [
|
||||
"toml_edit",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "proc-macro-utils"
|
||||
version = "0.10.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "eeaf08a13de400bc215877b5bdc088f241b12eb42f0a548d3390dc1c56bb7071"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"smallvec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "proc-macro2"
|
||||
version = "1.0.104"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9695f8df41bb4f3d222c95a67532365f569318332d03d5f3f67f37b20e6ebdf0"
|
||||
dependencies = [
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "prost"
|
||||
version = "0.14.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7231bd9b3d3d33c86b58adbac74b5ec0ad9f496b19d22801d773636feaa95f3d"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"prost-derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "prost-derive"
|
||||
version = "0.14.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9120690fafc389a67ba3803df527d0ec9cbbc9cc45e4cc20b332996dfb672425"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"itertools",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "quote"
|
||||
version = "1.0.42"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a338cc41d27e6cc6dce6cefc13a0729dfbb81c262b1f519331575dd80ef3067f"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rmp"
|
||||
version = "0.8.15"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4ba8be72d372b2c9b35542551678538b562e7cf86c3315773cae48dfbfe7790c"
|
||||
dependencies = [
|
||||
"num-traits",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rmp-serde"
|
||||
version = "1.3.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "72f81bee8c8ef9b577d1681a70ebbc962c232461e397b22c208c43c04b67a155"
|
||||
dependencies = [
|
||||
"rmp",
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde"
|
||||
version = "1.0.228"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e"
|
||||
dependencies = [
|
||||
"serde_core",
|
||||
"serde_derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_core"
|
||||
version = "1.0.228"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad"
|
||||
dependencies = [
|
||||
"serde_derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_derive"
|
||||
version = "1.0.228"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_json"
|
||||
version = "1.0.148"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3084b546a1dd6289475996f182a22aba973866ea8e8b02c51d9f46b1336a22da"
|
||||
dependencies = [
|
||||
"itoa",
|
||||
"memchr",
|
||||
"serde",
|
||||
"serde_core",
|
||||
"zmij",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "smallvec"
|
||||
version = "1.15.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03"
|
||||
|
||||
[[package]]
|
||||
name = "syn"
|
||||
version = "2.0.111"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "390cc9a294ab71bdb1aa2e99d13be9c753cd2d7bd6560c77118597410c4d2e87"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "toml_datetime"
|
||||
version = "0.7.5+spec-1.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "92e1cfed4a3038bc5a127e35a2d360f145e1f4b971b551a2ba5fd7aedf7e1347"
|
||||
dependencies = [
|
||||
"serde_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "toml_edit"
|
||||
version = "0.23.10+spec-1.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "84c8b9f757e028cee9fa244aea147aab2a9ec09d5325a9b01e0a49730c2b5269"
|
||||
dependencies = [
|
||||
"indexmap",
|
||||
"toml_datetime",
|
||||
"toml_parser",
|
||||
"winnow",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "toml_parser"
|
||||
version = "1.0.6+spec-1.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a3198b4b0a8e11f09dd03e133c0280504d0801269e9afa46362ffde1cbeebf44"
|
||||
dependencies = [
|
||||
"winnow",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "unicode-ident"
|
||||
version = "1.0.22"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9312f7c4f6ff9069b165498234ce8be658059c6728633667c526e27dc2cf1df5"
|
||||
|
||||
[[package]]
|
||||
name = "winnow"
|
||||
version = "0.7.14"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5a5364e9d77fcdeeaa6062ced926ee3381faa2ee02d3eb83a5c27a8825540829"
|
||||
dependencies = [
|
||||
"memchr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zmij"
|
||||
version = "1.0.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0f4a4e8e9dc5c62d159f04fcdbe07f4c3fb710415aab4754bf11505501e3251d"
|
||||
17
plugins/host/rust/Cargo.toml
Normal file
17
plugins/host/rust/Cargo.toml
Normal file
@ -0,0 +1,17 @@
|
||||
[package]
|
||||
name = "nd-host"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
description = "Navidrome host function wrappers for Rust plugins"
|
||||
authors = ["Navidrome Team"]
|
||||
license = "GPL-3.0"
|
||||
readme = "README.md"
|
||||
|
||||
[lib]
|
||||
path = "lib.rs"
|
||||
crate-type = ["cdylib", "rlib"]
|
||||
|
||||
[dependencies]
|
||||
extism-pdk = "1.2"
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
serde_json = "1.0"
|
||||
63
plugins/host/rust/README.md
Normal file
63
plugins/host/rust/README.md
Normal file
@ -0,0 +1,63 @@
|
||||
# Navidrome Host Function Wrappers for Rust
|
||||
|
||||
This directory contains auto-generated Rust wrappers for Navidrome's host services.
|
||||
These wrappers provide idiomatic Rust APIs for interacting with Navidrome from WASM plugins.
|
||||
|
||||
## ⚠️ Auto-Generated Code
|
||||
|
||||
**Do not edit these files manually.** They are generated by the `hostgen` tool.
|
||||
|
||||
To regenerate:
|
||||
|
||||
```bash
|
||||
cd plugins/host
|
||||
go generate
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
Add this crate as a dependency in your plugin's `Cargo.toml`:
|
||||
|
||||
```toml
|
||||
[dependencies]
|
||||
nd-host = { path = "../../host/rust" }
|
||||
```
|
||||
|
||||
Then import the services you need:
|
||||
|
||||
```rust
|
||||
use nd_host::{cache, scheduler, kvstore};
|
||||
|
||||
#[plugin_fn]
|
||||
pub fn my_callback(input: String) -> FnResult<String> {
|
||||
// Use the cache service
|
||||
cache::cache_set("my_key", b"my_value", 3600)?;
|
||||
|
||||
// Schedule a recurring task
|
||||
scheduler::scheduler_schedule_recurring("@every 5m", "payload", "task_id")?;
|
||||
|
||||
Ok("done".to_string())
|
||||
}
|
||||
```
|
||||
|
||||
## Available Services
|
||||
|
||||
| Module | Description |
|
||||
|--------|-------------|
|
||||
| `artwork` | Access album and artist artwork |
|
||||
| `cache` | Temporary key-value storage with TTL |
|
||||
| `kvstore` | Persistent key-value storage |
|
||||
| `library` | Access the music library (albums, artists, tracks) |
|
||||
| `scheduler` | Schedule one-time and recurring tasks |
|
||||
| `subsonicapi` | Make Subsonic API calls |
|
||||
| `websocket` | Send real-time messages to clients |
|
||||
|
||||
## Building Plugins
|
||||
|
||||
Rust plugins must be compiled to WebAssembly:
|
||||
|
||||
```bash
|
||||
cargo build --target wasm32-wasip1 --release
|
||||
```
|
||||
|
||||
See the [webhook-rs](../../examples/webhook-rs/) example for a complete plugin implementation.
|
||||
63
plugins/host/rust/lib.rs
Normal file
63
plugins/host/rust/lib.rs
Normal file
@ -0,0 +1,63 @@
|
||||
//! Navidrome Host Function Wrappers for Rust Plugins
|
||||
//!
|
||||
//! This crate provides idiomatic Rust wrappers for all Navidrome host services.
|
||||
//! It is auto-generated by the hostgen tool and should not be edited manually.
|
||||
//!
|
||||
//! # Usage
|
||||
//!
|
||||
//! Add this crate as a dependency in your plugin's Cargo.toml:
|
||||
//!
|
||||
//! ```toml
|
||||
//! [dependencies]
|
||||
//! nd-host = { path = "../../host/rust" }
|
||||
//! ```
|
||||
//!
|
||||
//! Then import the services you need:
|
||||
//!
|
||||
//! ```ignore
|
||||
//! use nd_host::{cache, scheduler, kvstore};
|
||||
//!
|
||||
//! fn my_plugin_function() -> Result<(), extism_pdk::Error> {
|
||||
//! // Use the cache service
|
||||
//! cache::cache_set("my_key", b"my_value", 3600)?;
|
||||
//!
|
||||
//! // Schedule a recurring task
|
||||
//! scheduler::scheduler_schedule_recurring("@every 5m", "payload", "task_id")?;
|
||||
//!
|
||||
//! Ok(())
|
||||
//! }
|
||||
//! ```
|
||||
//!
|
||||
//! # Available Services
|
||||
//!
|
||||
//! - [`artwork`] - Access album and artist artwork
|
||||
//! - [`cache`] - Temporary key-value storage with TTL
|
||||
//! - [`kvstore`] - Persistent key-value storage
|
||||
//! - [`library`] - Access the music library
|
||||
//! - [`scheduler`] - Schedule one-time and recurring tasks
|
||||
//! - [`subsonicapi`] - Make Subsonic API calls
|
||||
//! - [`websocket`] - Send real-time messages to clients
|
||||
|
||||
#[path = "nd_host_artwork.rs"]
|
||||
pub mod artwork;
|
||||
|
||||
#[path = "nd_host_cache.rs"]
|
||||
pub mod cache;
|
||||
|
||||
#[path = "nd_host_kvstore.rs"]
|
||||
pub mod kvstore;
|
||||
|
||||
#[path = "nd_host_library.rs"]
|
||||
pub mod library;
|
||||
|
||||
#[path = "nd_host_scheduler.rs"]
|
||||
pub mod scheduler;
|
||||
|
||||
#[path = "nd_host_subsonicapi.rs"]
|
||||
pub mod subsonicapi;
|
||||
|
||||
#[path = "nd_host_websocket.rs"]
|
||||
pub mod websocket;
|
||||
|
||||
// Re-export commonly used types from extism-pdk for convenience
|
||||
pub use extism_pdk::Error;
|
||||
207
plugins/host/rust/nd_host_artwork.rs
Normal file
207
plugins/host/rust/nd_host_artwork.rs
Normal file
@ -0,0 +1,207 @@
|
||||
// Code generated by hostgen. DO NOT EDIT.
|
||||
//
|
||||
// This file contains client wrappers for the Artwork 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 ArtworkGetArtistUrlRequest {
|
||||
id: String,
|
||||
size: i32,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct ArtworkGetArtistUrlResponse {
|
||||
#[serde(default)]
|
||||
url: String,
|
||||
#[serde(default)]
|
||||
error: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct ArtworkGetAlbumUrlRequest {
|
||||
id: String,
|
||||
size: i32,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct ArtworkGetAlbumUrlResponse {
|
||||
#[serde(default)]
|
||||
url: String,
|
||||
#[serde(default)]
|
||||
error: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct ArtworkGetTrackUrlRequest {
|
||||
id: String,
|
||||
size: i32,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct ArtworkGetTrackUrlResponse {
|
||||
#[serde(default)]
|
||||
url: String,
|
||||
#[serde(default)]
|
||||
error: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct ArtworkGetPlaylistUrlRequest {
|
||||
id: String,
|
||||
size: i32,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct ArtworkGetPlaylistUrlResponse {
|
||||
#[serde(default)]
|
||||
url: String,
|
||||
#[serde(default)]
|
||||
error: Option<String>,
|
||||
}
|
||||
|
||||
#[host_fn]
|
||||
extern "ExtismHost" {
|
||||
fn artwork_getartisturl(input: Json<ArtworkGetArtistUrlRequest>) -> Json<ArtworkGetArtistUrlResponse>;
|
||||
fn artwork_getalbumurl(input: Json<ArtworkGetAlbumUrlRequest>) -> Json<ArtworkGetAlbumUrlResponse>;
|
||||
fn artwork_gettrackurl(input: Json<ArtworkGetTrackUrlRequest>) -> Json<ArtworkGetTrackUrlResponse>;
|
||||
fn artwork_getplaylisturl(input: Json<ArtworkGetPlaylistUrlRequest>) -> Json<ArtworkGetPlaylistUrlResponse>;
|
||||
}
|
||||
|
||||
/// GetArtistUrl generates a public URL for an artist's artwork.
|
||||
///
|
||||
/// Parameters:
|
||||
/// - id: The artist's unique identifier
|
||||
/// - size: Desired image size in pixels (0 for original size)
|
||||
///
|
||||
/// Returns the public URL for the artwork, or an error if generation fails.
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `id` - String parameter.
|
||||
/// * `size` - i32 parameter.
|
||||
///
|
||||
/// # Returns
|
||||
/// The url value.
|
||||
///
|
||||
/// # Errors
|
||||
/// Returns an error if the host function call fails.
|
||||
pub fn get_artist_url(id: &str, size: i32) -> Result<String, Error> {
|
||||
let response = unsafe {
|
||||
artwork_getartisturl(Json(ArtworkGetArtistUrlRequest {
|
||||
id: id.to_owned(),
|
||||
size: size,
|
||||
}))?
|
||||
};
|
||||
|
||||
if let Some(err) = response.0.error {
|
||||
return Err(Error::msg(err));
|
||||
}
|
||||
|
||||
Ok(response.0.url)
|
||||
}
|
||||
|
||||
/// GetAlbumUrl generates a public URL for an album's artwork.
|
||||
///
|
||||
/// Parameters:
|
||||
/// - id: The album's unique identifier
|
||||
/// - size: Desired image size in pixels (0 for original size)
|
||||
///
|
||||
/// Returns the public URL for the artwork, or an error if generation fails.
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `id` - String parameter.
|
||||
/// * `size` - i32 parameter.
|
||||
///
|
||||
/// # Returns
|
||||
/// The url value.
|
||||
///
|
||||
/// # Errors
|
||||
/// Returns an error if the host function call fails.
|
||||
pub fn get_album_url(id: &str, size: i32) -> Result<String, Error> {
|
||||
let response = unsafe {
|
||||
artwork_getalbumurl(Json(ArtworkGetAlbumUrlRequest {
|
||||
id: id.to_owned(),
|
||||
size: size,
|
||||
}))?
|
||||
};
|
||||
|
||||
if let Some(err) = response.0.error {
|
||||
return Err(Error::msg(err));
|
||||
}
|
||||
|
||||
Ok(response.0.url)
|
||||
}
|
||||
|
||||
/// GetTrackUrl generates a public URL for a track's artwork.
|
||||
///
|
||||
/// Parameters:
|
||||
/// - id: The track's (media file) unique identifier
|
||||
/// - size: Desired image size in pixels (0 for original size)
|
||||
///
|
||||
/// Returns the public URL for the artwork, or an error if generation fails.
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `id` - String parameter.
|
||||
/// * `size` - i32 parameter.
|
||||
///
|
||||
/// # Returns
|
||||
/// The url value.
|
||||
///
|
||||
/// # Errors
|
||||
/// Returns an error if the host function call fails.
|
||||
pub fn get_track_url(id: &str, size: i32) -> Result<String, Error> {
|
||||
let response = unsafe {
|
||||
artwork_gettrackurl(Json(ArtworkGetTrackUrlRequest {
|
||||
id: id.to_owned(),
|
||||
size: size,
|
||||
}))?
|
||||
};
|
||||
|
||||
if let Some(err) = response.0.error {
|
||||
return Err(Error::msg(err));
|
||||
}
|
||||
|
||||
Ok(response.0.url)
|
||||
}
|
||||
|
||||
/// GetPlaylistUrl generates a public URL for a playlist's artwork.
|
||||
///
|
||||
/// Parameters:
|
||||
/// - id: The playlist's unique identifier
|
||||
/// - size: Desired image size in pixels (0 for original size)
|
||||
///
|
||||
/// Returns the public URL for the artwork, or an error if generation fails.
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `id` - String parameter.
|
||||
/// * `size` - i32 parameter.
|
||||
///
|
||||
/// # Returns
|
||||
/// The url value.
|
||||
///
|
||||
/// # Errors
|
||||
/// Returns an error if the host function call fails.
|
||||
pub fn get_playlist_url(id: &str, size: i32) -> Result<String, Error> {
|
||||
let response = unsafe {
|
||||
artwork_getplaylisturl(Json(ArtworkGetPlaylistUrlRequest {
|
||||
id: id.to_owned(),
|
||||
size: size,
|
||||
}))?
|
||||
};
|
||||
|
||||
if let Some(err) = response.0.error {
|
||||
return Err(Error::msg(err));
|
||||
}
|
||||
|
||||
Ok(response.0.url)
|
||||
}
|
||||
480
plugins/host/rust/nd_host_cache.rs
Normal file
480
plugins/host/rust/nd_host_cache.rs
Normal file
@ -0,0 +1,480 @@
|
||||
// Code generated by hostgen. DO NOT EDIT.
|
||||
//
|
||||
// This file contains client wrappers for the Cache 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 CacheSetStringRequest {
|
||||
key: String,
|
||||
value: String,
|
||||
ttl_seconds: i64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct CacheSetStringResponse {
|
||||
#[serde(default)]
|
||||
error: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct CacheGetStringRequest {
|
||||
key: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct CacheGetStringResponse {
|
||||
#[serde(default)]
|
||||
value: String,
|
||||
#[serde(default)]
|
||||
exists: bool,
|
||||
#[serde(default)]
|
||||
error: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct CacheSetIntRequest {
|
||||
key: String,
|
||||
value: i64,
|
||||
ttl_seconds: i64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct CacheSetIntResponse {
|
||||
#[serde(default)]
|
||||
error: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct CacheGetIntRequest {
|
||||
key: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct CacheGetIntResponse {
|
||||
#[serde(default)]
|
||||
value: i64,
|
||||
#[serde(default)]
|
||||
exists: bool,
|
||||
#[serde(default)]
|
||||
error: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct CacheSetFloatRequest {
|
||||
key: String,
|
||||
value: f64,
|
||||
ttl_seconds: i64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct CacheSetFloatResponse {
|
||||
#[serde(default)]
|
||||
error: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct CacheGetFloatRequest {
|
||||
key: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct CacheGetFloatResponse {
|
||||
#[serde(default)]
|
||||
value: f64,
|
||||
#[serde(default)]
|
||||
exists: bool,
|
||||
#[serde(default)]
|
||||
error: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct CacheSetBytesRequest {
|
||||
key: String,
|
||||
value: Vec<u8>,
|
||||
ttl_seconds: i64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct CacheSetBytesResponse {
|
||||
#[serde(default)]
|
||||
error: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct CacheGetBytesRequest {
|
||||
key: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct CacheGetBytesResponse {
|
||||
#[serde(default)]
|
||||
value: Vec<u8>,
|
||||
#[serde(default)]
|
||||
exists: bool,
|
||||
#[serde(default)]
|
||||
error: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct CacheHasRequest {
|
||||
key: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct CacheHasResponse {
|
||||
#[serde(default)]
|
||||
exists: bool,
|
||||
#[serde(default)]
|
||||
error: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct CacheRemoveRequest {
|
||||
key: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct CacheRemoveResponse {
|
||||
#[serde(default)]
|
||||
error: Option<String>,
|
||||
}
|
||||
|
||||
#[host_fn]
|
||||
extern "ExtismHost" {
|
||||
fn cache_setstring(input: Json<CacheSetStringRequest>) -> Json<CacheSetStringResponse>;
|
||||
fn cache_getstring(input: Json<CacheGetStringRequest>) -> Json<CacheGetStringResponse>;
|
||||
fn cache_setint(input: Json<CacheSetIntRequest>) -> Json<CacheSetIntResponse>;
|
||||
fn cache_getint(input: Json<CacheGetIntRequest>) -> Json<CacheGetIntResponse>;
|
||||
fn cache_setfloat(input: Json<CacheSetFloatRequest>) -> Json<CacheSetFloatResponse>;
|
||||
fn cache_getfloat(input: Json<CacheGetFloatRequest>) -> Json<CacheGetFloatResponse>;
|
||||
fn cache_setbytes(input: Json<CacheSetBytesRequest>) -> Json<CacheSetBytesResponse>;
|
||||
fn cache_getbytes(input: Json<CacheGetBytesRequest>) -> Json<CacheGetBytesResponse>;
|
||||
fn cache_has(input: Json<CacheHasRequest>) -> Json<CacheHasResponse>;
|
||||
fn cache_remove(input: Json<CacheRemoveRequest>) -> Json<CacheRemoveResponse>;
|
||||
}
|
||||
|
||||
/// SetString stores a string value in the cache.
|
||||
///
|
||||
/// Parameters:
|
||||
/// - key: The cache key (will be namespaced with plugin ID)
|
||||
/// - value: The string value to store
|
||||
/// - ttlSeconds: Time-to-live in seconds (0 uses default of 24 hours)
|
||||
///
|
||||
/// Returns an error if the operation fails.
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `key` - String parameter.
|
||||
/// * `value` - String parameter.
|
||||
/// * `ttl_seconds` - i64 parameter.
|
||||
///
|
||||
/// # Errors
|
||||
/// Returns an error if the host function call fails.
|
||||
pub fn set_string(key: &str, value: &str, ttl_seconds: i64) -> Result<(), Error> {
|
||||
let response = unsafe {
|
||||
cache_setstring(Json(CacheSetStringRequest {
|
||||
key: key.to_owned(),
|
||||
value: value.to_owned(),
|
||||
ttl_seconds: ttl_seconds,
|
||||
}))?
|
||||
};
|
||||
|
||||
if let Some(err) = response.0.error {
|
||||
return Err(Error::msg(err));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// GetString retrieves a string value from the cache.
|
||||
///
|
||||
/// Parameters:
|
||||
/// - key: The cache key (will be namespaced with plugin ID)
|
||||
///
|
||||
/// Returns the value and whether the key exists. If the key doesn't exist
|
||||
/// or the stored value is not a string, exists will be false.
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `key` - String parameter.
|
||||
///
|
||||
/// # Returns
|
||||
/// A tuple of (value, exists).
|
||||
///
|
||||
/// # Errors
|
||||
/// Returns an error if the host function call fails.
|
||||
pub fn get_string(key: &str) -> Result<(String, bool), Error> {
|
||||
let response = unsafe {
|
||||
cache_getstring(Json(CacheGetStringRequest {
|
||||
key: key.to_owned(),
|
||||
}))?
|
||||
};
|
||||
|
||||
if let Some(err) = response.0.error {
|
||||
return Err(Error::msg(err));
|
||||
}
|
||||
|
||||
Ok((response.0.value, response.0.exists))
|
||||
}
|
||||
|
||||
/// SetInt stores an integer value in the cache.
|
||||
///
|
||||
/// Parameters:
|
||||
/// - key: The cache key (will be namespaced with plugin ID)
|
||||
/// - value: The integer value to store
|
||||
/// - ttlSeconds: Time-to-live in seconds (0 uses default of 24 hours)
|
||||
///
|
||||
/// Returns an error if the operation fails.
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `key` - String parameter.
|
||||
/// * `value` - i64 parameter.
|
||||
/// * `ttl_seconds` - i64 parameter.
|
||||
///
|
||||
/// # Errors
|
||||
/// Returns an error if the host function call fails.
|
||||
pub fn set_int(key: &str, value: i64, ttl_seconds: i64) -> Result<(), Error> {
|
||||
let response = unsafe {
|
||||
cache_setint(Json(CacheSetIntRequest {
|
||||
key: key.to_owned(),
|
||||
value: value,
|
||||
ttl_seconds: ttl_seconds,
|
||||
}))?
|
||||
};
|
||||
|
||||
if let Some(err) = response.0.error {
|
||||
return Err(Error::msg(err));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// GetInt retrieves an integer value from the cache.
|
||||
///
|
||||
/// Parameters:
|
||||
/// - key: The cache key (will be namespaced with plugin ID)
|
||||
///
|
||||
/// Returns the value and whether the key exists. If the key doesn't exist
|
||||
/// or the stored value is not an integer, exists will be false.
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `key` - String parameter.
|
||||
///
|
||||
/// # Returns
|
||||
/// A tuple of (value, exists).
|
||||
///
|
||||
/// # Errors
|
||||
/// Returns an error if the host function call fails.
|
||||
pub fn get_int(key: &str) -> Result<(i64, bool), Error> {
|
||||
let response = unsafe {
|
||||
cache_getint(Json(CacheGetIntRequest {
|
||||
key: key.to_owned(),
|
||||
}))?
|
||||
};
|
||||
|
||||
if let Some(err) = response.0.error {
|
||||
return Err(Error::msg(err));
|
||||
}
|
||||
|
||||
Ok((response.0.value, response.0.exists))
|
||||
}
|
||||
|
||||
/// SetFloat stores a float value in the cache.
|
||||
///
|
||||
/// Parameters:
|
||||
/// - key: The cache key (will be namespaced with plugin ID)
|
||||
/// - value: The float value to store
|
||||
/// - ttlSeconds: Time-to-live in seconds (0 uses default of 24 hours)
|
||||
///
|
||||
/// Returns an error if the operation fails.
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `key` - String parameter.
|
||||
/// * `value` - f64 parameter.
|
||||
/// * `ttl_seconds` - i64 parameter.
|
||||
///
|
||||
/// # Errors
|
||||
/// Returns an error if the host function call fails.
|
||||
pub fn set_float(key: &str, value: f64, ttl_seconds: i64) -> Result<(), Error> {
|
||||
let response = unsafe {
|
||||
cache_setfloat(Json(CacheSetFloatRequest {
|
||||
key: key.to_owned(),
|
||||
value: value,
|
||||
ttl_seconds: ttl_seconds,
|
||||
}))?
|
||||
};
|
||||
|
||||
if let Some(err) = response.0.error {
|
||||
return Err(Error::msg(err));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// GetFloat retrieves a float value from the cache.
|
||||
///
|
||||
/// Parameters:
|
||||
/// - key: The cache key (will be namespaced with plugin ID)
|
||||
///
|
||||
/// Returns the value and whether the key exists. If the key doesn't exist
|
||||
/// or the stored value is not a float, exists will be false.
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `key` - String parameter.
|
||||
///
|
||||
/// # Returns
|
||||
/// A tuple of (value, exists).
|
||||
///
|
||||
/// # Errors
|
||||
/// Returns an error if the host function call fails.
|
||||
pub fn get_float(key: &str) -> Result<(f64, bool), Error> {
|
||||
let response = unsafe {
|
||||
cache_getfloat(Json(CacheGetFloatRequest {
|
||||
key: key.to_owned(),
|
||||
}))?
|
||||
};
|
||||
|
||||
if let Some(err) = response.0.error {
|
||||
return Err(Error::msg(err));
|
||||
}
|
||||
|
||||
Ok((response.0.value, response.0.exists))
|
||||
}
|
||||
|
||||
/// SetBytes stores a byte slice in the cache.
|
||||
///
|
||||
/// Parameters:
|
||||
/// - key: The cache key (will be namespaced with plugin ID)
|
||||
/// - value: The byte slice to store
|
||||
/// - ttlSeconds: Time-to-live in seconds (0 uses default of 24 hours)
|
||||
///
|
||||
/// Returns an error if the operation fails.
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `key` - String parameter.
|
||||
/// * `value` - Vec<u8> parameter.
|
||||
/// * `ttl_seconds` - i64 parameter.
|
||||
///
|
||||
/// # Errors
|
||||
/// Returns an error if the host function call fails.
|
||||
pub fn set_bytes(key: &str, value: Vec<u8>, ttl_seconds: i64) -> Result<(), Error> {
|
||||
let response = unsafe {
|
||||
cache_setbytes(Json(CacheSetBytesRequest {
|
||||
key: key.to_owned(),
|
||||
value: value,
|
||||
ttl_seconds: ttl_seconds,
|
||||
}))?
|
||||
};
|
||||
|
||||
if let Some(err) = response.0.error {
|
||||
return Err(Error::msg(err));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// GetBytes retrieves a byte slice from the cache.
|
||||
///
|
||||
/// Parameters:
|
||||
/// - key: The cache key (will be namespaced with plugin ID)
|
||||
///
|
||||
/// Returns the value and whether the key exists. If the key doesn't exist
|
||||
/// or the stored value is not a byte slice, exists will be false.
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `key` - String parameter.
|
||||
///
|
||||
/// # Returns
|
||||
/// A tuple of (value, exists).
|
||||
///
|
||||
/// # Errors
|
||||
/// Returns an error if the host function call fails.
|
||||
pub fn get_bytes(key: &str) -> Result<(Vec<u8>, bool), Error> {
|
||||
let response = unsafe {
|
||||
cache_getbytes(Json(CacheGetBytesRequest {
|
||||
key: key.to_owned(),
|
||||
}))?
|
||||
};
|
||||
|
||||
if let Some(err) = response.0.error {
|
||||
return Err(Error::msg(err));
|
||||
}
|
||||
|
||||
Ok((response.0.value, response.0.exists))
|
||||
}
|
||||
|
||||
/// Has checks if a key exists in the cache.
|
||||
///
|
||||
/// Parameters:
|
||||
/// - key: The cache key (will be namespaced with plugin ID)
|
||||
///
|
||||
/// Returns true if the key exists and has not expired.
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `key` - String parameter.
|
||||
///
|
||||
/// # Returns
|
||||
/// The exists value.
|
||||
///
|
||||
/// # Errors
|
||||
/// Returns an error if the host function call fails.
|
||||
pub fn has(key: &str) -> Result<bool, Error> {
|
||||
let response = unsafe {
|
||||
cache_has(Json(CacheHasRequest {
|
||||
key: key.to_owned(),
|
||||
}))?
|
||||
};
|
||||
|
||||
if let Some(err) = response.0.error {
|
||||
return Err(Error::msg(err));
|
||||
}
|
||||
|
||||
Ok(response.0.exists)
|
||||
}
|
||||
|
||||
/// Remove deletes a value from the cache.
|
||||
///
|
||||
/// Parameters:
|
||||
/// - key: The cache key (will be namespaced with plugin ID)
|
||||
///
|
||||
/// Returns an error if the operation fails. Does not return an error if the key doesn't exist.
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `key` - String parameter.
|
||||
///
|
||||
/// # Errors
|
||||
/// Returns an error if the host function call fails.
|
||||
pub fn remove(key: &str) -> Result<(), Error> {
|
||||
let response = unsafe {
|
||||
cache_remove(Json(CacheRemoveRequest {
|
||||
key: key.to_owned(),
|
||||
}))?
|
||||
};
|
||||
|
||||
if let Some(err) = response.0.error {
|
||||
return Err(Error::msg(err));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
261
plugins/host/rust/nd_host_kvstore.rs
Normal file
261
plugins/host/rust/nd_host_kvstore.rs
Normal file
@ -0,0 +1,261 @@
|
||||
// Code generated by hostgen. DO NOT EDIT.
|
||||
//
|
||||
// This file contains client wrappers for the KVStore 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 KVStoreSetRequest {
|
||||
key: String,
|
||||
value: Vec<u8>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct KVStoreSetResponse {
|
||||
#[serde(default)]
|
||||
error: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct KVStoreGetRequest {
|
||||
key: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct KVStoreGetResponse {
|
||||
#[serde(default)]
|
||||
value: Vec<u8>,
|
||||
#[serde(default)]
|
||||
exists: bool,
|
||||
#[serde(default)]
|
||||
error: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct KVStoreDeleteRequest {
|
||||
key: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct KVStoreDeleteResponse {
|
||||
#[serde(default)]
|
||||
error: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct KVStoreHasRequest {
|
||||
key: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct KVStoreHasResponse {
|
||||
#[serde(default)]
|
||||
exists: bool,
|
||||
#[serde(default)]
|
||||
error: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct KVStoreListRequest {
|
||||
prefix: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct KVStoreListResponse {
|
||||
#[serde(default)]
|
||||
keys: Vec<String>,
|
||||
#[serde(default)]
|
||||
error: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct KVStoreGetStorageUsedResponse {
|
||||
#[serde(default)]
|
||||
bytes: i64,
|
||||
#[serde(default)]
|
||||
error: Option<String>,
|
||||
}
|
||||
|
||||
#[host_fn]
|
||||
extern "ExtismHost" {
|
||||
fn kvstore_set(input: Json<KVStoreSetRequest>) -> Json<KVStoreSetResponse>;
|
||||
fn kvstore_get(input: Json<KVStoreGetRequest>) -> Json<KVStoreGetResponse>;
|
||||
fn kvstore_delete(input: Json<KVStoreDeleteRequest>) -> Json<KVStoreDeleteResponse>;
|
||||
fn kvstore_has(input: Json<KVStoreHasRequest>) -> Json<KVStoreHasResponse>;
|
||||
fn kvstore_list(input: Json<KVStoreListRequest>) -> Json<KVStoreListResponse>;
|
||||
fn kvstore_getstorageused(input: Json<serde_json::Value>) -> Json<KVStoreGetStorageUsedResponse>;
|
||||
}
|
||||
|
||||
/// Set stores a byte value with the given key.
|
||||
///
|
||||
/// Parameters:
|
||||
/// - key: The storage key (max 256 bytes, UTF-8)
|
||||
/// - value: The byte slice to store
|
||||
///
|
||||
/// Returns an error if the storage limit would be exceeded or the operation fails.
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `key` - String parameter.
|
||||
/// * `value` - Vec<u8> parameter.
|
||||
///
|
||||
/// # Errors
|
||||
/// Returns an error if the host function call fails.
|
||||
pub fn set(key: &str, value: Vec<u8>) -> Result<(), Error> {
|
||||
let response = unsafe {
|
||||
kvstore_set(Json(KVStoreSetRequest {
|
||||
key: key.to_owned(),
|
||||
value: value,
|
||||
}))?
|
||||
};
|
||||
|
||||
if let Some(err) = response.0.error {
|
||||
return Err(Error::msg(err));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Get retrieves a byte value from storage.
|
||||
///
|
||||
/// Parameters:
|
||||
/// - key: The storage key
|
||||
///
|
||||
/// Returns the value and whether the key exists.
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `key` - String parameter.
|
||||
///
|
||||
/// # Returns
|
||||
/// A tuple of (value, exists).
|
||||
///
|
||||
/// # Errors
|
||||
/// Returns an error if the host function call fails.
|
||||
pub fn get(key: &str) -> Result<(Vec<u8>, bool), Error> {
|
||||
let response = unsafe {
|
||||
kvstore_get(Json(KVStoreGetRequest {
|
||||
key: key.to_owned(),
|
||||
}))?
|
||||
};
|
||||
|
||||
if let Some(err) = response.0.error {
|
||||
return Err(Error::msg(err));
|
||||
}
|
||||
|
||||
Ok((response.0.value, response.0.exists))
|
||||
}
|
||||
|
||||
/// Delete removes a value from storage.
|
||||
///
|
||||
/// Parameters:
|
||||
/// - key: The storage key
|
||||
///
|
||||
/// Returns an error if the operation fails. Does not return an error if the key doesn't exist.
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `key` - String parameter.
|
||||
///
|
||||
/// # Errors
|
||||
/// Returns an error if the host function call fails.
|
||||
pub fn delete(key: &str) -> Result<(), Error> {
|
||||
let response = unsafe {
|
||||
kvstore_delete(Json(KVStoreDeleteRequest {
|
||||
key: key.to_owned(),
|
||||
}))?
|
||||
};
|
||||
|
||||
if let Some(err) = response.0.error {
|
||||
return Err(Error::msg(err));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Has checks if a key exists in storage.
|
||||
///
|
||||
/// Parameters:
|
||||
/// - key: The storage key
|
||||
///
|
||||
/// Returns true if the key exists.
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `key` - String parameter.
|
||||
///
|
||||
/// # Returns
|
||||
/// The exists value.
|
||||
///
|
||||
/// # Errors
|
||||
/// Returns an error if the host function call fails.
|
||||
pub fn has(key: &str) -> Result<bool, Error> {
|
||||
let response = unsafe {
|
||||
kvstore_has(Json(KVStoreHasRequest {
|
||||
key: key.to_owned(),
|
||||
}))?
|
||||
};
|
||||
|
||||
if let Some(err) = response.0.error {
|
||||
return Err(Error::msg(err));
|
||||
}
|
||||
|
||||
Ok(response.0.exists)
|
||||
}
|
||||
|
||||
/// List returns all keys matching the given prefix.
|
||||
///
|
||||
/// Parameters:
|
||||
/// - prefix: Key prefix to filter by (empty string returns all keys)
|
||||
///
|
||||
/// Returns a slice of matching keys.
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `prefix` - String parameter.
|
||||
///
|
||||
/// # Returns
|
||||
/// The keys value.
|
||||
///
|
||||
/// # Errors
|
||||
/// Returns an error if the host function call fails.
|
||||
pub fn list(prefix: &str) -> Result<Vec<String>, Error> {
|
||||
let response = unsafe {
|
||||
kvstore_list(Json(KVStoreListRequest {
|
||||
prefix: prefix.to_owned(),
|
||||
}))?
|
||||
};
|
||||
|
||||
if let Some(err) = response.0.error {
|
||||
return Err(Error::msg(err));
|
||||
}
|
||||
|
||||
Ok(response.0.keys)
|
||||
}
|
||||
|
||||
/// GetStorageUsed returns the total storage used by this plugin in bytes.
|
||||
///
|
||||
/// # Returns
|
||||
/// The bytes value.
|
||||
///
|
||||
/// # Errors
|
||||
/// Returns an error if the host function call fails.
|
||||
pub fn get_storage_used() -> Result<i64, Error> {
|
||||
let response = unsafe {
|
||||
kvstore_getstorageused(Json(serde_json::json!({})))?
|
||||
};
|
||||
|
||||
if let Some(err) = response.0.error {
|
||||
return Err(Error::msg(err));
|
||||
}
|
||||
|
||||
Ok(response.0.bytes)
|
||||
}
|
||||
87
plugins/host/rust/nd_host_library.rs
Normal file
87
plugins/host/rust/nd_host_library.rs
Normal file
@ -0,0 +1,87 @@
|
||||
// Code generated by hostgen. DO NOT EDIT.
|
||||
//
|
||||
// This file contains client wrappers for the Library 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 LibraryGetLibraryRequest {
|
||||
id: i32,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct LibraryGetLibraryResponse {
|
||||
#[serde(default)]
|
||||
result: Option<serde_json::Value>,
|
||||
#[serde(default)]
|
||||
error: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct LibraryGetAllLibrariesResponse {
|
||||
#[serde(default)]
|
||||
result: Vec<serde_json::Value>,
|
||||
#[serde(default)]
|
||||
error: Option<String>,
|
||||
}
|
||||
|
||||
#[host_fn]
|
||||
extern "ExtismHost" {
|
||||
fn library_getlibrary(input: Json<LibraryGetLibraryRequest>) -> Json<LibraryGetLibraryResponse>;
|
||||
fn library_getalllibraries(input: Json<serde_json::Value>) -> Json<LibraryGetAllLibrariesResponse>;
|
||||
}
|
||||
|
||||
/// GetLibrary retrieves metadata for a specific library by ID.
|
||||
///
|
||||
/// Parameters:
|
||||
/// - id: The library's unique identifier
|
||||
///
|
||||
/// Returns the library metadata, or an error if the library is not found.
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `id` - i32 parameter.
|
||||
///
|
||||
/// # Returns
|
||||
/// The result value.
|
||||
///
|
||||
/// # Errors
|
||||
/// Returns an error if the host function call fails.
|
||||
pub fn get_library(id: i32) -> Result<Option<serde_json::Value>, Error> {
|
||||
let response = unsafe {
|
||||
library_getlibrary(Json(LibraryGetLibraryRequest {
|
||||
id: id,
|
||||
}))?
|
||||
};
|
||||
|
||||
if let Some(err) = response.0.error {
|
||||
return Err(Error::msg(err));
|
||||
}
|
||||
|
||||
Ok(response.0.result)
|
||||
}
|
||||
|
||||
/// GetAllLibraries retrieves metadata for all configured libraries.
|
||||
///
|
||||
/// Returns a slice of all libraries with their metadata.
|
||||
///
|
||||
/// # Returns
|
||||
/// The result value.
|
||||
///
|
||||
/// # Errors
|
||||
/// Returns an error if the host function call fails.
|
||||
pub fn get_all_libraries() -> Result<Vec<serde_json::Value>, Error> {
|
||||
let response = unsafe {
|
||||
library_getalllibraries(Json(serde_json::json!({})))?
|
||||
};
|
||||
|
||||
if let Some(err) = response.0.error {
|
||||
return Err(Error::msg(err));
|
||||
}
|
||||
|
||||
Ok(response.0.result)
|
||||
}
|
||||
159
plugins/host/rust/nd_host_scheduler.rs
Normal file
159
plugins/host/rust/nd_host_scheduler.rs
Normal file
@ -0,0 +1,159 @@
|
||||
// Code generated by hostgen. DO NOT EDIT.
|
||||
//
|
||||
// This file contains client wrappers for the Scheduler 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 SchedulerScheduleOneTimeRequest {
|
||||
delay_seconds: i32,
|
||||
payload: String,
|
||||
schedule_id: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct SchedulerScheduleOneTimeResponse {
|
||||
#[serde(default)]
|
||||
new_schedule_id: String,
|
||||
#[serde(default)]
|
||||
error: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct SchedulerScheduleRecurringRequest {
|
||||
cron_expression: String,
|
||||
payload: String,
|
||||
schedule_id: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct SchedulerScheduleRecurringResponse {
|
||||
#[serde(default)]
|
||||
new_schedule_id: String,
|
||||
#[serde(default)]
|
||||
error: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct SchedulerCancelScheduleRequest {
|
||||
schedule_id: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct SchedulerCancelScheduleResponse {
|
||||
#[serde(default)]
|
||||
error: Option<String>,
|
||||
}
|
||||
|
||||
#[host_fn]
|
||||
extern "ExtismHost" {
|
||||
fn scheduler_scheduleonetime(input: Json<SchedulerScheduleOneTimeRequest>) -> Json<SchedulerScheduleOneTimeResponse>;
|
||||
fn scheduler_schedulerecurring(input: Json<SchedulerScheduleRecurringRequest>) -> Json<SchedulerScheduleRecurringResponse>;
|
||||
fn scheduler_cancelschedule(input: Json<SchedulerCancelScheduleRequest>) -> Json<SchedulerCancelScheduleResponse>;
|
||||
}
|
||||
|
||||
/// ScheduleOneTime schedules a one-time event to be triggered after the specified delay.
|
||||
/// Plugins that use this function must also implement the SchedulerCallback capability
|
||||
///
|
||||
/// Parameters:
|
||||
/// - delaySeconds: Number of seconds to wait before triggering the event
|
||||
/// - payload: Data to be passed to the scheduled event handler
|
||||
/// - scheduleID: Optional unique identifier for the scheduled job. If empty, one will be generated
|
||||
///
|
||||
/// Returns the schedule ID that can be used to cancel the job, or an error if scheduling fails.
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `delay_seconds` - i32 parameter.
|
||||
/// * `payload` - String parameter.
|
||||
/// * `schedule_id` - String parameter.
|
||||
///
|
||||
/// # Returns
|
||||
/// The new_schedule_id value.
|
||||
///
|
||||
/// # Errors
|
||||
/// Returns an error if the host function call fails.
|
||||
pub fn schedule_one_time(delay_seconds: i32, payload: &str, schedule_id: &str) -> Result<String, Error> {
|
||||
let response = unsafe {
|
||||
scheduler_scheduleonetime(Json(SchedulerScheduleOneTimeRequest {
|
||||
delay_seconds: delay_seconds,
|
||||
payload: payload.to_owned(),
|
||||
schedule_id: schedule_id.to_owned(),
|
||||
}))?
|
||||
};
|
||||
|
||||
if let Some(err) = response.0.error {
|
||||
return Err(Error::msg(err));
|
||||
}
|
||||
|
||||
Ok(response.0.new_schedule_id)
|
||||
}
|
||||
|
||||
/// ScheduleRecurring schedules a recurring event using a cron expression.
|
||||
/// Plugins that use this function must also implement the SchedulerCallback capability
|
||||
///
|
||||
/// Parameters:
|
||||
/// - cronExpression: Standard cron format expression (e.g., "0 0 * * *" for daily at midnight)
|
||||
/// - payload: Data to be passed to each scheduled event handler invocation
|
||||
/// - scheduleID: Optional unique identifier for the scheduled job. If empty, one will be generated
|
||||
///
|
||||
/// Returns the schedule ID that can be used to cancel the job, or an error if scheduling fails.
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `cron_expression` - String parameter.
|
||||
/// * `payload` - String parameter.
|
||||
/// * `schedule_id` - String parameter.
|
||||
///
|
||||
/// # Returns
|
||||
/// The new_schedule_id value.
|
||||
///
|
||||
/// # Errors
|
||||
/// Returns an error if the host function call fails.
|
||||
pub fn schedule_recurring(cron_expression: &str, payload: &str, schedule_id: &str) -> Result<String, Error> {
|
||||
let response = unsafe {
|
||||
scheduler_schedulerecurring(Json(SchedulerScheduleRecurringRequest {
|
||||
cron_expression: cron_expression.to_owned(),
|
||||
payload: payload.to_owned(),
|
||||
schedule_id: schedule_id.to_owned(),
|
||||
}))?
|
||||
};
|
||||
|
||||
if let Some(err) = response.0.error {
|
||||
return Err(Error::msg(err));
|
||||
}
|
||||
|
||||
Ok(response.0.new_schedule_id)
|
||||
}
|
||||
|
||||
/// CancelSchedule cancels a scheduled job identified by its schedule ID.
|
||||
///
|
||||
/// This works for both one-time and recurring schedules. Once cancelled, the job will not trigger
|
||||
/// any future events.
|
||||
///
|
||||
/// Returns an error if the schedule ID is not found or if cancellation fails.
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `schedule_id` - String parameter.
|
||||
///
|
||||
/// # Errors
|
||||
/// Returns an error if the host function call fails.
|
||||
pub fn cancel_schedule(schedule_id: &str) -> Result<(), Error> {
|
||||
let response = unsafe {
|
||||
scheduler_cancelschedule(Json(SchedulerCancelScheduleRequest {
|
||||
schedule_id: schedule_id.to_owned(),
|
||||
}))?
|
||||
};
|
||||
|
||||
if let Some(err) = response.0.error {
|
||||
return Err(Error::msg(err));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
54
plugins/host/rust/nd_host_subsonicapi.rs
Normal file
54
plugins/host/rust/nd_host_subsonicapi.rs
Normal file
@ -0,0 +1,54 @@
|
||||
// Code generated by hostgen. DO NOT EDIT.
|
||||
//
|
||||
// This file contains client wrappers for the SubsonicAPI 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 SubsonicAPICallRequest {
|
||||
uri: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct SubsonicAPICallResponse {
|
||||
#[serde(default)]
|
||||
response_json: String,
|
||||
#[serde(default)]
|
||||
error: Option<String>,
|
||||
}
|
||||
|
||||
#[host_fn]
|
||||
extern "ExtismHost" {
|
||||
fn subsonicapi_call(input: Json<SubsonicAPICallRequest>) -> Json<SubsonicAPICallResponse>;
|
||||
}
|
||||
|
||||
/// Call executes a Subsonic API request and returns the JSON response.
|
||||
///
|
||||
/// The uri parameter should be the Subsonic API path without the server prefix,
|
||||
/// e.g., "getAlbumList2?type=random&size=10". The response is returned as raw JSON.
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `uri` - String parameter.
|
||||
///
|
||||
/// # Returns
|
||||
/// The response_json value.
|
||||
///
|
||||
/// # Errors
|
||||
/// Returns an error if the host function call fails.
|
||||
pub fn call(uri: &str) -> Result<String, Error> {
|
||||
let response = unsafe {
|
||||
subsonicapi_call(Json(SubsonicAPICallRequest {
|
||||
uri: uri.to_owned(),
|
||||
}))?
|
||||
};
|
||||
|
||||
if let Some(err) = response.0.error {
|
||||
return Err(Error::msg(err));
|
||||
}
|
||||
|
||||
Ok(response.0.response_json)
|
||||
}
|
||||
204
plugins/host/rust/nd_host_websocket.rs
Normal file
204
plugins/host/rust/nd_host_websocket.rs
Normal file
@ -0,0 +1,204 @@
|
||||
// Code generated by hostgen. DO NOT EDIT.
|
||||
//
|
||||
// This file contains client wrappers for the WebSocket 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 WebSocketConnectRequest {
|
||||
url: String,
|
||||
headers: std::collections::HashMap<String, String>,
|
||||
connection_id: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct WebSocketConnectResponse {
|
||||
#[serde(default)]
|
||||
new_connection_id: String,
|
||||
#[serde(default)]
|
||||
error: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct WebSocketSendTextRequest {
|
||||
connection_id: String,
|
||||
message: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct WebSocketSendTextResponse {
|
||||
#[serde(default)]
|
||||
error: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct WebSocketSendBinaryRequest {
|
||||
connection_id: String,
|
||||
data: Vec<u8>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct WebSocketSendBinaryResponse {
|
||||
#[serde(default)]
|
||||
error: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct WebSocketCloseConnectionRequest {
|
||||
connection_id: String,
|
||||
code: i32,
|
||||
reason: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct WebSocketCloseConnectionResponse {
|
||||
#[serde(default)]
|
||||
error: Option<String>,
|
||||
}
|
||||
|
||||
#[host_fn]
|
||||
extern "ExtismHost" {
|
||||
fn websocket_connect(input: Json<WebSocketConnectRequest>) -> Json<WebSocketConnectResponse>;
|
||||
fn websocket_sendtext(input: Json<WebSocketSendTextRequest>) -> Json<WebSocketSendTextResponse>;
|
||||
fn websocket_sendbinary(input: Json<WebSocketSendBinaryRequest>) -> Json<WebSocketSendBinaryResponse>;
|
||||
fn websocket_closeconnection(input: Json<WebSocketCloseConnectionRequest>) -> Json<WebSocketCloseConnectionResponse>;
|
||||
}
|
||||
|
||||
/// Connect establishes a WebSocket connection to the specified URL.
|
||||
///
|
||||
/// Plugins that use this function must also implement the WebSocketCallback capability
|
||||
/// to receive incoming messages and connection events.
|
||||
///
|
||||
/// Parameters:
|
||||
/// - url: The WebSocket URL to connect to (ws:// or wss://)
|
||||
/// - headers: Optional HTTP headers to include in the handshake request
|
||||
/// - connectionID: Optional unique identifier for the connection. If empty, one will be generated
|
||||
///
|
||||
/// Returns the connection ID that can be used to send messages or close the connection,
|
||||
/// or an error if the connection fails.
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `url` - String parameter.
|
||||
/// * `headers` - std::collections::HashMap<String, String> parameter.
|
||||
/// * `connection_id` - String parameter.
|
||||
///
|
||||
/// # Returns
|
||||
/// The new_connection_id value.
|
||||
///
|
||||
/// # Errors
|
||||
/// Returns an error if the host function call fails.
|
||||
pub fn connect(url: &str, headers: std::collections::HashMap<String, String>, connection_id: &str) -> Result<String, Error> {
|
||||
let response = unsafe {
|
||||
websocket_connect(Json(WebSocketConnectRequest {
|
||||
url: url.to_owned(),
|
||||
headers: headers,
|
||||
connection_id: connection_id.to_owned(),
|
||||
}))?
|
||||
};
|
||||
|
||||
if let Some(err) = response.0.error {
|
||||
return Err(Error::msg(err));
|
||||
}
|
||||
|
||||
Ok(response.0.new_connection_id)
|
||||
}
|
||||
|
||||
/// SendText sends a text message over an established WebSocket connection.
|
||||
///
|
||||
/// Parameters:
|
||||
/// - connectionID: The connection identifier returned by Connect
|
||||
/// - message: The text message to send
|
||||
///
|
||||
/// Returns an error if the connection is not found or if sending fails.
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `connection_id` - String parameter.
|
||||
/// * `message` - String parameter.
|
||||
///
|
||||
/// # Errors
|
||||
/// Returns an error if the host function call fails.
|
||||
pub fn send_text(connection_id: &str, message: &str) -> Result<(), Error> {
|
||||
let response = unsafe {
|
||||
websocket_sendtext(Json(WebSocketSendTextRequest {
|
||||
connection_id: connection_id.to_owned(),
|
||||
message: message.to_owned(),
|
||||
}))?
|
||||
};
|
||||
|
||||
if let Some(err) = response.0.error {
|
||||
return Err(Error::msg(err));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// SendBinary sends binary data over an established WebSocket connection.
|
||||
///
|
||||
/// Parameters:
|
||||
/// - connectionID: The connection identifier returned by Connect
|
||||
/// - data: The binary data to send
|
||||
///
|
||||
/// Returns an error if the connection is not found or if sending fails.
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `connection_id` - String parameter.
|
||||
/// * `data` - Vec<u8> parameter.
|
||||
///
|
||||
/// # Errors
|
||||
/// Returns an error if the host function call fails.
|
||||
pub fn send_binary(connection_id: &str, data: Vec<u8>) -> Result<(), Error> {
|
||||
let response = unsafe {
|
||||
websocket_sendbinary(Json(WebSocketSendBinaryRequest {
|
||||
connection_id: connection_id.to_owned(),
|
||||
data: data,
|
||||
}))?
|
||||
};
|
||||
|
||||
if let Some(err) = response.0.error {
|
||||
return Err(Error::msg(err));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// CloseConnection gracefully closes a WebSocket connection.
|
||||
///
|
||||
/// Parameters:
|
||||
/// - connectionID: The connection identifier returned by Connect
|
||||
/// - code: WebSocket close status code (e.g., 1000 for normal closure)
|
||||
/// - reason: Optional human-readable reason for closing
|
||||
///
|
||||
/// Returns an error if the connection is not found or if closing fails.
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `connection_id` - String parameter.
|
||||
/// * `code` - i32 parameter.
|
||||
/// * `reason` - String parameter.
|
||||
///
|
||||
/// # Errors
|
||||
/// Returns an error if the host function call fails.
|
||||
pub fn close_connection(connection_id: &str, code: i32, reason: &str) -> Result<(), Error> {
|
||||
let response = unsafe {
|
||||
websocket_closeconnection(Json(WebSocketCloseConnectionRequest {
|
||||
connection_id: connection_id.to_owned(),
|
||||
code: code,
|
||||
reason: reason.to_owned(),
|
||||
}))?
|
||||
};
|
||||
|
||||
if let Some(err) = response.0.error {
|
||||
return Err(Error::msg(err));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@ -13,12 +13,12 @@ import (
|
||||
type SchedulerScheduleOneTimeRequest struct {
|
||||
DelaySeconds int32 `json:"delaySeconds"`
|
||||
Payload string `json:"payload"`
|
||||
ScheduleID string `json:"scheduleID"`
|
||||
ScheduleID string `json:"scheduleId"`
|
||||
}
|
||||
|
||||
// SchedulerScheduleOneTimeResponse is the response type for Scheduler.ScheduleOneTime.
|
||||
type SchedulerScheduleOneTimeResponse struct {
|
||||
NewScheduleID string `json:"newScheduleID,omitempty"`
|
||||
NewScheduleID string `json:"newScheduleId,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
@ -26,18 +26,18 @@ type SchedulerScheduleOneTimeResponse struct {
|
||||
type SchedulerScheduleRecurringRequest struct {
|
||||
CronExpression string `json:"cronExpression"`
|
||||
Payload string `json:"payload"`
|
||||
ScheduleID string `json:"scheduleID"`
|
||||
ScheduleID string `json:"scheduleId"`
|
||||
}
|
||||
|
||||
// SchedulerScheduleRecurringResponse is the response type for Scheduler.ScheduleRecurring.
|
||||
type SchedulerScheduleRecurringResponse struct {
|
||||
NewScheduleID string `json:"newScheduleID,omitempty"`
|
||||
NewScheduleID string `json:"newScheduleId,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
// SchedulerCancelScheduleRequest is the request type for Scheduler.CancelSchedule.
|
||||
type SchedulerCancelScheduleRequest struct {
|
||||
ScheduleID string `json:"scheduleID"`
|
||||
ScheduleID string `json:"scheduleId"`
|
||||
}
|
||||
|
||||
// SchedulerCancelScheduleResponse is the response type for Scheduler.CancelSchedule.
|
||||
|
||||
@ -16,7 +16,7 @@ type SubsonicAPICallRequest struct {
|
||||
|
||||
// SubsonicAPICallResponse is the response type for SubsonicAPI.Call.
|
||||
type SubsonicAPICallResponse struct {
|
||||
ResponseJSON string `json:"responseJSON,omitempty"`
|
||||
ResponseJSON string `json:"responseJson,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
|
||||
@ -13,18 +13,18 @@ import (
|
||||
type WebSocketConnectRequest struct {
|
||||
Url string `json:"url"`
|
||||
Headers map[string]string `json:"headers"`
|
||||
ConnectionID string `json:"connectionID"`
|
||||
ConnectionID string `json:"connectionId"`
|
||||
}
|
||||
|
||||
// WebSocketConnectResponse is the response type for WebSocket.Connect.
|
||||
type WebSocketConnectResponse struct {
|
||||
NewConnectionID string `json:"newConnectionID,omitempty"`
|
||||
NewConnectionID string `json:"newConnectionId,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
// WebSocketSendTextRequest is the request type for WebSocket.SendText.
|
||||
type WebSocketSendTextRequest struct {
|
||||
ConnectionID string `json:"connectionID"`
|
||||
ConnectionID string `json:"connectionId"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
@ -35,7 +35,7 @@ type WebSocketSendTextResponse struct {
|
||||
|
||||
// WebSocketSendBinaryRequest is the request type for WebSocket.SendBinary.
|
||||
type WebSocketSendBinaryRequest struct {
|
||||
ConnectionID string `json:"connectionID"`
|
||||
ConnectionID string `json:"connectionId"`
|
||||
Data []byte `json:"data"`
|
||||
}
|
||||
|
||||
@ -46,7 +46,7 @@ type WebSocketSendBinaryResponse struct {
|
||||
|
||||
// WebSocketCloseConnectionRequest is the request type for WebSocket.CloseConnection.
|
||||
type WebSocketCloseConnectionRequest struct {
|
||||
ConnectionID string `json:"connectionID"`
|
||||
ConnectionID string `json:"connectionId"`
|
||||
Code int32 `json:"code"`
|
||||
Reason string `json:"reason"`
|
||||
}
|
||||
|
||||
@ -33,12 +33,12 @@ func scheduler_cancelschedule(uint64) uint64
|
||||
type SchedulerScheduleOneTimeRequest struct {
|
||||
DelaySeconds int32 `json:"delaySeconds"`
|
||||
Payload string `json:"payload"`
|
||||
ScheduleID string `json:"scheduleID"`
|
||||
ScheduleID string `json:"scheduleId"`
|
||||
}
|
||||
|
||||
// SchedulerScheduleOneTimeResponse is the response type for Scheduler.ScheduleOneTime.
|
||||
type SchedulerScheduleOneTimeResponse struct {
|
||||
NewScheduleID string `json:"newScheduleID,omitempty"`
|
||||
NewScheduleID string `json:"newScheduleId,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
@ -46,18 +46,18 @@ type SchedulerScheduleOneTimeResponse struct {
|
||||
type SchedulerScheduleRecurringRequest struct {
|
||||
CronExpression string `json:"cronExpression"`
|
||||
Payload string `json:"payload"`
|
||||
ScheduleID string `json:"scheduleID"`
|
||||
ScheduleID string `json:"scheduleId"`
|
||||
}
|
||||
|
||||
// SchedulerScheduleRecurringResponse is the response type for Scheduler.ScheduleRecurring.
|
||||
type SchedulerScheduleRecurringResponse struct {
|
||||
NewScheduleID string `json:"newScheduleID,omitempty"`
|
||||
NewScheduleID string `json:"newScheduleId,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
// SchedulerCancelScheduleRequest is the request type for Scheduler.CancelSchedule.
|
||||
type SchedulerCancelScheduleRequest struct {
|
||||
ScheduleID string `json:"scheduleID"`
|
||||
ScheduleID string `json:"scheduleId"`
|
||||
}
|
||||
|
||||
// SchedulerCancelScheduleResponse is the response type for Scheduler.CancelSchedule.
|
||||
|
||||
@ -26,7 +26,7 @@ type SubsonicAPICallRequest struct {
|
||||
|
||||
// SubsonicAPICallResponse is the response type for SubsonicAPI.Call.
|
||||
type SubsonicAPICallResponse struct {
|
||||
ResponseJSON string `json:"responseJSON,omitempty"`
|
||||
ResponseJSON string `json:"responseJson,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
|
||||
@ -38,18 +38,18 @@ func websocket_closeconnection(uint64) uint64
|
||||
type WebSocketConnectRequest struct {
|
||||
Url string `json:"url"`
|
||||
Headers map[string]string `json:"headers"`
|
||||
ConnectionID string `json:"connectionID"`
|
||||
ConnectionID string `json:"connectionId"`
|
||||
}
|
||||
|
||||
// WebSocketConnectResponse is the response type for WebSocket.Connect.
|
||||
type WebSocketConnectResponse struct {
|
||||
NewConnectionID string `json:"newConnectionID,omitempty"`
|
||||
NewConnectionID string `json:"newConnectionId,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
// WebSocketSendTextRequest is the request type for WebSocket.SendText.
|
||||
type WebSocketSendTextRequest struct {
|
||||
ConnectionID string `json:"connectionID"`
|
||||
ConnectionID string `json:"connectionId"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
@ -60,7 +60,7 @@ type WebSocketSendTextResponse struct {
|
||||
|
||||
// WebSocketSendBinaryRequest is the request type for WebSocket.SendBinary.
|
||||
type WebSocketSendBinaryRequest struct {
|
||||
ConnectionID string `json:"connectionID"`
|
||||
ConnectionID string `json:"connectionId"`
|
||||
Data []byte `json:"data"`
|
||||
}
|
||||
|
||||
@ -71,7 +71,7 @@ type WebSocketSendBinaryResponse struct {
|
||||
|
||||
// WebSocketCloseConnectionRequest is the request type for WebSocket.CloseConnection.
|
||||
type WebSocketCloseConnectionRequest struct {
|
||||
ConnectionID string `json:"connectionID"`
|
||||
ConnectionID string `json:"connectionId"`
|
||||
Code int32 `json:"code"`
|
||||
Reason string `json:"reason"`
|
||||
}
|
||||
|
||||
@ -54,12 +54,8 @@ const PluginShowLayout = () => {
|
||||
const obj = {}
|
||||
pairs.forEach((pair) => {
|
||||
if (pair.key.trim()) {
|
||||
// Try to parse value as JSON, otherwise use as string
|
||||
try {
|
||||
obj[pair.key] = JSON.parse(pair.value)
|
||||
} catch {
|
||||
obj[pair.key] = pair.value
|
||||
}
|
||||
// Always store values as strings (backend expects map[string]string)
|
||||
obj[pair.key] = pair.value
|
||||
}
|
||||
})
|
||||
return JSON.stringify(obj)
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user