feat: implement Rust PDK

Signed-off-by: Deluan <deluan@navidrome.org>
This commit is contained in:
Deluan 2025-12-30 16:33:51 -05:00
parent ae41164c1f
commit 67ab3dc81a
35 changed files with 1614 additions and 156 deletions

View File

@ -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<IsAuthorizedResponse, Error> {
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

View File

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

View File

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

View File

@ -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<T>) because Rust's Result<T, Error> already provides optional semantics.
func rustOutputType(goType string) string {
// Strip pointer prefix - capability outputs use Result<T, Error> 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
}

View File

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

View File

@ -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/<capability_name>/
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 {

View File

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

View File

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

View File

@ -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};
// ============================================================================

View File

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

View File

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

View File

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

View File

@ -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<String>,
#[serde(default)]
mbz_album_id: Option<String>,
#[serde(default)]
mbz_artist_id: Option<String>,
}
#[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<String>,
#[serde(skip_serializing_if = "Option::is_none")]
error_type: Option<String>,
}
// ============================================================================
// Plugin Exports
// ============================================================================
/// Checks if a user is authorized. This plugin authorizes all users.
#[plugin_fn]
pub fn nd_scrobbler_is_authorized(Json(input): Json<AuthInput>) -> FnResult<Json<AuthOutput>> {
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<NowPlayingInput>) -> FnResult<Json<ScrobblerOutput>> {
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<ScrobbleInput>) -> FnResult<Json<ScrobblerOutput>> {
// 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={}&timestamp={}",
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<IsAuthorizedResponse, Error> {
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={}&timestamp={}",
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.

145
plugins/pdk/rust/README.md Normal file
View File

@ -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<IsAuthorizedResponse, Error> {
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<ArtistBiography, Error>
{
// 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.

View File

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

View File

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

View File

@ -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<String>) -> 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(())
}
};
}

View File

@ -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<ImageInfo>,
}
/// 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<ImageInfo>,
}
/// 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<ArtistRef>,
}
/// 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<SongRef>,
}
/// 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<String>) -> Self {
Self { message: message.into() }
}
}
/// ArtistMBIDProvider provides the GetArtistMBID function.
pub trait ArtistMBIDProvider {
fn get_artist_mbid(&self, req: ArtistMBIDRequest) -> Result<ArtistMBIDResponse, Error>;
}
/// 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<extism_pdk::Json<$crate::metadata::ArtistMBIDResponse>> {
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<ArtistURLResponse, Error>;
}
/// 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<extism_pdk::Json<$crate::metadata::ArtistURLResponse>> {
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<ArtistBiographyResponse, Error>;
}
/// 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<extism_pdk::Json<$crate::metadata::ArtistBiographyResponse>> {
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<SimilarArtistsResponse, Error>;
}
/// 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<extism_pdk::Json<$crate::metadata::SimilarArtistsResponse>> {
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<ArtistImagesResponse, Error>;
}
/// 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<extism_pdk::Json<$crate::metadata::ArtistImagesResponse>> {
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<TopSongsResponse, Error>;
}
/// 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<extism_pdk::Json<$crate::metadata::TopSongsResponse>> {
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<AlbumInfoResponse, Error>;
}
/// 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<extism_pdk::Json<$crate::metadata::AlbumInfoResponse>> {
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<AlbumImagesResponse, Error>;
}
/// 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<extism_pdk::Json<$crate::metadata::AlbumImagesResponse>> {
let plugin = <$plugin_type>::default();
let result = $crate::metadata::AlbumImagesProvider::get_album_images(&plugin, req.into_inner())?;
Ok(extism_pdk::Json(result))
}
};
}

View File

@ -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<String>) -> 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(())
}
};
}

View File

@ -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<String>) -> 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<IsAuthorizedResponse, Error>;
/// 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<extism_pdk::Json<$crate::scrobbler::IsAuthorizedResponse>> {
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(())
}
};
}

View File

@ -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<String>) -> 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(())
}
};
}

View File

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

View File

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

View File

@ -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<IsAuthorizedResponse, Error> {
//! 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;