From 8dad4f4a9cff9ea9895f5a3fe3bfe7fdc1c7d3b1 Mon Sep 17 00:00:00 2001 From: Deluan Date: Mon, 29 Dec 2025 14:25:13 -0500 Subject: [PATCH] feat(plugins): add Rust host function library and example implementation of Discord Rich Presence plugin in Rust Signed-off-by: Deluan --- plugins/{examples/webhook-rs => }/.gitignore | 5 +- plugins/README.md | 21 +- plugins/cmd/hostgen/README.md | 80 ++- plugins/cmd/hostgen/integration_test.go | 40 +- plugins/cmd/hostgen/internal/generator.go | 36 ++ .../cmd/hostgen/internal/generator_test.go | 18 +- .../internal/templates/client_rs.rs.tmpl | 86 ++++ plugins/cmd/hostgen/internal/types.go | 185 ++++++- plugins/cmd/hostgen/main.go | 52 +- .../hostgen/testdata/codec_client_expected.rs | 51 ++ .../testdata/comprehensive_client_expected.py | 341 +++++++++++++ .../testdata/comprehensive_client_expected.rs | 385 ++++++++++++++ .../testdata/counter_client_expected.rs | 51 ++ .../hostgen/testdata/echo_client_expected.rs | 51 ++ .../hostgen/testdata/list_client_expected.rs | 54 ++ .../hostgen/testdata/math_client_expected.rs | 54 ++ .../hostgen/testdata/meta_client_expected.rs | 86 ++++ .../hostgen/testdata/ping_client_expected.rs | 35 ++ .../testdata/search_client_expected.rs | 53 ++ .../hostgen/testdata/store_client_expected.rs | 51 ++ .../hostgen/testdata/users_client_expected.rs | 54 ++ plugins/examples/README.md | 21 +- .../crypto-ticker/nd_host_scheduler.go | 10 +- .../crypto-ticker/nd_host_websocket.go | 10 +- .../.cargo/config.toml | 2 + .../discord-rich-presence-rs/Cargo.toml | 16 + .../discord-rich-presence-rs/README.md | 94 ++++ .../discord-rich-presence-rs/manifest.json | 26 + .../discord-rich-presence-rs/src/lib.rs | 419 +++++++++++++++ .../discord-rich-presence-rs/src/rpc.rs | 480 ++++++++++++++++++ .../nd_host_scheduler.go | 10 +- .../nd_host_websocket.go | 10 +- .../examples/nowplaying-py/plugin/__init__.py | 6 +- plugins/host/doc.go | 2 +- plugins/host/go/nd_host_scheduler.go | 10 +- plugins/host/go/nd_host_subsonicapi.go | 2 +- plugins/host/go/nd_host_websocket.go | 10 +- plugins/host/python/nd_host_scheduler.py | 22 +- plugins/host/python/nd_host_subsonicapi.py | 2 +- plugins/host/python/nd_host_websocket.py | 26 +- plugins/host/rust/.gitignore | 1 + plugins/host/rust/Cargo.lock | 380 ++++++++++++++ plugins/host/rust/Cargo.toml | 17 + plugins/host/rust/README.md | 63 +++ plugins/host/rust/lib.rs | 63 +++ plugins/host/rust/nd_host_artwork.rs | 207 ++++++++ plugins/host/rust/nd_host_cache.rs | 480 ++++++++++++++++++ plugins/host/rust/nd_host_kvstore.rs | 261 ++++++++++ plugins/host/rust/nd_host_library.rs | 87 ++++ plugins/host/rust/nd_host_scheduler.rs | 159 ++++++ plugins/host/rust/nd_host_subsonicapi.rs | 54 ++ plugins/host/rust/nd_host_websocket.rs | 204 ++++++++ plugins/host/scheduler_gen.go | 10 +- plugins/host/subsonicapi_gen.go | 2 +- plugins/host/websocket_gen.go | 10 +- .../test-scheduler/nd_host_scheduler.go | 10 +- .../nd_host_subsonicapi.go | 2 +- .../test-websocket/nd_host_websocket.go | 10 +- ui/src/plugin/PluginShow.jsx | 8 +- 59 files changed, 4855 insertions(+), 140 deletions(-) rename plugins/{examples/webhook-rs => }/.gitignore (81%) create mode 100644 plugins/cmd/hostgen/internal/templates/client_rs.rs.tmpl create mode 100644 plugins/cmd/hostgen/testdata/codec_client_expected.rs create mode 100644 plugins/cmd/hostgen/testdata/comprehensive_client_expected.py create mode 100644 plugins/cmd/hostgen/testdata/comprehensive_client_expected.rs create mode 100644 plugins/cmd/hostgen/testdata/counter_client_expected.rs create mode 100644 plugins/cmd/hostgen/testdata/echo_client_expected.rs create mode 100644 plugins/cmd/hostgen/testdata/list_client_expected.rs create mode 100644 plugins/cmd/hostgen/testdata/math_client_expected.rs create mode 100644 plugins/cmd/hostgen/testdata/meta_client_expected.rs create mode 100644 plugins/cmd/hostgen/testdata/ping_client_expected.rs create mode 100644 plugins/cmd/hostgen/testdata/search_client_expected.rs create mode 100644 plugins/cmd/hostgen/testdata/store_client_expected.rs create mode 100644 plugins/cmd/hostgen/testdata/users_client_expected.rs create mode 100644 plugins/examples/discord-rich-presence-rs/.cargo/config.toml create mode 100644 plugins/examples/discord-rich-presence-rs/Cargo.toml create mode 100644 plugins/examples/discord-rich-presence-rs/README.md create mode 100644 plugins/examples/discord-rich-presence-rs/manifest.json create mode 100644 plugins/examples/discord-rich-presence-rs/src/lib.rs create mode 100644 plugins/examples/discord-rich-presence-rs/src/rpc.rs create mode 100644 plugins/host/rust/.gitignore create mode 100644 plugins/host/rust/Cargo.lock create mode 100644 plugins/host/rust/Cargo.toml create mode 100644 plugins/host/rust/README.md create mode 100644 plugins/host/rust/lib.rs create mode 100644 plugins/host/rust/nd_host_artwork.rs create mode 100644 plugins/host/rust/nd_host_cache.rs create mode 100644 plugins/host/rust/nd_host_kvstore.rs create mode 100644 plugins/host/rust/nd_host_library.rs create mode 100644 plugins/host/rust/nd_host_scheduler.rs create mode 100644 plugins/host/rust/nd_host_subsonicapi.rs create mode 100644 plugins/host/rust/nd_host_websocket.rs diff --git a/plugins/examples/webhook-rs/.gitignore b/plugins/.gitignore similarity index 81% rename from plugins/examples/webhook-rs/.gitignore rename to plugins/.gitignore index 8f0c20473..5026985e6 100644 --- a/plugins/examples/webhook-rs/.gitignore +++ b/plugins/.gitignore @@ -1,5 +1,4 @@ # Rust build artifacts -/target/ - # Cargo.lock is not needed for library crates (this is a cdylib) -Cargo.lock \ No newline at end of file +Cargo.lock +target \ No newline at end of file diff --git a/plugins/README.md b/plugins/README.md index 6c48e34d4..593b92816 100644 --- a/plugins/README.md +++ b/plugins/README.md @@ -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) | --- diff --git a/plugins/cmd/hostgen/README.md b/plugins/cmd/hostgen/README.md index 1bbf748f5..d22cba3d9 100644 --- a/plugins/cmd/hostgen/README.md +++ b/plugins/cmd/hostgen/README.md @@ -21,10 +21,11 @@ hostgen -input -output -package [-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_.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` 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` | | +| `[]T` | `Vec` | | +| `map[K]V` | `HashMap` | From `std::collections` | +| `*T` | `Option` | | +| `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 { + // 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 diff --git a/plugins/cmd/hostgen/integration_test.go b/plugins/cmd/hostgen/integration_test.go index 52bb80dff..733c94bf5 100644 --- a/plugins/cmd/hostgen/integration_test.go +++ b/plugins/cmd/hostgen/integration_test.go @@ -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() { diff --git a/plugins/cmd/hostgen/internal/generator.go b/plugins/cmd/hostgen/internal/generator.go index d96614098..df0eb6da7 100644 --- a/plugins/cmd/hostgen/internal/generator.go +++ b/plugins/cmd/hostgen/internal/generator.go @@ -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 +} diff --git a/plugins/cmd/hostgen/internal/generator_test.go b/plugins/cmd/hostgen/internal/generator_test.go index 5d3c40ae3..d9054898e 100644 --- a/plugins/cmd/hostgen/internal/generator_test.go +++ b/plugins/cmd/hostgen/internal/generator_test.go @@ -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() { diff --git a/plugins/cmd/hostgen/internal/templates/client_rs.rs.tmpl b/plugins/cmd/hostgen/internal/templates/client_rs.rs.tmpl new file mode 100644 index 000000000..16afd76ac --- /dev/null +++ b/plugins/cmd/hostgen/internal/templates/client_rs.rs.tmpl @@ -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, +} +{{- 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}} diff --git a/plugins/cmd/hostgen/internal/types.go b/plugins/cmd/hostgen/internal/types.go index 9bcc7a190..381f756b8 100644 --- a/plugins/cmd/hostgen/internal/types.go +++ b/plugins/cmd/hostgen/internal/types.go @@ -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" + } + 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" +} diff --git a/plugins/cmd/hostgen/main.go b/plugins/cmd/hostgen/main.go index c57c12536..545d601a5 100644 --- a/plugins/cmd/hostgen/main.go +++ b/plugins/cmd/hostgen/main.go @@ -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 +} diff --git a/plugins/cmd/hostgen/testdata/codec_client_expected.rs b/plugins/cmd/hostgen/testdata/codec_client_expected.rs new file mode 100644 index 000000000..c4b5e7e7c --- /dev/null +++ b/plugins/cmd/hostgen/testdata/codec_client_expected.rs @@ -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, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +struct CodecEncodeResponse { + #[serde(default)] + result: Vec, + #[serde(default)] + error: Option, +} + +#[host_fn] +extern "ExtismHost" { + fn codec_encode(input: Json) -> Json; +} + +/// Calls the codec_encode host function. +/// +/// # Arguments +/// * `data` - Vec parameter. +/// +/// # Returns +/// The result value. +/// +/// # Errors +/// Returns an error if the host function call fails. +pub fn encode(data: Vec) -> Result, 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) +} diff --git a/plugins/cmd/hostgen/testdata/comprehensive_client_expected.py b/plugins/cmd/hostgen/testdata/comprehensive_client_expected.py new file mode 100644 index 000000000..bcb6e2f12 --- /dev/null +++ b/plugins/cmd/hostgen/testdata/comprehensive_client_expected.py @@ -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"") diff --git a/plugins/cmd/hostgen/testdata/comprehensive_client_expected.rs b/plugins/cmd/hostgen/testdata/comprehensive_client_expected.rs new file mode 100644 index 000000000..772e127f0 --- /dev/null +++ b/plugins/cmd/hostgen/testdata/comprehensive_client_expected.rs @@ -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, +} + +#[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, +} + +#[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, +} + +#[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, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +struct ComprehensiveNoParamsResponse { + #[serde(default)] + error: Option, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +struct ComprehensiveNoParamsNoReturnsResponse { + #[serde(default)] + error: Option, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +struct ComprehensivePointerParamsRequest { + id: Option, + user: Option, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +struct ComprehensivePointerParamsResponse { + #[serde(default)] + result: Option, + #[serde(default)] + error: Option, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +struct ComprehensiveMapParamsRequest { + data: std::collections::HashMap, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +struct ComprehensiveMapParamsResponse { + #[serde(default)] + result: serde_json::Value, + #[serde(default)] + error: Option, +} + +#[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(default)] + total: i32, + #[serde(default)] + error: Option, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +struct ComprehensiveByteSliceRequest { + data: Vec, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +struct ComprehensiveByteSliceResponse { + #[serde(default)] + result: Vec, + #[serde(default)] + error: Option, +} + +#[host_fn] +extern "ExtismHost" { + fn comprehensive_simpleparams(input: Json) -> Json; + fn comprehensive_structparam(input: Json) -> Json; + fn comprehensive_mixedparams(input: Json) -> Json; + fn comprehensive_noerror(input: Json) -> Json; + fn comprehensive_noparams(input: Json) -> Json; + fn comprehensive_noparamsnoreturns(input: Json) -> Json; + fn comprehensive_pointerparams(input: Json) -> Json; + fn comprehensive_mapparams(input: Json) -> Json; + fn comprehensive_multiplereturns(input: Json) -> Json; + fn comprehensive_byteslice(input: Json) -> Json; +} + +/// 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 { + 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 { + 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 { + 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 parameter. +/// * `user` - Option parameter. +/// +/// # Returns +/// The result value. +/// +/// # Errors +/// Returns an error if the host function call fails. +pub fn pointer_params(id: Option, user: Option) -> Result, 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 parameter. +/// +/// # Returns +/// The result value. +/// +/// # Errors +/// Returns an error if the host function call fails. +pub fn map_params(data: std::collections::HashMap) -> Result { + 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, 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 parameter. +/// +/// # Returns +/// The result value. +/// +/// # Errors +/// Returns an error if the host function call fails. +pub fn byte_slice(data: Vec) -> Result, 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) +} diff --git a/plugins/cmd/hostgen/testdata/counter_client_expected.rs b/plugins/cmd/hostgen/testdata/counter_client_expected.rs new file mode 100644 index 000000000..11842689c --- /dev/null +++ b/plugins/cmd/hostgen/testdata/counter_client_expected.rs @@ -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, +} + +#[host_fn] +extern "ExtismHost" { + fn counter_count(input: Json) -> Json; +} + +/// 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 { + 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) +} diff --git a/plugins/cmd/hostgen/testdata/echo_client_expected.rs b/plugins/cmd/hostgen/testdata/echo_client_expected.rs new file mode 100644 index 000000000..4c4fd672a --- /dev/null +++ b/plugins/cmd/hostgen/testdata/echo_client_expected.rs @@ -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, +} + +#[host_fn] +extern "ExtismHost" { + fn echo_echo(input: Json) -> Json; +} + +/// 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 { + 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) +} diff --git a/plugins/cmd/hostgen/testdata/list_client_expected.rs b/plugins/cmd/hostgen/testdata/list_client_expected.rs new file mode 100644 index 000000000..b01de8192 --- /dev/null +++ b/plugins/cmd/hostgen/testdata/list_client_expected.rs @@ -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, +} + +#[host_fn] +extern "ExtismHost" { + fn list_items(input: Json) -> Json; +} + +/// 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 { + 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) +} diff --git a/plugins/cmd/hostgen/testdata/math_client_expected.rs b/plugins/cmd/hostgen/testdata/math_client_expected.rs new file mode 100644 index 000000000..4bfa3bd12 --- /dev/null +++ b/plugins/cmd/hostgen/testdata/math_client_expected.rs @@ -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, +} + +#[host_fn] +extern "ExtismHost" { + fn math_add(input: Json) -> Json; +} + +/// 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 { + 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) +} diff --git a/plugins/cmd/hostgen/testdata/meta_client_expected.rs b/plugins/cmd/hostgen/testdata/meta_client_expected.rs new file mode 100644 index 000000000..807b4895d --- /dev/null +++ b/plugins/cmd/hostgen/testdata/meta_client_expected.rs @@ -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, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +struct MetaSetRequest { + data: std::collections::HashMap, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +struct MetaSetResponse { + #[serde(default)] + error: Option, +} + +#[host_fn] +extern "ExtismHost" { + fn meta_get(input: Json) -> Json; + fn meta_set(input: Json) -> Json; +} + +/// 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 { + 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 parameter. +/// +/// # Errors +/// Returns an error if the host function call fails. +pub fn set(data: std::collections::HashMap) -> Result<(), Error> { + let response = unsafe { + meta_set(Json(MetaSetRequest { + data: data, + }))? + }; + + if let Some(err) = response.0.error { + return Err(Error::msg(err)); + } + + Ok(()) +} diff --git a/plugins/cmd/hostgen/testdata/ping_client_expected.rs b/plugins/cmd/hostgen/testdata/ping_client_expected.rs new file mode 100644 index 000000000..0b5e11eba --- /dev/null +++ b/plugins/cmd/hostgen/testdata/ping_client_expected.rs @@ -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, +} + +#[host_fn] +extern "ExtismHost" { + fn ping_ping(input: Json) -> Json; +} + +/// 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(()) +} diff --git a/plugins/cmd/hostgen/testdata/search_client_expected.rs b/plugins/cmd/hostgen/testdata/search_client_expected.rs new file mode 100644 index 000000000..1d1b20281 --- /dev/null +++ b/plugins/cmd/hostgen/testdata/search_client_expected.rs @@ -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(default)] + total: i32, + #[serde(default)] + error: Option, +} + +#[host_fn] +extern "ExtismHost" { + fn search_find(input: Json) -> Json; +} + +/// 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, 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)) +} diff --git a/plugins/cmd/hostgen/testdata/store_client_expected.rs b/plugins/cmd/hostgen/testdata/store_client_expected.rs new file mode 100644 index 000000000..b03914e46 --- /dev/null +++ b/plugins/cmd/hostgen/testdata/store_client_expected.rs @@ -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, +} + +#[host_fn] +extern "ExtismHost" { + fn store_save(input: Json) -> Json; +} + +/// 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 { + 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) +} diff --git a/plugins/cmd/hostgen/testdata/users_client_expected.rs b/plugins/cmd/hostgen/testdata/users_client_expected.rs new file mode 100644 index 000000000..15323f1ef --- /dev/null +++ b/plugins/cmd/hostgen/testdata/users_client_expected.rs @@ -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, + filter: Option, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +struct UsersGetResponse { + #[serde(default)] + result: Option, + #[serde(default)] + error: Option, +} + +#[host_fn] +extern "ExtismHost" { + fn users_get(input: Json) -> Json; +} + +/// Calls the users_get host function. +/// +/// # Arguments +/// * `id` - Option parameter. +/// * `filter` - Option parameter. +/// +/// # Returns +/// The result value. +/// +/// # Errors +/// Returns an error if the host function call fails. +pub fn get(id: Option, filter: Option) -> Result, 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) +} diff --git a/plugins/examples/README.md b/plugins/examples/README.md index 4177a72c9..7119dd870 100644 --- a/plugins/examples/README.md +++ b/plugins/examples/README.md @@ -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 diff --git a/plugins/examples/crypto-ticker/nd_host_scheduler.go b/plugins/examples/crypto-ticker/nd_host_scheduler.go index 4fe01e084..78c2e73f1 100644 --- a/plugins/examples/crypto-ticker/nd_host_scheduler.go +++ b/plugins/examples/crypto-ticker/nd_host_scheduler.go @@ -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. diff --git a/plugins/examples/crypto-ticker/nd_host_websocket.go b/plugins/examples/crypto-ticker/nd_host_websocket.go index 39d15c3b1..d0989de8e 100644 --- a/plugins/examples/crypto-ticker/nd_host_websocket.go +++ b/plugins/examples/crypto-ticker/nd_host_websocket.go @@ -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"` } diff --git a/plugins/examples/discord-rich-presence-rs/.cargo/config.toml b/plugins/examples/discord-rich-presence-rs/.cargo/config.toml new file mode 100644 index 000000000..6b509f5b7 --- /dev/null +++ b/plugins/examples/discord-rich-presence-rs/.cargo/config.toml @@ -0,0 +1,2 @@ +[build] +target = "wasm32-wasip1" diff --git a/plugins/examples/discord-rich-presence-rs/Cargo.toml b/plugins/examples/discord-rich-presence-rs/Cargo.toml new file mode 100644 index 000000000..44ec844e9 --- /dev/null +++ b/plugins/examples/discord-rich-presence-rs/Cargo.toml @@ -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" } diff --git a/plugins/examples/discord-rich-presence-rs/README.md b/plugins/examples/discord-rich-presence-rs/README.md new file mode 100644 index 000000000..6dff5f0b7 --- /dev/null +++ b/plugins/examples/discord-rich-presence-rs/README.md @@ -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 diff --git a/plugins/examples/discord-rich-presence-rs/manifest.json b/plugins/examples/discord-rich-presence-rs/manifest.json new file mode 100644 index 000000000..2415cd7ef --- /dev/null +++ b/plugins/examples/discord-rich-presence-rs/manifest.json @@ -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" + } + } +} diff --git a/plugins/examples/discord-rich-presence-rs/src/lib.rs b/plugins/examples/discord-rich-presence-rs/src/lib.rs new file mode 100644 index 000000000..76020023b --- /dev/null +++ b/plugins/examples/discord-rich-presence-rs/src/lib.rs @@ -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), 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, + #[serde(default)] + mbz_album_id: Option, + #[serde(default)] + mbz_artist_id: Option, +} + +#[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, + #[serde(skip_serializing_if = "Option::is_none")] + error_type: Option, +} + +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, +} + +// ============================================================================ +// 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, +} + +#[derive(Deserialize)] +#[allow(dead_code)] +struct OnBinaryMessageInput { + connection_id: String, + message: Vec, +} + +#[derive(Serialize, Default)] +struct OnBinaryMessageOutput { + #[serde(skip_serializing_if = "Option::is_none")] + error: Option, +} + +#[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, +} + +#[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, +} + +// ============================================================================ +// Scrobbler Plugin Exports +// ============================================================================ + +/// Checks if a user is authorized for Discord Rich Presence. +#[plugin_fn] +pub fn nd_scrobbler_is_authorized(Json(input): Json) -> FnResult> { + 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, +) -> FnResult> { + 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) -> FnResult> { + // 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, +) -> FnResult> { + + 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, +) -> FnResult> { + 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, +) -> FnResult> { + // 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) -> FnResult> { + 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) -> FnResult> { + info!( + "WebSocket connection '{}' closed with code {}: {}", + input.connection_id, input.code, input.reason + ); + Ok(Json(OnCloseOutput::default())) +} diff --git a/plugins/examples/discord-rich-presence-rs/src/rpc.rs b/plugins/examples/discord-rich-presence-rs/src/rpc.rs new file mode 100644 index 000000000..763f22eaf --- /dev/null +++ b/plugins/examples/discord-rich-presence-rs/src/rpc.rs @@ -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, + 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 { + 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(default)] + s: Option, +} + +// ============================================================================ +// 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, 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 = 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 { + 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 { + 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::(&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> = 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 +} diff --git a/plugins/examples/discord-rich-presence/nd_host_scheduler.go b/plugins/examples/discord-rich-presence/nd_host_scheduler.go index 4fe01e084..78c2e73f1 100644 --- a/plugins/examples/discord-rich-presence/nd_host_scheduler.go +++ b/plugins/examples/discord-rich-presence/nd_host_scheduler.go @@ -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. diff --git a/plugins/examples/discord-rich-presence/nd_host_websocket.go b/plugins/examples/discord-rich-presence/nd_host_websocket.go index 39d15c3b1..d0989de8e 100644 --- a/plugins/examples/discord-rich-presence/nd_host_websocket.go +++ b/plugins/examples/discord-rich-presence/nd_host_websocket.go @@ -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"` } diff --git a/plugins/examples/nowplaying-py/plugin/__init__.py b/plugins/examples/nowplaying-py/plugin/__init__.py index fd45cfcb3..4e280a5d5 100644 --- a/plugins/examples/nowplaying-py/plugin/__init__.py +++ b/plugins/examples/nowplaying-py/plugin/__init__.py @@ -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) diff --git a/plugins/host/doc.go b/plugins/host/doc.go index 39fadd1ec..905060405 100644 --- a/plugins/host/doc.go +++ b/plugins/host/doc.go @@ -37,5 +37,5 @@ // Generated files follow the pattern _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 diff --git a/plugins/host/go/nd_host_scheduler.go b/plugins/host/go/nd_host_scheduler.go index 4fe01e084..78c2e73f1 100644 --- a/plugins/host/go/nd_host_scheduler.go +++ b/plugins/host/go/nd_host_scheduler.go @@ -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. diff --git a/plugins/host/go/nd_host_subsonicapi.go b/plugins/host/go/nd_host_subsonicapi.go index 1d7be2f61..75f70c8a7 100644 --- a/plugins/host/go/nd_host_subsonicapi.go +++ b/plugins/host/go/nd_host_subsonicapi.go @@ -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"` } diff --git a/plugins/host/go/nd_host_websocket.go b/plugins/host/go/nd_host_websocket.go index 39d15c3b1..d0989de8e 100644 --- a/plugins/host/go/nd_host_websocket.go +++ b/plugins/host/go/nd_host_websocket.go @@ -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"` } diff --git a/plugins/host/python/nd_host_scheduler.py b/plugins/host/python/nd_host_scheduler.py index d9a6049e9..3a94da94d 100644 --- a/plugins/host/python/nd_host_scheduler.py +++ b/plugins/host/python/nd_host_scheduler.py @@ -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) diff --git a/plugins/host/python/nd_host_subsonicapi.py b/plugins/host/python/nd_host_subsonicapi.py index 4d5e9112d..4eaf06d4c 100644 --- a/plugins/host/python/nd_host_subsonicapi.py +++ b/plugins/host/python/nd_host_subsonicapi.py @@ -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", "") diff --git a/plugins/host/python/nd_host_websocket.py b/plugins/host/python/nd_host_websocket.py index c025db690..67938f41f 100644 --- a/plugins/host/python/nd_host_websocket.py +++ b/plugins/host/python/nd_host_websocket.py @@ -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, } diff --git a/plugins/host/rust/.gitignore b/plugins/host/rust/.gitignore new file mode 100644 index 000000000..9da4a887b --- /dev/null +++ b/plugins/host/rust/.gitignore @@ -0,0 +1 @@ +!Cargo.lock diff --git a/plugins/host/rust/Cargo.lock b/plugins/host/rust/Cargo.lock new file mode 100644 index 000000000..b0a639ba3 --- /dev/null +++ b/plugins/host/rust/Cargo.lock @@ -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" diff --git a/plugins/host/rust/Cargo.toml b/plugins/host/rust/Cargo.toml new file mode 100644 index 000000000..dc3325d2d --- /dev/null +++ b/plugins/host/rust/Cargo.toml @@ -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" diff --git a/plugins/host/rust/README.md b/plugins/host/rust/README.md new file mode 100644 index 000000000..0e6e74cae --- /dev/null +++ b/plugins/host/rust/README.md @@ -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 { + // 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. diff --git a/plugins/host/rust/lib.rs b/plugins/host/rust/lib.rs new file mode 100644 index 000000000..6102f64a3 --- /dev/null +++ b/plugins/host/rust/lib.rs @@ -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; diff --git a/plugins/host/rust/nd_host_artwork.rs b/plugins/host/rust/nd_host_artwork.rs new file mode 100644 index 000000000..31ccb4370 --- /dev/null +++ b/plugins/host/rust/nd_host_artwork.rs @@ -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, +} + +#[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, +} + +#[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, +} + +#[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, +} + +#[host_fn] +extern "ExtismHost" { + fn artwork_getartisturl(input: Json) -> Json; + fn artwork_getalbumurl(input: Json) -> Json; + fn artwork_gettrackurl(input: Json) -> Json; + fn artwork_getplaylisturl(input: Json) -> Json; +} + +/// 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 { + 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 { + 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 { + 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 { + 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) +} diff --git a/plugins/host/rust/nd_host_cache.rs b/plugins/host/rust/nd_host_cache.rs new file mode 100644 index 000000000..f223c8de8 --- /dev/null +++ b/plugins/host/rust/nd_host_cache.rs @@ -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, +} + +#[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, +} + +#[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, +} + +#[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, +} + +#[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, +} + +#[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, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +struct CacheSetBytesRequest { + key: String, + value: Vec, + ttl_seconds: i64, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +struct CacheSetBytesResponse { + #[serde(default)] + error: Option, +} + +#[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, + #[serde(default)] + exists: bool, + #[serde(default)] + error: Option, +} + +#[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, +} + +#[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, +} + +#[host_fn] +extern "ExtismHost" { + fn cache_setstring(input: Json) -> Json; + fn cache_getstring(input: Json) -> Json; + fn cache_setint(input: Json) -> Json; + fn cache_getint(input: Json) -> Json; + fn cache_setfloat(input: Json) -> Json; + fn cache_getfloat(input: Json) -> Json; + fn cache_setbytes(input: Json) -> Json; + fn cache_getbytes(input: Json) -> Json; + fn cache_has(input: Json) -> Json; + fn cache_remove(input: Json) -> Json; +} + +/// 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 parameter. +/// * `ttl_seconds` - i64 parameter. +/// +/// # Errors +/// Returns an error if the host function call fails. +pub fn set_bytes(key: &str, value: Vec, 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, 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 { + 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(()) +} diff --git a/plugins/host/rust/nd_host_kvstore.rs b/plugins/host/rust/nd_host_kvstore.rs new file mode 100644 index 000000000..5c25a7e9b --- /dev/null +++ b/plugins/host/rust/nd_host_kvstore.rs @@ -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, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +struct KVStoreSetResponse { + #[serde(default)] + error: Option, +} + +#[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, + #[serde(default)] + exists: bool, + #[serde(default)] + error: Option, +} + +#[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, +} + +#[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, +} + +#[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, + #[serde(default)] + error: Option, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +struct KVStoreGetStorageUsedResponse { + #[serde(default)] + bytes: i64, + #[serde(default)] + error: Option, +} + +#[host_fn] +extern "ExtismHost" { + fn kvstore_set(input: Json) -> Json; + fn kvstore_get(input: Json) -> Json; + fn kvstore_delete(input: Json) -> Json; + fn kvstore_has(input: Json) -> Json; + fn kvstore_list(input: Json) -> Json; + fn kvstore_getstorageused(input: Json) -> Json; +} + +/// 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 parameter. +/// +/// # Errors +/// Returns an error if the host function call fails. +pub fn set(key: &str, value: Vec) -> 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, 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 { + 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, 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 { + 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) +} diff --git a/plugins/host/rust/nd_host_library.rs b/plugins/host/rust/nd_host_library.rs new file mode 100644 index 000000000..3a6a1bbbb --- /dev/null +++ b/plugins/host/rust/nd_host_library.rs @@ -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(default)] + error: Option, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +struct LibraryGetAllLibrariesResponse { + #[serde(default)] + result: Vec, + #[serde(default)] + error: Option, +} + +#[host_fn] +extern "ExtismHost" { + fn library_getlibrary(input: Json) -> Json; + fn library_getalllibraries(input: Json) -> Json; +} + +/// 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, 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, 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) +} diff --git a/plugins/host/rust/nd_host_scheduler.rs b/plugins/host/rust/nd_host_scheduler.rs new file mode 100644 index 000000000..9578463a9 --- /dev/null +++ b/plugins/host/rust/nd_host_scheduler.rs @@ -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, +} + +#[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, +} + +#[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, +} + +#[host_fn] +extern "ExtismHost" { + fn scheduler_scheduleonetime(input: Json) -> Json; + fn scheduler_schedulerecurring(input: Json) -> Json; + fn scheduler_cancelschedule(input: Json) -> Json; +} + +/// 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 { + 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 { + 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(()) +} diff --git a/plugins/host/rust/nd_host_subsonicapi.rs b/plugins/host/rust/nd_host_subsonicapi.rs new file mode 100644 index 000000000..d96995189 --- /dev/null +++ b/plugins/host/rust/nd_host_subsonicapi.rs @@ -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, +} + +#[host_fn] +extern "ExtismHost" { + fn subsonicapi_call(input: Json) -> Json; +} + +/// 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 { + 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) +} diff --git a/plugins/host/rust/nd_host_websocket.rs b/plugins/host/rust/nd_host_websocket.rs new file mode 100644 index 000000000..61392072a --- /dev/null +++ b/plugins/host/rust/nd_host_websocket.rs @@ -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, + connection_id: String, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +struct WebSocketConnectResponse { + #[serde(default)] + new_connection_id: String, + #[serde(default)] + error: Option, +} + +#[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, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +struct WebSocketSendBinaryRequest { + connection_id: String, + data: Vec, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +struct WebSocketSendBinaryResponse { + #[serde(default)] + error: Option, +} + +#[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, +} + +#[host_fn] +extern "ExtismHost" { + fn websocket_connect(input: Json) -> Json; + fn websocket_sendtext(input: Json) -> Json; + fn websocket_sendbinary(input: Json) -> Json; + fn websocket_closeconnection(input: Json) -> Json; +} + +/// 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 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, connection_id: &str) -> Result { + 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 parameter. +/// +/// # Errors +/// Returns an error if the host function call fails. +pub fn send_binary(connection_id: &str, data: Vec) -> 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(()) +} diff --git a/plugins/host/scheduler_gen.go b/plugins/host/scheduler_gen.go index 84fef8894..bf24a30d4 100644 --- a/plugins/host/scheduler_gen.go +++ b/plugins/host/scheduler_gen.go @@ -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. diff --git a/plugins/host/subsonicapi_gen.go b/plugins/host/subsonicapi_gen.go index 92d187b25..32d5ee709 100644 --- a/plugins/host/subsonicapi_gen.go +++ b/plugins/host/subsonicapi_gen.go @@ -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"` } diff --git a/plugins/host/websocket_gen.go b/plugins/host/websocket_gen.go index dc8f211a1..abba57066 100644 --- a/plugins/host/websocket_gen.go +++ b/plugins/host/websocket_gen.go @@ -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"` } diff --git a/plugins/testdata/test-scheduler/nd_host_scheduler.go b/plugins/testdata/test-scheduler/nd_host_scheduler.go index 4fe01e084..78c2e73f1 100644 --- a/plugins/testdata/test-scheduler/nd_host_scheduler.go +++ b/plugins/testdata/test-scheduler/nd_host_scheduler.go @@ -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. diff --git a/plugins/testdata/test-subsonicapi-plugin/nd_host_subsonicapi.go b/plugins/testdata/test-subsonicapi-plugin/nd_host_subsonicapi.go index 1d7be2f61..75f70c8a7 100644 --- a/plugins/testdata/test-subsonicapi-plugin/nd_host_subsonicapi.go +++ b/plugins/testdata/test-subsonicapi-plugin/nd_host_subsonicapi.go @@ -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"` } diff --git a/plugins/testdata/test-websocket/nd_host_websocket.go b/plugins/testdata/test-websocket/nd_host_websocket.go index 39d15c3b1..d0989de8e 100644 --- a/plugins/testdata/test-websocket/nd_host_websocket.go +++ b/plugins/testdata/test-websocket/nd_host_websocket.go @@ -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"` } diff --git a/ui/src/plugin/PluginShow.jsx b/ui/src/plugin/PluginShow.jsx index 628170646..74fc675cb 100644 --- a/ui/src/plugin/PluginShow.jsx +++ b/ui/src/plugin/PluginShow.jsx @@ -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)