diff --git a/plugins/README.md b/plugins/README.md index 7462a2cdf..9a1c4e944 100644 --- a/plugins/README.md +++ b/plugins/README.md @@ -753,12 +753,62 @@ See the example plugins in [examples/](examples/) for complete usage patterns. ```bash # Build WebAssembly module -cargo build --release --target wasm32-unknown-unknown +cargo build --release --target wasm32-wasip1 # Package as .ndp -zip -j my-plugin.ndp manifest.json target/wasm32-unknown-unknown/release/plugin.wasm +zip -j my-plugin.ndp manifest.json target/wasm32-wasip1/release/plugin.wasm ``` +#### Using Rust PDK + +The Rust PDK provides generated type-safe wrappers for both capabilities and host services: + +```toml +# Cargo.toml +[dependencies] +nd-pdk = { path = "../../pdk/rust/nd-pdk" } +extism-pdk = "1.2" +serde = { version = "1.0", features = ["derive"] } +serde_json = "1.0" +``` + +**Implementing capabilities with traits and macros:** + +```rust +use nd_pdk::scrobbler::{Scrobbler, IsAuthorizedRequest, IsAuthorizedResponse, Error}; +use nd_pdk::register_scrobbler; + +#[derive(Default)] +struct MyPlugin; + +impl Scrobbler for MyPlugin { + fn is_authorized(&self, req: IsAuthorizedRequest) -> Result { + Ok(IsAuthorizedResponse { authorized: true }) + } + fn now_playing(&self, req: NowPlayingRequest) -> Result<(), Error> { Ok(()) } + fn scrobble(&self, req: ScrobbleRequest) -> Result<(), Error> { Ok(()) } +} + +register_scrobbler!(MyPlugin); // Generates all WASM exports +``` + +**Using host services:** + +```rust +use nd_pdk::host::{cache, scheduler, library}; + +// Cache a value for 1 hour +cache::set_string("my_key", "my_value", 3600)?; + +// Schedule a recurring task +scheduler::schedule_recurring("@every 5m", "payload", "task_id")?; + +// Access library metadata +let libs = library::get_all_libraries()?; +``` + +See [pdk/rust/README.md](pdk/rust/README.md) for detailed documentation and examples. + ### Python (with extism-py) ```bash @@ -811,6 +861,12 @@ See [pdk/go/README.md](pdk/go/README.md) for detailed documentation. **For Python plugins:** Copy functions from `nd_host_*.py` into your `__init__.py` (see comments in those files for extism-py limitations). +**Recommendations:** + +- **Go:** Best overall experience with excellent stdlib support and familiar syntax for most developers. Recommended if you're already in the Go ecosystem. +- **Rust:** Best for performance-critical plugins or when leveraging Rust's ecosystem. Produces smallest binaries with excellent type safety. +- **Python:** Best for rapid prototyping or simple plugins. Note that extism-py has limitations compared to compiled languages. + --- ## Examples diff --git a/plugins/capabilities/doc.go b/plugins/capabilities/doc.go index 798d86cab..7569fae52 100644 --- a/plugins/capabilities/doc.go +++ b/plugins/capabilities/doc.go @@ -54,6 +54,6 @@ // // func Register(impl Scrobbler) { ... } // -//go:generate go run ../cmd/ndpgen -capability-only -input=. -output=../pdk -go +//go:generate go run ../cmd/ndpgen -capability-only -input=. -output=../pdk -go -rust //go:generate go run ../cmd/ndpgen -schemas -input=. package capabilities diff --git a/plugins/cmd/ndpgen/integration_test.go b/plugins/cmd/ndpgen/integration_test.go index 5d02df54c..20ff7c61f 100644 --- a/plugins/cmd/ndpgen/integration_test.go +++ b/plugins/cmd/ndpgen/integration_test.go @@ -228,8 +228,8 @@ type ServiceB interface { Expect(string(pyClientActual)).To(Equal(pyClientExpected), "Python client code mismatch") - // Verify Rust client code (now in $output/rust/host/) - rustHostDir := filepath.Join(outputDir, "rust", "host") + // Verify Rust client code (now in $output/rust/nd-pdk-host/) + rustHostDir := filepath.Join(outputDir, "rust", "nd-pdk-host") rsClientEntries, err := os.ReadDir(rustHostDir) Expect(err).ToNot(HaveOccurred()) Expect(rsClientEntries).To(HaveLen(2), "Expected Rust client file and lib.rs") diff --git a/plugins/cmd/ndpgen/internal/generator.go b/plugins/cmd/ndpgen/internal/generator.go index 12b968387..680457559 100644 --- a/plugins/cmd/ndpgen/internal/generator.go +++ b/plugins/cmd/ndpgen/internal/generator.go @@ -402,3 +402,164 @@ func GenerateCapabilityGoStub(cap Capability, pkgName string) ([]byte, error) { return buf.Bytes(), nil } + +// rustCapabilityFuncMap returns template functions for Rust capability code generation. +func rustCapabilityFuncMap(cap Capability) template.FuncMap { + knownStructs := cap.KnownStructs() + return template.FuncMap{ + "rustDocComment": RustDocComment, + "rustTypeAlias": rustTypeAlias, + "rustConstType": rustConstType, + "rustConstName": rustConstName, + "rustFieldName": func(name string) string { return ToSnakeCase(name) }, + "rustMethodName": func(name string) string { return ToSnakeCase(name) }, + "fieldRustType": func(f FieldDef) string { return f.RustType(knownStructs) }, + "rustOutputType": rustOutputType, + "skipSerializingFunc": skipSerializingFunc, + "hasHashMap": hasHashMap, + "agentName": capabilityAgentName, + "providerInterface": func(e Export) string { return e.ProviderInterfaceName() }, + "registerMacroName": registerMacroName, + "snakeCase": ToSnakeCase, + "indent": func(spaces int, s string) string { + indent := strings.Repeat(" ", spaces) + lines := strings.Split(s, "\n") + for i, line := range lines { + if line != "" { + lines[i] = indent + line + } + } + return strings.Join(lines, "\n") + }, + } +} + +// rustTypeAlias converts a Go type to its Rust equivalent for type aliases. +// For string types used as error sentinels/constants, we use &'static str +// since Rust consts can't be heap-allocated String values. +func rustTypeAlias(goType string) string { + switch goType { + case "string": + return "&'static str" + case "int", "int32": + return "i32" + case "int64": + return "i64" + default: + return goType + } +} + +// rustConstType converts a Go type to its Rust equivalent for const declarations. +// For String types, it returns &'static str since Rust consts can't be heap-allocated. +func rustConstType(goType string) string { + switch goType { + case "string", "String": + return "&'static str" + case "int", "int32": + return "i32" + case "int64": + return "i64" + default: + return goType + } +} + +// rustOutputType converts a Go type to Rust for capability method signatures. +// It handles pointer types specially - for capability outputs, pointers become the base type +// (not Option) because Rust's Result already provides optional semantics. +func rustOutputType(goType string) string { + // Strip pointer prefix - capability outputs use Result for optionality + if strings.HasPrefix(goType, "*") { + return goType[1:] + } + return goType +} + +// rustConstName converts a Go const name to Rust convention (SCREAMING_SNAKE_CASE). +func rustConstName(name string) string { + return strings.ToUpper(ToSnakeCase(name)) +} + +// skipSerializingFunc returns the appropriate skip_serializing_if function name. +func skipSerializingFunc(goType string) string { + if strings.HasPrefix(goType, "*") || strings.HasPrefix(goType, "[]") || strings.HasPrefix(goType, "map[") { + return "Option::is_none" + } + switch goType { + case "string": + return "String::is_empty" + case "bool": + return "std::ops::Not::not" + default: + return "Option::is_none" + } +} + +// hasHashMap returns true if any struct in the capability uses HashMap. +func hasHashMap(cap Capability) bool { + for _, st := range cap.Structs { + for _, f := range st.Fields { + if strings.HasPrefix(f.Type, "map[") { + return true + } + } + } + return false +} + +// registerMacroName returns the macro name for registering an optional method. +// For "GetArtistBiography", returns "register_artist_biography". +func registerMacroName(name string) string { + // Remove common prefixes + for _, prefix := range []string{"Get", "On"} { + if strings.HasPrefix(name, prefix) { + name = name[len(prefix):] + break + } + } + return "register_" + ToSnakeCase(name) +} + +// GenerateCapabilityRust generates Rust export wrapper code for a capability. +func GenerateCapabilityRust(cap Capability) ([]byte, error) { + tmplContent, err := templatesFS.ReadFile("templates/capability.rs.tmpl") + if err != nil { + return nil, fmt.Errorf("reading Rust capability template: %w", err) + } + + tmpl, err := template.New("capability_rust").Funcs(rustCapabilityFuncMap(cap)).Parse(string(tmplContent)) + if err != nil { + return nil, fmt.Errorf("parsing template: %w", err) + } + + data := capabilityTemplateData{ + Package: cap.Name, + Capability: cap, + } + + var buf bytes.Buffer + if err := tmpl.Execute(&buf, data); err != nil { + return nil, fmt.Errorf("executing template: %w", err) + } + + return buf.Bytes(), nil +} + +// GenerateCapabilityRustLib generates the lib.rs file for the Rust capabilities crate. +func GenerateCapabilityRustLib(capabilities []Capability) ([]byte, error) { + var buf bytes.Buffer + buf.WriteString("// Code generated by ndpgen. DO NOT EDIT.\n\n") + buf.WriteString("//! Navidrome Plugin Development Kit - Capability Wrappers\n") + buf.WriteString("//!\n") + buf.WriteString("//! This crate provides type definitions, traits, and registration macros\n") + buf.WriteString("//! for implementing Navidrome plugin capabilities in Rust.\n\n") + + // Module declarations + for _, cap := range capabilities { + moduleName := ToSnakeCase(cap.Name) + buf.WriteString(fmt.Sprintf("pub mod %s;\n", moduleName)) + } + + return buf.Bytes(), nil +} diff --git a/plugins/cmd/ndpgen/internal/templates/capability.rs.tmpl b/plugins/cmd/ndpgen/internal/templates/capability.rs.tmpl new file mode 100644 index 000000000..01629ed53 --- /dev/null +++ b/plugins/cmd/ndpgen/internal/templates/capability.rs.tmpl @@ -0,0 +1,184 @@ +// Code generated by ndpgen. DO NOT EDIT. +// +// This file contains export wrappers for the {{.Capability.Interface}} capability. +// It is intended for use in Navidrome plugins built with extism-pdk. +{{if .Capability.Structs}} +use serde::{Deserialize, Serialize}; +{{- if hasHashMap .Capability}} +use std::collections::HashMap; +{{- end}} +{{- end}} + +{{- /* Generate type alias definitions */ -}} +{{- range .Capability.TypeAliases}} + +{{- if .Doc}} +{{rustDocComment .Doc}} +{{- end}} +pub type {{.Name}} = {{rustTypeAlias .Type}}; +{{- end}} + +{{- /* Generate const definitions */ -}} +{{- range .Capability.Consts}} +{{- if .Values}} +{{- $type := .Type}} +{{- range $i, $v := .Values}} + +{{- if $v.Doc}} +{{rustDocComment $v.Doc}} +{{- end}} +{{- /* Use the type alias name if a named type is provided, otherwise use &'static str */ -}} +{{- if $type}} +pub const {{rustConstName $v.Name}}: {{$type}} = {{$v.Value}}; +{{- else}} +pub const {{rustConstName $v.Name}}: &'static str = {{$v.Value}}; +{{- end}} +{{- end}} +{{- end}} +{{- end}} + +{{- /* Generate struct definitions */ -}} +{{- range .Capability.Structs}} + +{{- if .Doc}} +{{rustDocComment .Doc}} +{{- else}} +/// {{.Name}} represents the {{.Name}} data structure. +{{- end}} +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct {{.Name}} { +{{- range .Fields}} +{{- if .Doc}} +{{rustDocComment .Doc | indent 4}} +{{- end}} +{{- if .OmitEmpty}} + #[serde(default, skip_serializing_if = "{{skipSerializingFunc .Type}}")] +{{- else}} + #[serde(default)] +{{- end}} + pub {{rustFieldName .Name}}: {{fieldRustType .}}, +{{- end}} +} +{{- end}} + +/// Error represents an error from a capability method. +#[derive(Debug)] +pub struct Error { + pub message: String, +} + +impl std::fmt::Display for Error { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.message) + } +} + +impl std::error::Error for Error {} + +impl Error { + pub fn new(message: impl Into) -> Self { + Self { message: message.into() } + } +} + +{{- /* Generate main interface based on required flag */ -}} +{{if .Capability.Required}} + +/// {{agentName .Capability}} requires all methods to be implemented. +{{- if .Capability.Doc}} +{{rustDocComment .Capability.Doc}} +{{- end}} +pub trait {{agentName .Capability}} { +{{- range .Capability.Methods}} + /// {{.Name}}{{if .Doc}} - {{.Doc}}{{end}} + {{- if and .HasInput .HasOutput}} + fn {{rustMethodName .Name}}(&self, req: {{rustOutputType .Input.Type}}) -> Result<{{rustOutputType .Output.Type}}, Error>; + {{- else if .HasInput}} + fn {{rustMethodName .Name}}(&self, req: {{rustOutputType .Input.Type}}) -> Result<(), Error>; + {{- else if .HasOutput}} + fn {{rustMethodName .Name}}(&self) -> Result<{{rustOutputType .Output.Type}}, Error>; + {{- else}} + fn {{rustMethodName .Name}}(&self) -> Result<(), Error>; + {{- end}} +{{- end}} +} + +/// Register all exports for the {{agentName .Capability}} capability. +/// This macro generates the WASM export functions for all trait methods. +#[macro_export] +macro_rules! register_{{snakeCase .Package}} { + ($plugin_type:ty) => { + {{- range .Capability.Methods}} + #[extism_pdk::plugin_fn] + pub fn {{.ExportName}}( + {{- if .HasInput}} + req: extism_pdk::Json<$crate::{{snakeCase $.Package}}::{{rustOutputType .Input.Type}}> + {{- end}} + ) -> extism_pdk::FnResult<{{if .HasOutput}}extism_pdk::Json<$crate::{{snakeCase $.Package}}::{{rustOutputType .Output.Type}}>{{else}}(){{end}}> { + let plugin = <$plugin_type>::default(); + {{- if and .HasInput .HasOutput}} + let result = $crate::{{snakeCase $.Package}}::{{agentName $.Capability}}::{{rustMethodName .Name}}(&plugin, req.into_inner())?; + Ok(extism_pdk::Json(result)) + {{- else if .HasInput}} + $crate::{{snakeCase $.Package}}::{{agentName $.Capability}}::{{rustMethodName .Name}}(&plugin, req.into_inner())?; + Ok(()) + {{- else if .HasOutput}} + let result = $crate::{{snakeCase $.Package}}::{{agentName $.Capability}}::{{rustMethodName .Name}}(&plugin)?; + Ok(extism_pdk::Json(result)) + {{- else}} + $crate::{{snakeCase $.Package}}::{{agentName $.Capability}}::{{rustMethodName .Name}}(&plugin)?; + Ok(()) + {{- end}} + } + {{- end}} + }; +} +{{- else}} + +{{- /* Generate optional provider interfaces for non-required capabilities */ -}} +{{- range .Capability.Methods}} + +/// {{providerInterface .}} provides the {{.Name}} function. +pub trait {{providerInterface .}} { + {{- if and .HasInput .HasOutput}} + fn {{rustMethodName .Name}}(&self, req: {{rustOutputType .Input.Type}}) -> Result<{{rustOutputType .Output.Type}}, Error>; + {{- else if .HasInput}} + fn {{rustMethodName .Name}}(&self, req: {{rustOutputType .Input.Type}}) -> Result<(), Error>; + {{- else if .HasOutput}} + fn {{rustMethodName .Name}}(&self) -> Result<{{rustOutputType .Output.Type}}, Error>; + {{- else}} + fn {{rustMethodName .Name}}(&self) -> Result<(), Error>; + {{- end}} +} + +/// Register the {{rustMethodName .Name}} export. +/// This macro generates the WASM export function for this method. +#[macro_export] +macro_rules! {{registerMacroName .Name}} { + ($plugin_type:ty) => { + #[extism_pdk::plugin_fn] + pub fn {{.ExportName}}( + {{- if .HasInput}} + req: extism_pdk::Json<$crate::{{snakeCase $.Package}}::{{rustOutputType .Input.Type}}> + {{- end}} + ) -> extism_pdk::FnResult<{{if .HasOutput}}extism_pdk::Json<$crate::{{snakeCase $.Package}}::{{rustOutputType .Output.Type}}>{{else}}(){{end}}> { + let plugin = <$plugin_type>::default(); + {{- if and .HasInput .HasOutput}} + let result = $crate::{{snakeCase $.Package}}::{{providerInterface .}}::{{rustMethodName .Name}}(&plugin, req.into_inner())?; + Ok(extism_pdk::Json(result)) + {{- else if .HasInput}} + $crate::{{snakeCase $.Package}}::{{providerInterface .}}::{{rustMethodName .Name}}(&plugin, req.into_inner())?; + Ok(()) + {{- else if .HasOutput}} + let result = $crate::{{snakeCase $.Package}}::{{providerInterface .}}::{{rustMethodName .Name}}(&plugin)?; + Ok(extism_pdk::Json(result)) + {{- else}} + $crate::{{snakeCase $.Package}}::{{providerInterface .}}::{{rustMethodName .Name}}(&plugin)?; + Ok(()) + {{- end}} + } + }; +} +{{- end}} +{{- end}} diff --git a/plugins/cmd/ndpgen/main.go b/plugins/cmd/ndpgen/main.go index 1ec5407fd..5ff8a4a21 100644 --- a/plugins/cmd/ndpgen/main.go +++ b/plugins/cmd/ndpgen/main.go @@ -194,10 +194,13 @@ func parseConfig() (*config, error) { return nil, fmt.Errorf("resolving output path: %w", err) } - // Set output directories for each language: $output/$lang/host/ + // Set output directories for each language + // Go host wrappers: $output/go/host/ + // Python host wrappers: $output/python/host/ + // Rust host wrappers: $output/rust/nd-pdk-host/ (renamed crate) absGoOutput := filepath.Join(absOutput, "go", "host") absPythonOutput := filepath.Join(absOutput, "python", "host") - absRustOutput := filepath.Join(absOutput, "rust", "host") + absRustOutput := filepath.Join(absOutput, "rust", "nd-pdk-host") // Determine what to generate // Default: generate Go clients if no language flag is specified @@ -291,14 +294,23 @@ func parseCapabilities(cfg *config) ([]internal.Capability, error) { return capabilities, nil } -// generateCapabilityCode generates Go export wrappers for all capabilities. +// generateCapabilityCode generates export wrappers for all capabilities. func generateCapabilityCode(cfg *config, capabilities []internal.Capability) error { + // Generate Go capability wrappers (always, for now) for _, cap := range capabilities { // Output directory is $output/go// outputDir := filepath.Join(cfg.outputDir, "go", cap.Name) if err := generateCapabilityGoCode(cap, outputDir, cfg.dryRun, cfg.verbose); err != nil { - return fmt.Errorf("generating capability code for %s: %w", cap.Name, err) + return fmt.Errorf("generating Go capability code for %s: %w", cap.Name, err) + } + } + + // Generate Rust capability wrappers if -rust flag is set + if cfg.generateRsClient { + rustOutputDir := filepath.Join(cfg.outputDir, "rust", "nd-pdk-capabilities", "src") + if err := generateCapabilityRustCode(capabilities, rustOutputDir, cfg.dryRun, cfg.verbose); err != nil { + return fmt.Errorf("generating Rust capability code: %w", err) } } @@ -367,6 +379,58 @@ func generateCapabilityGoCode(cap internal.Capability, outputDir string, dryRun, return nil } +// generateCapabilityRustCode generates Rust export wrapper code for all capabilities. +func generateCapabilityRustCode(capabilities []internal.Capability, outputDir string, dryRun, verbose bool) error { + // Generate individual capability modules + for _, cap := range capabilities { + code, err := internal.GenerateCapabilityRust(cap) + if err != nil { + return fmt.Errorf("generating Rust code for %s: %w", cap.Name, err) + } + + fileName := internal.ToSnakeCase(cap.Name) + ".rs" + filePath := filepath.Join(outputDir, fileName) + + if dryRun { + fmt.Printf("=== %s ===\n%s\n", filePath, code) + } else { + if err := os.MkdirAll(outputDir, 0755); err != nil { + return fmt.Errorf("creating output directory: %w", err) + } + + if err := os.WriteFile(filePath, code, 0600); err != nil { + return fmt.Errorf("writing file %s: %w", filePath, err) + } + + if verbose { + fmt.Printf("Generated Rust capability code: %s\n", filePath) + } + } + } + + // Generate lib.rs + libCode, err := internal.GenerateCapabilityRustLib(capabilities) + if err != nil { + return fmt.Errorf("generating lib.rs: %w", err) + } + + libPath := filepath.Join(outputDir, "lib.rs") + + if dryRun { + fmt.Printf("=== %s ===\n%s\n", libPath, libCode) + } else { + if err := os.WriteFile(libPath, libCode, 0600); err != nil { + return fmt.Errorf("writing lib.rs: %w", err) + } + + if verbose { + fmt.Printf("Generated Rust lib.rs: %s\n", libPath) + } + } + + return nil +} + // generateAllCode generates all requested code for the services. func generateAllCode(cfg *config, services []internal.Service) error { for _, svc := range services { diff --git a/plugins/examples/discord-rich-presence-rs/Cargo.toml b/plugins/examples/discord-rich-presence-rs/Cargo.toml index ed9f32249..bc473147d 100644 --- a/plugins/examples/discord-rich-presence-rs/Cargo.toml +++ b/plugins/examples/discord-rich-presence-rs/Cargo.toml @@ -10,7 +10,7 @@ license = "GPL-3.0" crate-type = ["cdylib"] [dependencies] +nd-pdk = { path = "../../pdk/rust/nd-pdk" } extism-pdk = "1.2" serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" -nd-host = { path = "../../pdk/rust/host" } diff --git a/plugins/examples/discord-rich-presence-rs/src/lib.rs b/plugins/examples/discord-rich-presence-rs/src/lib.rs index 52f0876cc..dd8a49213 100644 --- a/plugins/examples/discord-rich-presence-rs/src/lib.rs +++ b/plugins/examples/discord-rich-presence-rs/src/lib.rs @@ -18,7 +18,7 @@ //! in configuration files is not secure and may violate Discord's terms of service. use extism_pdk::*; -use nd_host::{artwork, scheduler}; +use nd_pdk::host::{artwork, scheduler}; use serde::{Deserialize, Serialize}; mod rpc; diff --git a/plugins/examples/discord-rich-presence-rs/src/rpc.rs b/plugins/examples/discord-rich-presence-rs/src/rpc.rs index b769285d8..cc172ec7a 100644 --- a/plugins/examples/discord-rich-presence-rs/src/rpc.rs +++ b/plugins/examples/discord-rich-presence-rs/src/rpc.rs @@ -4,7 +4,7 @@ //! presence updates, and heartbeat management. use extism_pdk::*; -use nd_host::{cache, scheduler, websocket}; +use nd_pdk::host::{cache, scheduler, websocket}; use serde::{Deserialize, Serialize}; // ============================================================================ diff --git a/plugins/examples/library-inspector/Cargo.toml b/plugins/examples/library-inspector/Cargo.toml index f2915e8f6..a3af4eec5 100644 --- a/plugins/examples/library-inspector/Cargo.toml +++ b/plugins/examples/library-inspector/Cargo.toml @@ -10,7 +10,7 @@ license = "GPL-3.0" crate-type = ["cdylib"] [dependencies] +nd-pdk = { path = "../../pdk/rust/nd-pdk" } extism-pdk = "1.2" -nd-host = { path = "../../pdk/rust/host" } serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" diff --git a/plugins/examples/library-inspector/src/lib.rs b/plugins/examples/library-inspector/src/lib.rs index a7901698b..bcdb6fb10 100644 --- a/plugins/examples/library-inspector/src/lib.rs +++ b/plugins/examples/library-inspector/src/lib.rs @@ -13,7 +13,7 @@ //! ``` use extism_pdk::*; -use nd_host::{library, scheduler}; +use nd_pdk::host::{library, scheduler}; use serde::{Deserialize, Serialize}; use std::fs; diff --git a/plugins/examples/webhook-rs/Cargo.toml b/plugins/examples/webhook-rs/Cargo.toml index 69c38b12b..d74e180fd 100644 --- a/plugins/examples/webhook-rs/Cargo.toml +++ b/plugins/examples/webhook-rs/Cargo.toml @@ -10,6 +10,7 @@ license = "GPL-3.0" crate-type = ["cdylib"] [dependencies] +nd-pdk = { path = "../../pdk/rust/nd-pdk" } extism-pdk = "1.2" serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" diff --git a/plugins/examples/webhook-rs/src/lib.rs b/plugins/examples/webhook-rs/src/lib.rs index 25245a4a7..0850e61c8 100644 --- a/plugins/examples/webhook-rs/src/lib.rs +++ b/plugins/examples/webhook-rs/src/lib.rs @@ -1,6 +1,6 @@ //! Webhook Scrobbler Plugin for Navidrome //! -//! This plugin demonstrates how to build a Navidrome plugin in Rust using the Extism PDK. +//! This plugin demonstrates how to build a Navidrome plugin in Rust using the nd-pdk crate. //! It implements the Scrobbler capability and sends HTTP GET requests to configured URLs //! whenever a track is scrobbled. //! @@ -12,152 +12,96 @@ //! urls = "https://example.com/webhook1,https://example.com/webhook2" //! ``` -use extism_pdk::*; -use serde::{Deserialize, Serialize}; +use extism_pdk::{config, error, http, info, warn, HttpRequest}; +use nd_pdk::scrobbler::{ + Error, IsAuthorizedRequest, IsAuthorizedResponse, NowPlayingRequest, ScrobbleRequest, + Scrobbler, +}; + +// Register the WASM exports for the Scrobbler capability +nd_pdk::register_scrobbler!(WebhookPlugin); // ============================================================================ -// Scrobbler Types +// Plugin Implementation // ============================================================================ -#[derive(Deserialize)] -#[serde(rename_all = "camelCase")] -struct AuthInput { - user_id: String, - username: String, -} +/// The webhook plugin type. Implements the Scrobbler trait. +#[derive(Default)] +struct WebhookPlugin; -#[derive(Serialize)] -struct AuthOutput { - authorized: bool, -} - -#[derive(Deserialize)] -#[serde(rename_all = "camelCase")] -#[allow(dead_code)] // Fields are deserialized from JSON but not all are used -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)] -#[serde(rename_all = "camelCase")] -#[allow(dead_code)] // Fields are deserialized from JSON but not all are used -struct NowPlayingInput { - user_id: String, - username: String, - track: TrackInfo, - position: i32, -} - -#[derive(Deserialize)] -#[serde(rename_all = "camelCase")] -#[allow(dead_code)] // Fields are deserialized from JSON but not all are used -struct ScrobbleInput { - user_id: String, - username: String, - track: TrackInfo, - timestamp: i64, -} - -#[derive(Serialize, Default)] -#[serde(rename_all = "camelCase")] -struct ScrobblerOutput { - #[serde(skip_serializing_if = "Option::is_none")] - error: Option, - #[serde(skip_serializing_if = "Option::is_none")] - error_type: Option, -} - -// ============================================================================ -// Plugin Exports -// ============================================================================ - -/// Checks if a user is authorized. This plugin authorizes all users. -#[plugin_fn] -pub fn nd_scrobbler_is_authorized(Json(input): Json) -> FnResult> { - info!( - "Authorization check for user: {} ({})", - input.username, input.user_id - ); - Ok(Json(AuthOutput { authorized: true })) -} - -/// Handles now playing notifications. This plugin ignores them (webhooks only on scrobble). -#[plugin_fn] -pub fn nd_scrobbler_now_playing(Json(input): Json) -> FnResult> { - info!( - "Now playing (ignored): {} - {} for user {}", - input.track.artist, input.track.title, input.username - ); - Ok(Json(ScrobblerOutput::default())) -} - -/// Handles scrobble events by sending HTTP GET requests to configured URLs. -#[plugin_fn] -pub fn nd_scrobbler_scrobble(Json(input): Json) -> FnResult> { - // Get configured URLs - let urls_config = match config::get("urls") { - Ok(Some(urls)) if !urls.is_empty() => urls, - _ => { - warn!("No webhook URLs configured. Set 'urls' in plugin config."); - return Ok(Json(ScrobblerOutput::default())); - } - }; - - info!( - "Scrobble: {} - {} by user {}", - input.track.artist, input.track.title, input.username - ); - - // Build query parameters - let query = format!( - "?title={}&artist={}&album={}&user={}×tamp={}", - urlencod(&input.track.title), - urlencod(&input.track.artist), - urlencod(&input.track.album), - urlencod(&input.username), - input.timestamp - ); - - // Send requests to each configured URL - for url in urls_config.split(',') { - let url = url.trim(); - if url.is_empty() { - continue; - } - - let full_url = format!("{}{}", url, query); - info!("Sending webhook to: {}", full_url); - - let req = HttpRequest::new(&full_url); - match http::request::<()>(&req, None) { - Ok(res) => { - let status = res.status_code(); - if status >= 200 && status < 300 { - info!("Webhook succeeded: {} (status {})", url, status); - } else { - warn!("Webhook returned non-2xx status: {} (status {})", url, status); - } - } - Err(e) => { - error!("Webhook failed for {}: {:?}", url, e); - } - } +impl Scrobbler for WebhookPlugin { + /// Checks if a user is authorized. This plugin authorizes all users. + fn is_authorized(&self, req: IsAuthorizedRequest) -> Result { + info!( + "Authorization check for user: {} ({})", + req.username, req.user_id + ); + Ok(IsAuthorizedResponse { authorized: true }) } - Ok(Json(ScrobblerOutput::default())) + /// Handles now playing notifications. This plugin ignores them (webhooks only on scrobble). + fn now_playing(&self, req: NowPlayingRequest) -> Result<(), Error> { + info!( + "Now playing (ignored): {} - {} for user {}", + req.track.artist, req.track.title, req.username + ); + Ok(()) + } + + /// Handles scrobble events by sending HTTP GET requests to configured URLs. + fn scrobble(&self, req: ScrobbleRequest) -> Result<(), Error> { + // Get configured URLs + let urls_config = match config::get("urls") { + Ok(Some(urls)) if !urls.is_empty() => urls, + _ => { + warn!("No webhook URLs configured. Set 'urls' in plugin config."); + return Ok(()); + } + }; + + info!( + "Scrobble: {} - {} by user {}", + req.track.artist, req.track.title, req.username + ); + + // Build query parameters + let query = format!( + "?title={}&artist={}&album={}&user={}×tamp={}", + urlencod(&req.track.title), + urlencod(&req.track.artist), + urlencod(&req.track.album), + urlencod(&req.username), + req.timestamp + ); + + // Send requests to each configured URL + for url in urls_config.split(',') { + let url = url.trim(); + if url.is_empty() { + continue; + } + + let full_url = format!("{}{}", url, query); + info!("Sending webhook to: {}", full_url); + + let http_req = HttpRequest::new(&full_url); + match http::request::<()>(&http_req, None) { + Ok(res) => { + let status = res.status_code(); + if status >= 200 && status < 300 { + info!("Webhook succeeded: {} (status {})", url, status); + } else { + warn!("Webhook returned non-2xx status: {} (status {})", url, status); + } + } + Err(e) => { + error!("Webhook failed for {}: {:?}", url, e); + } + } + } + + Ok(()) + } } /// Simple URL encoding for query parameters. diff --git a/plugins/pdk/rust/README.md b/plugins/pdk/rust/README.md new file mode 100644 index 000000000..c377cbb48 --- /dev/null +++ b/plugins/pdk/rust/README.md @@ -0,0 +1,145 @@ +# Navidrome Plugin Development Kit for Rust + +This directory contains the Rust PDK crates for building Navidrome plugins. + +## Crate Structure + +``` +plugins/pdk/rust/ +├── nd-pdk/ # Umbrella crate - use this as your dependency +├── nd-pdk-host/ # Host function wrappers (call Navidrome services) +└── nd-pdk-capabilities/ # Capability traits and types (generated) +``` + +## Usage + +Add the `nd-pdk` crate as a dependency in your plugin's `Cargo.toml`: + +```toml +[package] +name = "my-plugin" +edition = "2021" + +[lib] +crate-type = ["cdylib"] + +[dependencies] +nd-pdk = { path = "../../pdk/rust/nd-pdk" } +extism-pdk = "1.2" +``` + +### Implementing a Scrobbler (Required-All Pattern) + +The Scrobbler capability requires all methods to be implemented: + +```rust +use nd_pdk::scrobbler::{ + Error, IsAuthorizedRequest, IsAuthorizedResponse, + NowPlayingRequest, ScrobbleRequest, Scrobbler, +}; + +// Register WASM exports for all Scrobbler methods +nd_pdk::register_scrobbler!(MyPlugin); + +#[derive(Default)] +struct MyPlugin; + +impl Scrobbler for MyPlugin { + fn is_authorized(&self, req: IsAuthorizedRequest) -> Result { + Ok(IsAuthorizedResponse { authorized: true }) + } + + fn now_playing(&self, req: NowPlayingRequest) -> Result<(), Error> { + // Handle now playing notification + Ok(()) + } + + fn scrobble(&self, req: ScrobbleRequest) -> Result<(), Error> { + // Submit scrobble + Ok(()) + } +} +``` + +### Implementing Metadata Agent (Optional Pattern) + +The MetadataAgent capability allows implementing individual methods: + +```rust +use nd_pdk::metadata::{ + ArtistBiographyProvider, GetArtistBiographyRequest, ArtistBiography, Error, +}; + +// Register only the methods you implement +nd_pdk::register_artist_biography!(MyPlugin); + +#[derive(Default)] +struct MyPlugin; + +impl ArtistBiographyProvider for MyPlugin { + fn get_artist_biography(&self, req: GetArtistBiographyRequest) + -> Result + { + // Return artist biography + Ok(ArtistBiography { + biography: "Artist bio text...".into(), + ..Default::default() + }) + } +} +``` + +### Using Host Services + +Access Navidrome services via the host module: + +```rust +use nd_pdk::host::{artwork, scheduler, library}; + +// Get artwork URL for a track +let url = artwork::get_track_url("track-id", 300)?; + +// Schedule a one-time callback +scheduler::schedule_one_time(60, "my-payload", "schedule-id")?; + +// Get library information +let libs = library::get_all()?; +``` + +## Available Capabilities + +| Capability | Pattern | Description | +|------------|---------|-------------| +| `scrobbler` | Required-all | Submit listening history to external services | +| `metadata` | Optional | Provide artist/album metadata from external sources | +| `lifecycle` | Optional | Handle plugin initialization | +| `scheduler` | Optional | Receive scheduled callbacks | +| `websocket` | Optional | Handle WebSocket messages | + +## Building + +Rust plugins must be compiled to WASM using the `wasm32-wasip1` target: + +```bash +cargo build --release --target wasm32-wasip1 +``` + +The resulting `.wasm` file can be packaged into an `.ndp` plugin package. + +## Examples + +See the example plugins for complete implementations: + +- [webhook-rs](../../examples/webhook-rs/) - Simple scrobbler using the PDK +- [discord-rich-presence-rs](../../examples/discord-rich-presence-rs/) - Complex plugin with multiple capabilities +- [library-inspector](../../examples/library-inspector/) - Host service demonstration + +## Code Generation + +The capability modules in `nd-pdk-capabilities` are auto-generated from the Go capability definitions. To regenerate after capability changes: + +```bash +go generate ./plugins/capabilities +``` + +This generates both Go and Rust PDK code. diff --git a/plugins/pdk/rust/nd-pdk-capabilities/Cargo.toml b/plugins/pdk/rust/nd-pdk-capabilities/Cargo.toml new file mode 100644 index 000000000..98a91da1f --- /dev/null +++ b/plugins/pdk/rust/nd-pdk-capabilities/Cargo.toml @@ -0,0 +1,16 @@ +[package] +name = "nd-pdk-capabilities" +version = "0.1.0" +edition = "2021" +description = "Navidrome capability wrappers for Rust plugins" +authors = ["Navidrome Team"] +license = "GPL-3.0" + +[lib] +path = "src/lib.rs" +crate-type = ["rlib"] + +[dependencies] +extism-pdk = "1.2" +serde = { version = "1.0", features = ["derive"] } +serde_json = "1.0" diff --git a/plugins/pdk/rust/nd-pdk-capabilities/src/lib.rs b/plugins/pdk/rust/nd-pdk-capabilities/src/lib.rs new file mode 100644 index 000000000..0f0daf80f --- /dev/null +++ b/plugins/pdk/rust/nd-pdk-capabilities/src/lib.rs @@ -0,0 +1,12 @@ +// Code generated by ndpgen. DO NOT EDIT. + +//! Navidrome Plugin Development Kit - Capability Wrappers +//! +//! This crate provides type definitions, traits, and registration macros +//! for implementing Navidrome plugin capabilities in Rust. + +pub mod lifecycle; +pub mod metadata; +pub mod scheduler; +pub mod scrobbler; +pub mod websocket; diff --git a/plugins/pdk/rust/nd-pdk-capabilities/src/lifecycle.rs b/plugins/pdk/rust/nd-pdk-capabilities/src/lifecycle.rs new file mode 100644 index 000000000..cd99386de --- /dev/null +++ b/plugins/pdk/rust/nd-pdk-capabilities/src/lifecycle.rs @@ -0,0 +1,45 @@ +// Code generated by ndpgen. DO NOT EDIT. +// +// This file contains export wrappers for the Lifecycle capability. +// It is intended for use in Navidrome plugins built with extism-pdk. + + +/// Error represents an error from a capability method. +#[derive(Debug)] +pub struct Error { + pub message: String, +} + +impl std::fmt::Display for Error { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.message) + } +} + +impl std::error::Error for Error {} + +impl Error { + pub fn new(message: impl Into) -> Self { + Self { message: message.into() } + } +} + +/// InitProvider provides the OnInit function. +pub trait InitProvider { + fn on_init(&self) -> Result<(), Error>; +} + +/// Register the on_init export. +/// This macro generates the WASM export function for this method. +#[macro_export] +macro_rules! register_init { + ($plugin_type:ty) => { + #[extism_pdk::plugin_fn] + pub fn nd_on_init( + ) -> extism_pdk::FnResult<()> { + let plugin = <$plugin_type>::default(); + $crate::lifecycle::InitProvider::on_init(&plugin)?; + Ok(()) + } + }; +} diff --git a/plugins/pdk/rust/nd-pdk-capabilities/src/metadata.rs b/plugins/pdk/rust/nd-pdk-capabilities/src/metadata.rs new file mode 100644 index 000000000..823a62fc6 --- /dev/null +++ b/plugins/pdk/rust/nd-pdk-capabilities/src/metadata.rs @@ -0,0 +1,373 @@ +// Code generated by ndpgen. DO NOT EDIT. +// +// This file contains export wrappers for the MetadataAgent capability. +// It is intended for use in Navidrome plugins built with extism-pdk. + +use serde::{Deserialize, Serialize}; +/// AlbumImagesResponse is the response for GetAlbumImages. +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct AlbumImagesResponse { + /// Images is the list of album images. + #[serde(default)] + pub images: Vec, +} +/// AlbumInfoResponse is the response for GetAlbumInfo. +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct AlbumInfoResponse { + /// Name is the album name. + #[serde(default)] + pub name: String, + /// MBID is the MusicBrainz ID for the album. + #[serde(default)] + pub mbid: String, + /// Description is the album description/notes. + #[serde(default)] + pub description: String, + /// URL is the external URL for the album. + #[serde(default)] + pub url: String, +} +/// AlbumRequest is the common request for album-related functions. +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct AlbumRequest { + /// Name is the album name. + #[serde(default)] + pub name: String, + /// Artist is the album artist name. + #[serde(default)] + pub artist: String, + /// MBID is the MusicBrainz ID for the album (if known). + #[serde(default, skip_serializing_if = "String::is_empty")] + pub mbid: String, +} +/// ArtistBiographyResponse is the response for GetArtistBiography. +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ArtistBiographyResponse { + /// Biography is the artist biography text. + #[serde(default)] + pub biography: String, +} +/// ArtistImagesResponse is the response for GetArtistImages. +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ArtistImagesResponse { + /// Images is the list of artist images. + #[serde(default)] + pub images: Vec, +} +/// ArtistMBIDRequest is the request for GetArtistMBID. +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ArtistMBIDRequest { + /// ID is the internal Navidrome artist ID. + #[serde(default)] + pub id: String, + /// Name is the artist name. + #[serde(default)] + pub name: String, +} +/// ArtistMBIDResponse is the response for GetArtistMBID. +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ArtistMBIDResponse { + /// MBID is the MusicBrainz ID for the artist. + #[serde(default)] + pub mbid: String, +} +/// ArtistRef is a reference to an artist with name and optional MBID. +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ArtistRef { + /// Name is the artist name. + #[serde(default)] + pub name: String, + /// MBID is the MusicBrainz ID for the artist. + #[serde(default, skip_serializing_if = "String::is_empty")] + pub mbid: String, +} +/// ArtistRequest is the common request for artist-related functions. +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ArtistRequest { + /// ID is the internal Navidrome artist ID. + #[serde(default)] + pub id: String, + /// Name is the artist name. + #[serde(default)] + pub name: String, + /// MBID is the MusicBrainz ID for the artist (if known). + #[serde(default, skip_serializing_if = "String::is_empty")] + pub mbid: String, +} +/// ArtistURLResponse is the response for GetArtistURL. +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ArtistURLResponse { + /// URL is the external URL for the artist. + #[serde(default)] + pub url: String, +} +/// ImageInfo represents an image with URL and size. +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ImageInfo { + /// URL is the URL of the image. + #[serde(default)] + pub url: String, + /// Size is the size of the image in pixels (width or height). + #[serde(default)] + pub size: i32, +} +/// SimilarArtistsRequest is the request for GetSimilarArtists. +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SimilarArtistsRequest { + /// ID is the internal Navidrome artist ID. + #[serde(default)] + pub id: String, + /// Name is the artist name. + #[serde(default)] + pub name: String, + /// MBID is the MusicBrainz ID for the artist (if known). + #[serde(default, skip_serializing_if = "String::is_empty")] + pub mbid: String, + /// Limit is the maximum number of similar artists to return. + #[serde(default)] + pub limit: i32, +} +/// SimilarArtistsResponse is the response for GetSimilarArtists. +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SimilarArtistsResponse { + /// Artists is the list of similar artists. + #[serde(default)] + pub artists: Vec, +} +/// SongRef is a reference to a song with name and optional MBID. +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SongRef { + /// Name is the song name. + #[serde(default)] + pub name: String, + /// MBID is the MusicBrainz ID for the song. + #[serde(default, skip_serializing_if = "String::is_empty")] + pub mbid: String, +} +/// TopSongsRequest is the request for GetArtistTopSongs. +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct TopSongsRequest { + /// ID is the internal Navidrome artist ID. + #[serde(default)] + pub id: String, + /// Name is the artist name. + #[serde(default)] + pub name: String, + /// MBID is the MusicBrainz ID for the artist (if known). + #[serde(default, skip_serializing_if = "String::is_empty")] + pub mbid: String, + /// Count is the maximum number of top songs to return. + #[serde(default)] + pub count: i32, +} +/// TopSongsResponse is the response for GetArtistTopSongs. +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct TopSongsResponse { + /// Songs is the list of top songs. + #[serde(default)] + pub songs: Vec, +} + +/// Error represents an error from a capability method. +#[derive(Debug)] +pub struct Error { + pub message: String, +} + +impl std::fmt::Display for Error { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.message) + } +} + +impl std::error::Error for Error {} + +impl Error { + pub fn new(message: impl Into) -> Self { + Self { message: message.into() } + } +} + +/// ArtistMBIDProvider provides the GetArtistMBID function. +pub trait ArtistMBIDProvider { + fn get_artist_mbid(&self, req: ArtistMBIDRequest) -> Result; +} + +/// Register the get_artist_mbid export. +/// This macro generates the WASM export function for this method. +#[macro_export] +macro_rules! register_artist_mbid { + ($plugin_type:ty) => { + #[extism_pdk::plugin_fn] + pub fn nd_get_artist_mbid( + req: extism_pdk::Json<$crate::metadata::ArtistMBIDRequest> + ) -> extism_pdk::FnResult> { + let plugin = <$plugin_type>::default(); + let result = $crate::metadata::ArtistMBIDProvider::get_artist_mbid(&plugin, req.into_inner())?; + Ok(extism_pdk::Json(result)) + } + }; +} + +/// ArtistURLProvider provides the GetArtistURL function. +pub trait ArtistURLProvider { + fn get_artist_url(&self, req: ArtistRequest) -> Result; +} + +/// Register the get_artist_url export. +/// This macro generates the WASM export function for this method. +#[macro_export] +macro_rules! register_artist_url { + ($plugin_type:ty) => { + #[extism_pdk::plugin_fn] + pub fn nd_get_artist_url( + req: extism_pdk::Json<$crate::metadata::ArtistRequest> + ) -> extism_pdk::FnResult> { + let plugin = <$plugin_type>::default(); + let result = $crate::metadata::ArtistURLProvider::get_artist_url(&plugin, req.into_inner())?; + Ok(extism_pdk::Json(result)) + } + }; +} + +/// ArtistBiographyProvider provides the GetArtistBiography function. +pub trait ArtistBiographyProvider { + fn get_artist_biography(&self, req: ArtistRequest) -> Result; +} + +/// Register the get_artist_biography export. +/// This macro generates the WASM export function for this method. +#[macro_export] +macro_rules! register_artist_biography { + ($plugin_type:ty) => { + #[extism_pdk::plugin_fn] + pub fn nd_get_artist_biography( + req: extism_pdk::Json<$crate::metadata::ArtistRequest> + ) -> extism_pdk::FnResult> { + let plugin = <$plugin_type>::default(); + let result = $crate::metadata::ArtistBiographyProvider::get_artist_biography(&plugin, req.into_inner())?; + Ok(extism_pdk::Json(result)) + } + }; +} + +/// SimilarArtistsProvider provides the GetSimilarArtists function. +pub trait SimilarArtistsProvider { + fn get_similar_artists(&self, req: SimilarArtistsRequest) -> Result; +} + +/// Register the get_similar_artists export. +/// This macro generates the WASM export function for this method. +#[macro_export] +macro_rules! register_similar_artists { + ($plugin_type:ty) => { + #[extism_pdk::plugin_fn] + pub fn nd_get_similar_artists( + req: extism_pdk::Json<$crate::metadata::SimilarArtistsRequest> + ) -> extism_pdk::FnResult> { + let plugin = <$plugin_type>::default(); + let result = $crate::metadata::SimilarArtistsProvider::get_similar_artists(&plugin, req.into_inner())?; + Ok(extism_pdk::Json(result)) + } + }; +} + +/// ArtistImagesProvider provides the GetArtistImages function. +pub trait ArtistImagesProvider { + fn get_artist_images(&self, req: ArtistRequest) -> Result; +} + +/// Register the get_artist_images export. +/// This macro generates the WASM export function for this method. +#[macro_export] +macro_rules! register_artist_images { + ($plugin_type:ty) => { + #[extism_pdk::plugin_fn] + pub fn nd_get_artist_images( + req: extism_pdk::Json<$crate::metadata::ArtistRequest> + ) -> extism_pdk::FnResult> { + let plugin = <$plugin_type>::default(); + let result = $crate::metadata::ArtistImagesProvider::get_artist_images(&plugin, req.into_inner())?; + Ok(extism_pdk::Json(result)) + } + }; +} + +/// ArtistTopSongsProvider provides the GetArtistTopSongs function. +pub trait ArtistTopSongsProvider { + fn get_artist_top_songs(&self, req: TopSongsRequest) -> Result; +} + +/// Register the get_artist_top_songs export. +/// This macro generates the WASM export function for this method. +#[macro_export] +macro_rules! register_artist_top_songs { + ($plugin_type:ty) => { + #[extism_pdk::plugin_fn] + pub fn nd_get_artist_top_songs( + req: extism_pdk::Json<$crate::metadata::TopSongsRequest> + ) -> extism_pdk::FnResult> { + let plugin = <$plugin_type>::default(); + let result = $crate::metadata::ArtistTopSongsProvider::get_artist_top_songs(&plugin, req.into_inner())?; + Ok(extism_pdk::Json(result)) + } + }; +} + +/// AlbumInfoProvider provides the GetAlbumInfo function. +pub trait AlbumInfoProvider { + fn get_album_info(&self, req: AlbumRequest) -> Result; +} + +/// Register the get_album_info export. +/// This macro generates the WASM export function for this method. +#[macro_export] +macro_rules! register_album_info { + ($plugin_type:ty) => { + #[extism_pdk::plugin_fn] + pub fn nd_get_album_info( + req: extism_pdk::Json<$crate::metadata::AlbumRequest> + ) -> extism_pdk::FnResult> { + let plugin = <$plugin_type>::default(); + let result = $crate::metadata::AlbumInfoProvider::get_album_info(&plugin, req.into_inner())?; + Ok(extism_pdk::Json(result)) + } + }; +} + +/// AlbumImagesProvider provides the GetAlbumImages function. +pub trait AlbumImagesProvider { + fn get_album_images(&self, req: AlbumRequest) -> Result; +} + +/// Register the get_album_images export. +/// This macro generates the WASM export function for this method. +#[macro_export] +macro_rules! register_album_images { + ($plugin_type:ty) => { + #[extism_pdk::plugin_fn] + pub fn nd_get_album_images( + req: extism_pdk::Json<$crate::metadata::AlbumRequest> + ) -> extism_pdk::FnResult> { + let plugin = <$plugin_type>::default(); + let result = $crate::metadata::AlbumImagesProvider::get_album_images(&plugin, req.into_inner())?; + Ok(extism_pdk::Json(result)) + } + }; +} diff --git a/plugins/pdk/rust/nd-pdk-capabilities/src/scheduler.rs b/plugins/pdk/rust/nd-pdk-capabilities/src/scheduler.rs new file mode 100644 index 000000000..b9f188363 --- /dev/null +++ b/plugins/pdk/rust/nd-pdk-capabilities/src/scheduler.rs @@ -0,0 +1,64 @@ +// Code generated by ndpgen. DO NOT EDIT. +// +// This file contains export wrappers for the SchedulerCallback capability. +// It is intended for use in Navidrome plugins built with extism-pdk. + +use serde::{Deserialize, Serialize}; +/// SchedulerCallbackRequest is the request provided when a scheduled task fires. +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SchedulerCallbackRequest { + /// ScheduleID is the unique identifier for this scheduled task. + /// This is either the ID provided when scheduling, or an auto-generated UUID if none was specified. + #[serde(default)] + pub schedule_id: String, + /// Payload is the payload data that was provided when the task was scheduled. + /// Can be used to pass context or parameters to the callback handler. + #[serde(default)] + pub payload: String, + /// IsRecurring is true if this is a recurring schedule (created via ScheduleRecurring), + /// false if it's a one-time schedule (created via ScheduleOneTime). + #[serde(default)] + pub is_recurring: bool, +} + +/// Error represents an error from a capability method. +#[derive(Debug)] +pub struct Error { + pub message: String, +} + +impl std::fmt::Display for Error { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.message) + } +} + +impl std::error::Error for Error {} + +impl Error { + pub fn new(message: impl Into) -> Self { + Self { message: message.into() } + } +} + +/// SchedulerCallbackProvider provides the OnSchedulerCallback function. +pub trait SchedulerCallbackProvider { + fn on_scheduler_callback(&self, req: SchedulerCallbackRequest) -> Result<(), Error>; +} + +/// Register the on_scheduler_callback export. +/// This macro generates the WASM export function for this method. +#[macro_export] +macro_rules! register_scheduler_callback { + ($plugin_type:ty) => { + #[extism_pdk::plugin_fn] + pub fn nd_scheduler_callback( + req: extism_pdk::Json<$crate::scheduler::SchedulerCallbackRequest> + ) -> extism_pdk::FnResult<()> { + let plugin = <$plugin_type>::default(); + $crate::scheduler::SchedulerCallbackProvider::on_scheduler_callback(&plugin, req.into_inner())?; + Ok(()) + } + }; +} diff --git a/plugins/pdk/rust/nd-pdk-capabilities/src/scrobbler.rs b/plugins/pdk/rust/nd-pdk-capabilities/src/scrobbler.rs new file mode 100644 index 000000000..afa9ac724 --- /dev/null +++ b/plugins/pdk/rust/nd-pdk-capabilities/src/scrobbler.rs @@ -0,0 +1,182 @@ +// Code generated by ndpgen. DO NOT EDIT. +// +// This file contains export wrappers for the Scrobbler capability. +// It is intended for use in Navidrome plugins built with extism-pdk. + +use serde::{Deserialize, Serialize}; +/// ScrobblerError represents an error type for scrobbling operations. +pub type ScrobblerError = &'static str; +/// ScrobblerErrorNotAuthorized indicates the user is not authorized. +pub const SCROBBLER_ERROR_NOT_AUTHORIZED: ScrobblerError = "scrobbler(not_authorized)"; +/// ScrobblerErrorRetryLater indicates the operation should be retried later. +pub const SCROBBLER_ERROR_RETRY_LATER: ScrobblerError = "scrobbler(retry_later)"; +/// ScrobblerErrorUnrecoverable indicates an unrecoverable error. +pub const SCROBBLER_ERROR_UNRECOVERABLE: ScrobblerError = "scrobbler(unrecoverable)"; +/// IsAuthorizedRequest is the request for authorization check. +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct IsAuthorizedRequest { + /// UserID is the internal Navidrome user ID. + #[serde(default)] + pub user_id: String, + /// Username is the username of the user. + #[serde(default)] + pub username: String, +} +/// IsAuthorizedResponse is the response for authorization check. +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct IsAuthorizedResponse { + /// Authorized indicates whether the user is authorized to scrobble. + #[serde(default)] + pub authorized: bool, +} +/// NowPlayingRequest is the request for now playing notification. +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct NowPlayingRequest { + /// UserID is the internal Navidrome user ID. + #[serde(default)] + pub user_id: String, + /// Username is the username of the user. + #[serde(default)] + pub username: String, + /// Track is the track currently playing. + #[serde(default)] + pub track: TrackInfo, + /// Position is the current playback position in seconds. + #[serde(default)] + pub position: i32, +} +/// ScrobbleRequest is the request for submitting a scrobble. +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ScrobbleRequest { + /// UserID is the internal Navidrome user ID. + #[serde(default)] + pub user_id: String, + /// Username is the username of the user. + #[serde(default)] + pub username: String, + /// Track is the track that was played. + #[serde(default)] + pub track: TrackInfo, + /// Timestamp is the Unix timestamp when the track started playing. + #[serde(default)] + pub timestamp: i64, +} +/// TrackInfo contains track metadata for scrobbling. +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct TrackInfo { + /// ID is the internal Navidrome track ID. + #[serde(default)] + pub id: String, + /// Title is the track title. + #[serde(default)] + pub title: String, + /// Album is the album name. + #[serde(default)] + pub album: String, + /// Artist is the track artist. + #[serde(default)] + pub artist: String, + /// AlbumArtist is the album artist. + #[serde(default)] + pub album_artist: String, + /// Duration is the track duration in seconds. + #[serde(default)] + pub duration: f32, + /// TrackNumber is the track number on the album. + #[serde(default)] + pub track_number: i32, + /// DiscNumber is the disc number. + #[serde(default)] + pub disc_number: i32, + /// MBZRecordingID is the MusicBrainz recording ID. + #[serde(default, skip_serializing_if = "String::is_empty")] + pub mbz_recording_id: String, + /// MBZAlbumID is the MusicBrainz album/release ID. + #[serde(default, skip_serializing_if = "String::is_empty")] + pub mbz_album_id: String, + /// MBZArtistID is the MusicBrainz artist ID. + #[serde(default, skip_serializing_if = "String::is_empty")] + pub mbz_artist_id: String, + /// MBZReleaseGroupID is the MusicBrainz release group ID. + #[serde(default, skip_serializing_if = "String::is_empty")] + pub mbz_release_group_id: String, + /// MBZAlbumArtistID is the MusicBrainz album artist ID. + #[serde(default, skip_serializing_if = "String::is_empty")] + pub mbz_album_artist_id: String, + /// MBZReleaseTrackID is the MusicBrainz release track ID. + #[serde(default, skip_serializing_if = "String::is_empty")] + pub mbz_release_track_id: String, +} + +/// Error represents an error from a capability method. +#[derive(Debug)] +pub struct Error { + pub message: String, +} + +impl std::fmt::Display for Error { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.message) + } +} + +impl std::error::Error for Error {} + +impl Error { + pub fn new(message: impl Into) -> Self { + Self { message: message.into() } + } +} + +/// Scrobbler requires all methods to be implemented. +/// Scrobbler provides scrobbling functionality to external services. +/// This capability allows plugins to submit listening history to services like Last.fm, +/// ListenBrainz, or custom scrobbling backends. +/// +/// All methods are required - plugins implementing this capability must provide +/// all three functions: IsAuthorized, NowPlaying, and Scrobble. +pub trait Scrobbler { + /// IsAuthorized - IsAuthorized checks if a user is authorized to scrobble to this service. + fn is_authorized(&self, req: IsAuthorizedRequest) -> Result; + /// NowPlaying - NowPlaying sends a now playing notification to the scrobbling service. + fn now_playing(&self, req: NowPlayingRequest) -> Result<(), Error>; + /// Scrobble - Scrobble submits a completed scrobble to the scrobbling service. + fn scrobble(&self, req: ScrobbleRequest) -> Result<(), Error>; +} + +/// Register all exports for the Scrobbler capability. +/// This macro generates the WASM export functions for all trait methods. +#[macro_export] +macro_rules! register_scrobbler { + ($plugin_type:ty) => { + #[extism_pdk::plugin_fn] + pub fn nd_scrobbler_is_authorized( + req: extism_pdk::Json<$crate::scrobbler::IsAuthorizedRequest> + ) -> extism_pdk::FnResult> { + let plugin = <$plugin_type>::default(); + let result = $crate::scrobbler::Scrobbler::is_authorized(&plugin, req.into_inner())?; + Ok(extism_pdk::Json(result)) + } + #[extism_pdk::plugin_fn] + pub fn nd_scrobbler_now_playing( + req: extism_pdk::Json<$crate::scrobbler::NowPlayingRequest> + ) -> extism_pdk::FnResult<()> { + let plugin = <$plugin_type>::default(); + $crate::scrobbler::Scrobbler::now_playing(&plugin, req.into_inner())?; + Ok(()) + } + #[extism_pdk::plugin_fn] + pub fn nd_scrobbler_scrobble( + req: extism_pdk::Json<$crate::scrobbler::ScrobbleRequest> + ) -> extism_pdk::FnResult<()> { + let plugin = <$plugin_type>::default(); + $crate::scrobbler::Scrobbler::scrobble(&plugin, req.into_inner())?; + Ok(()) + } + }; +} diff --git a/plugins/pdk/rust/nd-pdk-capabilities/src/websocket.rs b/plugins/pdk/rust/nd-pdk-capabilities/src/websocket.rs new file mode 100644 index 000000000..4cf25520c --- /dev/null +++ b/plugins/pdk/rust/nd-pdk-capabilities/src/websocket.rs @@ -0,0 +1,158 @@ +// Code generated by ndpgen. DO NOT EDIT. +// +// This file contains export wrappers for the WebSocketCallback capability. +// It is intended for use in Navidrome plugins built with extism-pdk. + +use serde::{Deserialize, Serialize}; +/// OnBinaryMessageRequest is the request provided when a binary message is received. +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct OnBinaryMessageRequest { + /// ConnectionID is the unique identifier for the WebSocket connection that received the message. + #[serde(default)] + pub connection_id: String, + /// Data is the binary data received from the WebSocket, encoded as base64. + #[serde(default)] + pub data: String, +} +/// OnCloseRequest is the request provided when a WebSocket connection is closed. +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct OnCloseRequest { + /// ConnectionID is the unique identifier for the WebSocket connection that was closed. + #[serde(default)] + pub connection_id: String, + /// Code is the WebSocket close status code (e.g., 1000 for normal closure, + /// 1001 for going away, 1006 for abnormal closure). + #[serde(default)] + pub code: i32, + /// Reason is the human-readable reason for the connection closure, if provided. + #[serde(default)] + pub reason: String, +} +/// OnErrorRequest is the request provided when an error occurs on a WebSocket connection. +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct OnErrorRequest { + /// ConnectionID is the unique identifier for the WebSocket connection where the error occurred. + #[serde(default)] + pub connection_id: String, + /// Error is the error message describing what went wrong. + #[serde(default)] + pub error: String, +} +/// OnTextMessageRequest is the request provided when a text message is received. +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct OnTextMessageRequest { + /// ConnectionID is the unique identifier for the WebSocket connection that received the message. + #[serde(default)] + pub connection_id: String, + /// Message is the text message content received from the WebSocket. + #[serde(default)] + pub message: String, +} + +/// Error represents an error from a capability method. +#[derive(Debug)] +pub struct Error { + pub message: String, +} + +impl std::fmt::Display for Error { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.message) + } +} + +impl std::error::Error for Error {} + +impl Error { + pub fn new(message: impl Into) -> Self { + Self { message: message.into() } + } +} + +/// TextMessageProvider provides the OnTextMessage function. +pub trait TextMessageProvider { + fn on_text_message(&self, req: OnTextMessageRequest) -> Result<(), Error>; +} + +/// Register the on_text_message export. +/// This macro generates the WASM export function for this method. +#[macro_export] +macro_rules! register_text_message { + ($plugin_type:ty) => { + #[extism_pdk::plugin_fn] + pub fn nd_websocket_on_text_message( + req: extism_pdk::Json<$crate::websocket::OnTextMessageRequest> + ) -> extism_pdk::FnResult<()> { + let plugin = <$plugin_type>::default(); + $crate::websocket::TextMessageProvider::on_text_message(&plugin, req.into_inner())?; + Ok(()) + } + }; +} + +/// BinaryMessageProvider provides the OnBinaryMessage function. +pub trait BinaryMessageProvider { + fn on_binary_message(&self, req: OnBinaryMessageRequest) -> Result<(), Error>; +} + +/// Register the on_binary_message export. +/// This macro generates the WASM export function for this method. +#[macro_export] +macro_rules! register_binary_message { + ($plugin_type:ty) => { + #[extism_pdk::plugin_fn] + pub fn nd_websocket_on_binary_message( + req: extism_pdk::Json<$crate::websocket::OnBinaryMessageRequest> + ) -> extism_pdk::FnResult<()> { + let plugin = <$plugin_type>::default(); + $crate::websocket::BinaryMessageProvider::on_binary_message(&plugin, req.into_inner())?; + Ok(()) + } + }; +} + +/// ErrorProvider provides the OnError function. +pub trait ErrorProvider { + fn on_error(&self, req: OnErrorRequest) -> Result<(), Error>; +} + +/// Register the on_error export. +/// This macro generates the WASM export function for this method. +#[macro_export] +macro_rules! register_error { + ($plugin_type:ty) => { + #[extism_pdk::plugin_fn] + pub fn nd_websocket_on_error( + req: extism_pdk::Json<$crate::websocket::OnErrorRequest> + ) -> extism_pdk::FnResult<()> { + let plugin = <$plugin_type>::default(); + $crate::websocket::ErrorProvider::on_error(&plugin, req.into_inner())?; + Ok(()) + } + }; +} + +/// CloseProvider provides the OnClose function. +pub trait CloseProvider { + fn on_close(&self, req: OnCloseRequest) -> Result<(), Error>; +} + +/// Register the on_close export. +/// This macro generates the WASM export function for this method. +#[macro_export] +macro_rules! register_close { + ($plugin_type:ty) => { + #[extism_pdk::plugin_fn] + pub fn nd_websocket_on_close( + req: extism_pdk::Json<$crate::websocket::OnCloseRequest> + ) -> extism_pdk::FnResult<()> { + let plugin = <$plugin_type>::default(); + $crate::websocket::CloseProvider::on_close(&plugin, req.into_inner())?; + Ok(()) + } + }; +} diff --git a/plugins/pdk/rust/host/.gitignore b/plugins/pdk/rust/nd-pdk-host/.gitignore similarity index 100% rename from plugins/pdk/rust/host/.gitignore rename to plugins/pdk/rust/nd-pdk-host/.gitignore diff --git a/plugins/pdk/rust/host/Cargo.lock b/plugins/pdk/rust/nd-pdk-host/Cargo.lock similarity index 100% rename from plugins/pdk/rust/host/Cargo.lock rename to plugins/pdk/rust/nd-pdk-host/Cargo.lock diff --git a/plugins/pdk/rust/host/Cargo.toml b/plugins/pdk/rust/nd-pdk-host/Cargo.toml similarity index 86% rename from plugins/pdk/rust/host/Cargo.toml rename to plugins/pdk/rust/nd-pdk-host/Cargo.toml index dc3325d2d..52fd0ab1c 100644 --- a/plugins/pdk/rust/host/Cargo.toml +++ b/plugins/pdk/rust/nd-pdk-host/Cargo.toml @@ -1,5 +1,5 @@ [package] -name = "nd-host" +name = "nd-pdk-host" version = "0.1.0" edition = "2021" description = "Navidrome host function wrappers for Rust plugins" @@ -9,7 +9,7 @@ readme = "README.md" [lib] path = "lib.rs" -crate-type = ["cdylib", "rlib"] +crate-type = ["rlib"] [dependencies] extism-pdk = "1.2" diff --git a/plugins/pdk/rust/host/README.md b/plugins/pdk/rust/nd-pdk-host/README.md similarity index 100% rename from plugins/pdk/rust/host/README.md rename to plugins/pdk/rust/nd-pdk-host/README.md diff --git a/plugins/pdk/rust/host/lib.rs b/plugins/pdk/rust/nd-pdk-host/lib.rs similarity index 100% rename from plugins/pdk/rust/host/lib.rs rename to plugins/pdk/rust/nd-pdk-host/lib.rs diff --git a/plugins/pdk/rust/host/nd_host_artwork.rs b/plugins/pdk/rust/nd-pdk-host/nd_host_artwork.rs similarity index 100% rename from plugins/pdk/rust/host/nd_host_artwork.rs rename to plugins/pdk/rust/nd-pdk-host/nd_host_artwork.rs diff --git a/plugins/pdk/rust/host/nd_host_cache.rs b/plugins/pdk/rust/nd-pdk-host/nd_host_cache.rs similarity index 100% rename from plugins/pdk/rust/host/nd_host_cache.rs rename to plugins/pdk/rust/nd-pdk-host/nd_host_cache.rs diff --git a/plugins/pdk/rust/host/nd_host_kvstore.rs b/plugins/pdk/rust/nd-pdk-host/nd_host_kvstore.rs similarity index 100% rename from plugins/pdk/rust/host/nd_host_kvstore.rs rename to plugins/pdk/rust/nd-pdk-host/nd_host_kvstore.rs diff --git a/plugins/pdk/rust/host/nd_host_library.rs b/plugins/pdk/rust/nd-pdk-host/nd_host_library.rs similarity index 100% rename from plugins/pdk/rust/host/nd_host_library.rs rename to plugins/pdk/rust/nd-pdk-host/nd_host_library.rs diff --git a/plugins/pdk/rust/host/nd_host_scheduler.rs b/plugins/pdk/rust/nd-pdk-host/nd_host_scheduler.rs similarity index 100% rename from plugins/pdk/rust/host/nd_host_scheduler.rs rename to plugins/pdk/rust/nd-pdk-host/nd_host_scheduler.rs diff --git a/plugins/pdk/rust/host/nd_host_subsonicapi.rs b/plugins/pdk/rust/nd-pdk-host/nd_host_subsonicapi.rs similarity index 100% rename from plugins/pdk/rust/host/nd_host_subsonicapi.rs rename to plugins/pdk/rust/nd-pdk-host/nd_host_subsonicapi.rs diff --git a/plugins/pdk/rust/host/nd_host_websocket.rs b/plugins/pdk/rust/nd-pdk-host/nd_host_websocket.rs similarity index 100% rename from plugins/pdk/rust/host/nd_host_websocket.rs rename to plugins/pdk/rust/nd-pdk-host/nd_host_websocket.rs diff --git a/plugins/pdk/rust/nd-pdk/Cargo.toml b/plugins/pdk/rust/nd-pdk/Cargo.toml new file mode 100644 index 000000000..34fe9f032 --- /dev/null +++ b/plugins/pdk/rust/nd-pdk/Cargo.toml @@ -0,0 +1,18 @@ +[package] +name = "nd-pdk" +version = "0.1.0" +edition = "2021" +description = "Navidrome Plugin Development Kit for Rust" +authors = ["Navidrome Team"] +license = "GPL-3.0" +readme = "../README.md" + +[lib] +crate-type = ["rlib"] + +[dependencies] +nd-pdk-host = { path = "../nd-pdk-host" } +nd-pdk-capabilities = { path = "../nd-pdk-capabilities" } +extism-pdk = "1.2" +serde = { version = "1.0", features = ["derive"] } +serde_json = "1.0" diff --git a/plugins/pdk/rust/nd-pdk/src/lib.rs b/plugins/pdk/rust/nd-pdk/src/lib.rs new file mode 100644 index 000000000..45d45e523 --- /dev/null +++ b/plugins/pdk/rust/nd-pdk/src/lib.rs @@ -0,0 +1,35 @@ +//! Navidrome Plugin Development Kit for Rust +//! +//! This crate provides a unified API for building Navidrome plugins in Rust. +//! It re-exports all functionality from the host and capabilities sub-crates. +//! +//! # Example +//! +//! ```rust,no_run +//! use nd_pdk::scrobbler::{Scrobbler, IsAuthorizedRequest, IsAuthorizedResponse, Error}; +//! use nd_pdk::register_scrobbler; +//! +//! struct MyPlugin; +//! +//! impl Default for MyPlugin { +//! fn default() -> Self { MyPlugin } +//! } +//! +//! impl Scrobbler for MyPlugin { +//! fn is_authorized(&self, req: IsAuthorizedRequest) -> Result { +//! Ok(IsAuthorizedResponse { authorized: true }) +//! } +//! // ... implement other required methods +//! } +//! +//! register_scrobbler!(MyPlugin); +//! ``` + +/// Host function wrappers for calling Navidrome services from plugins. +pub use nd_pdk_host as host; + +/// Capability wrappers for implementing plugin exports. +pub use nd_pdk_capabilities::*; + +/// Re-export extism-pdk for convenience. +pub use extism_pdk;