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:
Deluan 2025-12-29 14:25:13 -05:00
parent 2ec972cdc8
commit 8dad4f4a9c
59 changed files with 4855 additions and 140 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

@ -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"
}

View File

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

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

View 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"")

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

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

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

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

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

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

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

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

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

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

View File

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

View File

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

View File

@ -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"`
}

View File

@ -0,0 +1,2 @@
[build]
target = "wasm32-wasip1"

View 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" }

View 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

View 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"
}
}
}

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

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

View File

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

View File

@ -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"`
}

View File

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

View File

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

View File

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

View File

@ -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"`
}

View File

@ -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"`
}

View File

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

View File

@ -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", "")

View File

@ -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
View File

@ -0,0 +1 @@
!Cargo.lock

380
plugins/host/rust/Cargo.lock generated Normal file
View 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"

View 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"

View 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
View 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;

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

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

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

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

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

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

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

View File

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

View File

@ -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"`
}

View File

@ -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"`
}

View File

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

View File

@ -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"`
}

View File

@ -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"`
}

View File

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