mirror of
https://github.com/navidrome/navidrome.git
synced 2026-05-03 06:51:16 +00:00
feat: implement Rust PDK
Signed-off-by: Deluan <deluan@navidrome.org>
This commit is contained in:
parent
ae41164c1f
commit
67ab3dc81a
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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")
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
184
plugins/cmd/ndpgen/internal/templates/capability.rs.tmpl
Normal file
184
plugins/cmd/ndpgen/internal/templates/capability.rs.tmpl
Normal 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}}
|
||||
@ -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 {
|
||||
|
||||
@ -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" }
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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};
|
||||
|
||||
// ============================================================================
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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;
|
||||
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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={}×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<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={}×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.
|
||||
|
||||
145
plugins/pdk/rust/README.md
Normal file
145
plugins/pdk/rust/README.md
Normal 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.
|
||||
16
plugins/pdk/rust/nd-pdk-capabilities/Cargo.toml
Normal file
16
plugins/pdk/rust/nd-pdk-capabilities/Cargo.toml
Normal 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"
|
||||
12
plugins/pdk/rust/nd-pdk-capabilities/src/lib.rs
Normal file
12
plugins/pdk/rust/nd-pdk-capabilities/src/lib.rs
Normal 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;
|
||||
45
plugins/pdk/rust/nd-pdk-capabilities/src/lifecycle.rs
Normal file
45
plugins/pdk/rust/nd-pdk-capabilities/src/lifecycle.rs
Normal 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(())
|
||||
}
|
||||
};
|
||||
}
|
||||
373
plugins/pdk/rust/nd-pdk-capabilities/src/metadata.rs
Normal file
373
plugins/pdk/rust/nd-pdk-capabilities/src/metadata.rs
Normal 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))
|
||||
}
|
||||
};
|
||||
}
|
||||
64
plugins/pdk/rust/nd-pdk-capabilities/src/scheduler.rs
Normal file
64
plugins/pdk/rust/nd-pdk-capabilities/src/scheduler.rs
Normal 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(())
|
||||
}
|
||||
};
|
||||
}
|
||||
182
plugins/pdk/rust/nd-pdk-capabilities/src/scrobbler.rs
Normal file
182
plugins/pdk/rust/nd-pdk-capabilities/src/scrobbler.rs
Normal 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(())
|
||||
}
|
||||
};
|
||||
}
|
||||
158
plugins/pdk/rust/nd-pdk-capabilities/src/websocket.rs
Normal file
158
plugins/pdk/rust/nd-pdk-capabilities/src/websocket.rs
Normal 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(())
|
||||
}
|
||||
};
|
||||
}
|
||||
@ -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"
|
||||
18
plugins/pdk/rust/nd-pdk/Cargo.toml
Normal file
18
plugins/pdk/rust/nd-pdk/Cargo.toml
Normal 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"
|
||||
35
plugins/pdk/rust/nd-pdk/src/lib.rs
Normal file
35
plugins/pdk/rust/nd-pdk/src/lib.rs
Normal 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;
|
||||
Loading…
x
Reference in New Issue
Block a user