mirror of
https://github.com/navidrome/navidrome.git
synced 2026-08-31 07:30:32 +00:00
A MetadataAgent plugin reports "I have no data for this item" by returning an empty response with a nil error. Any error it returns instead is treated as a plugin fault and retried with backoff. That rule was not documented anywhere, so an author naturally returns an error for a missing item, and Navidrome then retries every item the plugin's source does not cover. This is not hypothetical: the artist-nfo-metadata plugin returned an error for every artist without an artist.nfo, which kept those artists in the artwork retry queue for hours and tripped the artwork circuit breaker for the plugin as a whole. Document the rule on the capability interface, which ndpgen copies into the Go PDK, and in the MetadataAgent section of the plugin README.
1315 lines
47 KiB
Markdown
1315 lines
47 KiB
Markdown
# Navidrome Plugin System
|
||
|
||
Navidrome supports WebAssembly (Wasm) plugins for extending functionality. Plugins run in a secure sandbox and can provide metadata agents, scrobblers, lyrics providers, audio similarity, and other integrations through host services like scheduling, caching, task queues, WebSockets, and Subsonic API access.
|
||
|
||
The plugin system is built on **[Extism](https://extism.org/)**, a cross-language framework for building WebAssembly plugins. You can write plugins in any language that Extism supports (Go, Rust, Python, TypeScript, and more) using their Plugin Development Kits (PDKs).
|
||
|
||
**Essential Extism Resources:**
|
||
- [Extism Documentation](https://extism.org/docs/overview) – Core concepts and architecture
|
||
- [Plugin Development Kits (PDKs)](https://extism.org/docs/concepts/pdk) – Language-specific libraries for writing plugins
|
||
- [Go PDK](https://github.com/extism/go-pdk) – Recommended for Go plugins with TinyGo
|
||
- [Rust PDK](https://github.com/extism/rust-pdk) – For Rust plugins
|
||
- [Python PDK](https://github.com/extism/python-pdk) – Experimental Python support
|
||
- [JavaScript PDK](https://github.com/extism/js-pdk) – For TypeScript/JavaScript plugins
|
||
|
||
## Table of Contents
|
||
|
||
- [Quick Start](#quick-start)
|
||
- [Plugin Basics](#plugin-basics)
|
||
- [Capabilities](#capabilities)
|
||
- [MetadataAgent](#metadataagent)
|
||
- [Scrobbler](#scrobbler)
|
||
- [Lyrics](#lyrics)
|
||
- [SonicSimilarity](#sonicsimilarity)
|
||
- [TaskWorker](#taskworker)
|
||
- [Lifecycle](#lifecycle)
|
||
- [SchedulerCallback](#schedulercallback)
|
||
- [WebSocketCallback](#websocketcallback)
|
||
- [Host Services](#host-services)
|
||
- [HTTP](#http)
|
||
- [Scheduler](#scheduler)
|
||
- [Cache](#cache)
|
||
- [KVStore](#kvstore)
|
||
- [Storage](#storage)
|
||
- [Task](#task)
|
||
- [WebSocket](#websocket)
|
||
- [Library](#library)
|
||
- [Matcher](#matcher)
|
||
- [Artwork](#artwork)
|
||
- [SubsonicAPI](#subsonicapi)
|
||
- [Config](#config)
|
||
- [Users](#users)
|
||
- [ScrobbleRetriever](#scrobbleretriever)
|
||
- [Configuration](#configuration)
|
||
- [Command Line Interface](#command-line-interface)
|
||
- [Building Plugins](#building-plugins)
|
||
- [Examples](#examples)
|
||
- [Security](#security)
|
||
|
||
---
|
||
|
||
## Quick Start
|
||
|
||
### 1. Create a minimal plugin
|
||
|
||
Create `main.go`:
|
||
|
||
```go
|
||
package main
|
||
|
||
import "github.com/extism/go-pdk"
|
||
|
||
func main() {}
|
||
|
||
// Implement your capability functions here
|
||
```
|
||
|
||
Create `manifest.json`:
|
||
|
||
```json
|
||
{
|
||
"name": "My Plugin",
|
||
"author": "Your Name",
|
||
"version": "1.0.0"
|
||
}
|
||
```
|
||
|
||
### 2. Build with TinyGo and package as .ndp
|
||
|
||
```bash
|
||
# Compile to WebAssembly
|
||
tinygo build -o plugin.wasm -target wasip1 -buildmode=c-shared .
|
||
|
||
# Package as .ndp (zip archive)
|
||
zip -j my-plugin.ndp manifest.json plugin.wasm
|
||
```
|
||
|
||
### 3. Install
|
||
|
||
Copy `my-plugin.ndp` to your Navidrome plugins folder and enable plugins in your config:
|
||
|
||
```toml
|
||
[Plugins]
|
||
Enabled = true
|
||
Folder = "/path/to/plugins"
|
||
```
|
||
|
||
---
|
||
|
||
## Plugin Basics
|
||
|
||
### What is a Plugin?
|
||
|
||
A Navidrome plugin is an `.ndp` package file (zip archive) containing:
|
||
|
||
1. **`manifest.json`** – Plugin metadata (name, author, version, permissions)
|
||
2. **`plugin.wasm`** – Compiled WebAssembly module with capability functions
|
||
|
||
### Plugin Naming
|
||
|
||
Plugins are identified by their **filename** (without `.ndp` extension), not the manifest `name` field:
|
||
|
||
- `my-plugin.ndp` → plugin ID is `my-plugin`
|
||
- The manifest `name` is the display name shown in the UI
|
||
|
||
This allows users to have multiple instances of the same plugin with different configs by renaming the files.
|
||
|
||
### The Manifest
|
||
|
||
Every plugin must include a `manifest.json` file. Example:
|
||
|
||
```json
|
||
{
|
||
"name": "My Plugin",
|
||
"author": "Author Name",
|
||
"version": "1.0.0",
|
||
"description": "What this plugin does",
|
||
"website": "https://example.com",
|
||
"config": {
|
||
"schema": { ... },
|
||
"uiSchema": { ... }
|
||
},
|
||
"permissions": {
|
||
"http": {
|
||
"reason": "Fetch metadata from external API",
|
||
"requiredHosts": ["api.example.com", "*.musicbrainz.org"]
|
||
}
|
||
}
|
||
}
|
||
```
|
||
|
||
**Required fields:** `name`, `author`, `version`
|
||
|
||
**Optional fields:** `description`, `website`, `config`, `permissions`
|
||
|
||
#### Config Definition
|
||
|
||
The `config` field defines the plugin's configuration schema using [JSON Schema (draft-07)](https://json-schema.org/) and an optional [JSONForms](https://jsonforms.io/) UI schema for rendering in the Navidrome web UI:
|
||
|
||
```json
|
||
{
|
||
"config": {
|
||
"schema": {
|
||
"type": "object",
|
||
"properties": {
|
||
"api_key": { "type": "string", "title": "API Key" },
|
||
"max_retries": { "type": "integer", "default": 3 }
|
||
},
|
||
"required": ["api_key"]
|
||
},
|
||
"uiSchema": {
|
||
"api_key": { "ui:widget": "password" }
|
||
}
|
||
}
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
## Capabilities
|
||
|
||
Capabilities define what your plugin can do. They're automatically detected based on which functions you export. A plugin can implement multiple capabilities.
|
||
|
||
### MetadataAgent
|
||
|
||
Provides artist and album metadata. All methods are **optional** — implement only the ones your data source supports.
|
||
|
||
> **Returning "not found".** When you have no data for an item, return an empty response and no
|
||
> error. In the Go PDK that is `return nil, nil`. Navidrome reads it as a definitive "not found"
|
||
> and stops asking.
|
||
>
|
||
> Return an error only when the plugin itself failed, such as an unreachable API or a broken host
|
||
> call. Navidrome retries failed calls with backoff. A plugin that errors on "no data" makes
|
||
> Navidrome retry every item it has no data for.
|
||
|
||
| Function | Input | Output | Description |
|
||
|-----------------------------------|----------------------------|----------------------------------|--------------------------|
|
||
| `nd_get_artist_mbid` | `{id, name}` | `{mbid}` | Get MusicBrainz ID |
|
||
| `nd_get_artist_url` | `{id, name, mbid?}` | `{url}` | Get artist URL |
|
||
| `nd_get_artist_biography` | `{id, name, mbid?}` | `{biography}` | Get artist biography |
|
||
| `nd_get_similar_artists` | `{id, name, mbid?, limit}` | `{artists: [{name, mbid?}]}` | Get similar artists |
|
||
| `nd_get_artist_images` | `{id, name, mbid?}` | `{images: [{url, size}]}` | Get artist images |
|
||
| `nd_get_artist_top_songs` | `{id, name, mbid?, count}` | `{songs: [{name, mbid?}]}` | Get top songs |
|
||
| `nd_get_album_info` | `{name, artist, mbid?}` | `{name, mbid, description, url}` | Get album info |
|
||
| `nd_get_album_images` | `{name, artist, mbid?}` | `{images: [{url, size}]}` | Get album images |
|
||
| `nd_get_similar_songs_by_track` | `{id, name, artist, ...}` | `{songs: [{name, artist}]}` | Similar songs by track |
|
||
| `nd_get_similar_songs_by_album` | `{id, name, artist, ...}` | `{songs: [{name, artist}]}` | Similar songs by album |
|
||
| `nd_get_similar_songs_by_artist` | `{id, name, mbid?, count}` | `{songs: [{name, artist}]}` | Similar songs by artist |
|
||
|
||
To use the plugin as a metadata agent, add it to your config:
|
||
|
||
```toml
|
||
Agents = "lastfm,spotify,my-plugin"
|
||
```
|
||
|
||
**Example (using Go PDK package):**
|
||
|
||
```go
|
||
package main
|
||
|
||
import "github.com/navidrome/navidrome/plugins/pdk/go/metadata"
|
||
|
||
type myPlugin struct{}
|
||
|
||
func (p *myPlugin) GetArtistBiography(input metadata.ArtistRequest) (*metadata.ArtistBiographyResponse, error) {
|
||
return &metadata.ArtistBiographyResponse{Biography: "Biography text..."}, nil
|
||
}
|
||
|
||
func init() { metadata.Register(&myPlugin{}) }
|
||
func main() {}
|
||
```
|
||
|
||
**Example (raw wasmexport):**
|
||
|
||
```go
|
||
//go:wasmexport nd_get_artist_biography
|
||
func ndGetArtistBiography() int32 {
|
||
var input ArtistInput
|
||
if err := pdk.InputJSON(&input); err != nil {
|
||
pdk.SetError(err)
|
||
return 1
|
||
}
|
||
pdk.OutputJSON(BiographyOutput{Biography: "Artist biography..."})
|
||
return 0
|
||
}
|
||
```
|
||
|
||
### Scrobbler
|
||
|
||
Integrates with external scrobbling services. All four methods are **required**.
|
||
|
||
| Function | Input | Output | Description |
|
||
|---------------------------------|--------------|--------|------------------------------|
|
||
| `nd_scrobbler_is_authorized` | `{username}` | `bool` | Check if user is authorized |
|
||
| `nd_scrobbler_now_playing` | See below | (none) | Send now playing |
|
||
| `nd_scrobbler_scrobble` | See below | (none) | Submit a scrobble |
|
||
| `nd_scrobbler_playback_report` | See below | (none) | Send playback state report |
|
||
|
||
> **Important:** Scrobbler plugins require the `users` permission in their manifest. Scrobble events are only sent for users assigned to the plugin through Navidrome's configuration.
|
||
|
||
**Manifest permission:**
|
||
|
||
```json
|
||
{
|
||
"permissions": {
|
||
"users": {
|
||
"reason": "Receive scrobble events for users assigned to this plugin"
|
||
}
|
||
}
|
||
}
|
||
```
|
||
|
||
**NowPlaying/Scrobble Input:**
|
||
|
||
```json
|
||
{
|
||
"username": "john",
|
||
"track": {
|
||
"id": "track-id",
|
||
"title": "Song Title",
|
||
"album": "Album Name",
|
||
"artist": "Artist Name",
|
||
"albumArtist": "Album Artist",
|
||
"duration": 180.5,
|
||
"trackNumber": 1,
|
||
"discNumber": 1,
|
||
"mbzRecordingId": "...",
|
||
"mbzAlbumId": "...",
|
||
"mbzArtistId": "..."
|
||
},
|
||
"timestamp": 1703270400
|
||
}
|
||
```
|
||
|
||
**PlaybackReport Input:**
|
||
|
||
Same `username` and `track` fields, plus playback state details:
|
||
|
||
```json
|
||
{
|
||
"username": "john",
|
||
"track": { ... },
|
||
"state": "playing",
|
||
"positionMs": 45000,
|
||
"playbackRate": 1.0,
|
||
"playerId": "player-id",
|
||
"playerName": "My Client",
|
||
"timestamp": 1703270400
|
||
}
|
||
```
|
||
|
||
`state` is one of `starting`, `playing`, `paused`, `stopped`, or `expired`.
|
||
|
||
**Error Handling:**
|
||
|
||
On success, return `0`. On failure, use `pdk.SetError()` with one of these error types:
|
||
|
||
- `scrobbler(not_authorized)` – User needs to re-authorize
|
||
- `scrobbler(retry_later)` – Temporary failure, Navidrome will retry
|
||
- `scrobbler(unrecoverable)` – Permanent failure, scrobble discarded
|
||
|
||
```go
|
||
import "github.com/navidrome/navidrome/plugins/pdk/go/scrobbler"
|
||
|
||
return scrobbler.ScrobblerErrorNotAuthorized
|
||
return scrobbler.ScrobblerErrorRetryLater
|
||
return scrobbler.ScrobblerErrorUnrecoverable
|
||
```
|
||
|
||
### Lyrics
|
||
|
||
Provides lyrics for tracks. The single method is **required**.
|
||
|
||
| Function | Input | Output | Description |
|
||
|-------------------------|-------------------------------|------------------------------------|-----------------|
|
||
| `nd_lyrics_get_lyrics` | `{artistName, title, ...}` | `{lyrics: [{lang, text}]}` | Get lyrics |
|
||
|
||
Each returned lyric entry has a `lang` (language code) and `text` field. Multiple entries can be returned for different languages.
|
||
|
||
### SonicSimilarity
|
||
|
||
Audio-similarity discovery based on acoustic features (e.g., embeddings). Both methods are **required**.
|
||
|
||
| Function | Input | Output | Description |
|
||
|---------------------------------|----------------------------------|--------------------------------------------|---------------------------------------|
|
||
| `nd_get_sonic_similar_tracks` | `{song, count}` | `{matches: [{song, similarity}]}` | Find acoustically similar tracks |
|
||
| `nd_find_sonic_path` | `{startSong, endSong, count}` | `{matches: [{song, similarity}]}` | Find a path between two songs |
|
||
|
||
Each match contains a `song` reference and a `similarity` score (float64, 0.0–1.0).
|
||
|
||
### TaskWorker
|
||
|
||
Processes tasks from a queue. **Required** if your plugin uses the [Task](#task) host service: declaring the `taskqueue` permission without exporting this function fails the plugin load.
|
||
|
||
| Function | Input | Output | Description |
|
||
|---------------------|---------------------------------------------|---------|----------------------|
|
||
| `nd_task_execute` | `{queueName, taskID, payload, attempt}` | `string`| Execute a queued task|
|
||
|
||
The `payload` is raw bytes (the same bytes passed to `TaskEnqueue`). The `attempt` counter starts at 1 and increments on retries. Return a string result on success.
|
||
|
||
### Lifecycle
|
||
|
||
Optional initialization callback. Called once after the plugin fully loads.
|
||
|
||
| Function | Input | Output | Description |
|
||
|--------------|-------|------------|--------------------------------|
|
||
| `nd_on_init` | `{}` | `{error?}` | Called once after plugin loads |
|
||
|
||
Useful for initializing connections, scheduling recurring tasks, etc. Errors are logged but don't prevent the plugin from loading.
|
||
|
||
### SchedulerCallback
|
||
|
||
Receives scheduled task events. **Required** if your plugin uses the [Scheduler](#scheduler) host service: declaring the `scheduler` permission without exporting this function fails the plugin load.
|
||
|
||
| Function | Input | Output | Description |
|
||
|---------------------------|----------------------------------------------|--------|-----------------------------|
|
||
| `nd_scheduler_callback` | `{scheduleId, payload, isRecurring}` | (none) | Handle scheduled task event |
|
||
|
||
### WebSocketCallback
|
||
|
||
Receives WebSocket events. Export any subset of these to handle events from the [WebSocket](#websocket) host service.
|
||
|
||
| Function | Input | Description |
|
||
|----------------------------------|---------------------------------|----------------------------------|
|
||
| `nd_websocket_on_text_message` | `{connectionId, message}` | Text message received |
|
||
| `nd_websocket_on_binary_message` | `{connectionId, data}` | Binary message received (base64) |
|
||
| `nd_websocket_on_error` | `{connectionId, error}` | Connection error |
|
||
| `nd_websocket_on_close` | `{connectionId, code, reason}` | Connection closed |
|
||
|
||
Each callback invocation is subject to a 30-second timeout.
|
||
|
||
---
|
||
|
||
## Host Services
|
||
|
||
Host services let your plugin call back into Navidrome for advanced functionality. Each service (except [Config](#config)) requires declaring the corresponding permission in your manifest.
|
||
|
||
### Go PDK Setup
|
||
|
||
All host service examples below use the generated Go SDK. Add this to your `go.mod`:
|
||
|
||
```
|
||
require github.com/navidrome/navidrome/plugins/pdk/go v0.0.0
|
||
replace github.com/navidrome/navidrome/plugins/pdk/go => ../../pdk/go
|
||
```
|
||
|
||
Then import:
|
||
|
||
```go
|
||
import "github.com/navidrome/navidrome/plugins/pdk/go/host"
|
||
```
|
||
|
||
### HTTP
|
||
|
||
Make HTTP requests to external services. This is a dedicated host service (separate from Extism's built-in HTTP support) with additional features like timeouts and redirect control.
|
||
|
||
**Manifest permission:**
|
||
|
||
```json
|
||
{
|
||
"permissions": {
|
||
"http": {
|
||
"reason": "Fetch metadata from external API",
|
||
"requiredHosts": ["api.example.com", "*.musicbrainz.org"]
|
||
}
|
||
}
|
||
}
|
||
```
|
||
|
||
**Host functions:**
|
||
|
||
| Function | Parameters | Returns |
|
||
|-------------|----------------------------------------------------------|----------------------------------|
|
||
| `http_send` | `method, url, headers, body, timeoutMs, noFollowRedirects` | `statusCode, headers, body` |
|
||
|
||
**Limits:** Requests time out after 10 seconds by default (override per request with `timeoutMs`). Redirects are followed up to 5 times, re-checking the allowed hosts on every hop. Response bodies are capped at 10MB.
|
||
|
||
**Usage:**
|
||
|
||
```go
|
||
resp, err := host.HTTPSend(host.HTTPRequest{
|
||
Method: "GET",
|
||
URL: "https://api.example.com/data",
|
||
Headers: map[string]string{"Authorization": "Bearer " + apiKey},
|
||
})
|
||
if resp.StatusCode == 200 {
|
||
// Process resp.Body
|
||
}
|
||
```
|
||
|
||
### Scheduler
|
||
|
||
Schedule one-time or recurring tasks. Your plugin must export the [`nd_scheduler_callback`](#schedulercallback) function to receive events.
|
||
|
||
**Manifest permission:**
|
||
|
||
```json
|
||
{
|
||
"permissions": {
|
||
"scheduler": {
|
||
"reason": "Schedule periodic metadata refresh"
|
||
}
|
||
}
|
||
}
|
||
```
|
||
|
||
**Host functions:**
|
||
|
||
| Function | Parameters | Description |
|
||
|-------------------------------|------------------------------------------|-----------------------------|
|
||
| `scheduler_scheduleonetime` | `delaySeconds, payload, scheduleId?` | Schedule one-time callback |
|
||
| `scheduler_schedulerecurring` | `cronExpression, payload, scheduleId?` | Schedule recurring callback |
|
||
| `scheduler_cancelschedule` | `scheduleId` | Cancel a scheduled task |
|
||
|
||
**Usage:**
|
||
|
||
```go
|
||
// Schedule one-time task in 60 seconds
|
||
scheduleID, err := host.SchedulerScheduleOneTime(60, "my-payload", "")
|
||
|
||
// Schedule recurring task with cron expression (every hour)
|
||
scheduleID, err := host.SchedulerScheduleRecurring("0 * * * *", "hourly-task", "")
|
||
|
||
// Cancel a task
|
||
err := host.SchedulerCancelSchedule(scheduleID)
|
||
```
|
||
|
||
### Cache
|
||
|
||
In-memory TTL-based cache. Each plugin has its own isolated namespace. Cleared on server restart.
|
||
|
||
**Manifest permission:**
|
||
|
||
```json
|
||
{
|
||
"permissions": {
|
||
"cache": {
|
||
"reason": "Cache API responses to reduce external requests"
|
||
}
|
||
}
|
||
}
|
||
```
|
||
|
||
**Host functions:**
|
||
|
||
| Function | Parameters | Description |
|
||
|-------------------|---------------------------|-----------------------|
|
||
| `cache_setstring` | `key, value, ttl_seconds` | Store a string |
|
||
| `cache_getstring` | `key` | Get a string |
|
||
| `cache_setint` | `key, value, ttl_seconds` | Store an integer |
|
||
| `cache_getint` | `key` | Get an integer |
|
||
| `cache_setfloat` | `key, value, ttl_seconds` | Store a float |
|
||
| `cache_getfloat` | `key` | Get a float |
|
||
| `cache_setbytes` | `key, value, ttl_seconds` | Store bytes |
|
||
| `cache_getbytes` | `key` | Get bytes |
|
||
| `cache_has` | `key` | Check if key exists |
|
||
| `cache_remove` | `key` | Delete a cached value |
|
||
|
||
**TTL:** Pass `0` for the default (24 hours), or specify seconds.
|
||
|
||
**Usage:**
|
||
|
||
```go
|
||
// Cache a value for 1 hour
|
||
host.CacheSetString("api-response", responseData, 3600)
|
||
|
||
// Retrieve (returns value, exists, error)
|
||
value, exists, err := host.CacheGetString("api-response")
|
||
if exists {
|
||
// Use value
|
||
}
|
||
```
|
||
|
||
### KVStore
|
||
|
||
Persistent key-value storage backed by SQLite. Survives server restarts. Each plugin has its own isolated database at `${DataFolder}/plugins/${pluginID}/kvstore.db`.
|
||
|
||
**Manifest permission:**
|
||
|
||
```json
|
||
{
|
||
"permissions": {
|
||
"kvstore": {
|
||
"reason": "Store OAuth tokens and plugin state",
|
||
"maxSize": "1MB"
|
||
}
|
||
}
|
||
}
|
||
```
|
||
|
||
- `maxSize`: Maximum storage size (e.g., `"1MB"`, `"500KB"`). Default: 1MB
|
||
|
||
**Key constraints:** Maximum 256 bytes, must be valid UTF-8.
|
||
|
||
**Host functions:**
|
||
|
||
| Function | Parameters | Description |
|
||
|-----------------------------|--------------------------|-----------------------------------|
|
||
| `kvstore_set` | `key, value` | Store a byte value |
|
||
| `kvstore_setwithttl` | `key, value, ttlSeconds` | Store with auto-expiration |
|
||
| `kvstore_get` | `key` | Retrieve a byte value |
|
||
| `kvstore_getmany` | `keys` | Retrieve multiple values at once |
|
||
| `kvstore_has` | `key` | Check if key exists |
|
||
| `kvstore_list` | `prefix` | List keys matching prefix |
|
||
| `kvstore_delete` | `key` | Delete a value |
|
||
| `kvstore_deletebyprefix` | `prefix` | Delete all keys matching prefix |
|
||
| `kvstore_getstorageused` | – | Get current storage usage (bytes) |
|
||
|
||
**Usage:**
|
||
|
||
```go
|
||
// Store a value (as raw bytes)
|
||
token := []byte(`{"access_token": "xyz", "refresh_token": "abc"}`)
|
||
host.KVStoreSet("oauth:spotify", token)
|
||
|
||
// Store with TTL (auto-expires after 1 hour)
|
||
host.KVStoreSetWithTTL("session:abc", sessionData, 3600)
|
||
|
||
// Retrieve a value
|
||
value, exists, err := host.KVStoreGet("oauth:spotify")
|
||
if exists {
|
||
var tokenData map[string]string
|
||
json.Unmarshal(value, &tokenData)
|
||
}
|
||
|
||
// Batch retrieve
|
||
results, err := host.KVStoreGetMany([]string{"key1", "key2", "key3"})
|
||
|
||
// List and delete by prefix
|
||
keys, err := host.KVStoreList("user:")
|
||
host.KVStoreDeleteByPrefix("user:")
|
||
|
||
// Check storage usage
|
||
usage, err := host.KVStoreGetStorageUsed()
|
||
fmt.Printf("Using %d bytes\n", usage)
|
||
```
|
||
|
||
### Storage
|
||
|
||
A private read-write directory, mounted into the sandbox at `/storage` and backed by `${DataFolder}/plugins/${pluginID}/storage`. Survives server restarts. Use it for data that doesn't fit a key-value store: caches, downloaded files, generated indexes.
|
||
|
||
**Manifest permission:**
|
||
|
||
```json
|
||
{
|
||
"permissions": {
|
||
"storage": {
|
||
"reason": "Cache generated playlists between restarts"
|
||
}
|
||
}
|
||
}
|
||
```
|
||
|
||
**Host functions:**
|
||
|
||
| Function | Parameters | Description |
|
||
|--------------------------|------------|------------------------------------|
|
||
| `storage_getstoragepath` | – | Get the guest path of the mount |
|
||
|
||
**Usage:**
|
||
|
||
Normal WASI filesystem calls work inside the mount, so use the `os` package directly:
|
||
|
||
```go
|
||
import (
|
||
"os"
|
||
"path/filepath"
|
||
|
||
"github.com/navidrome/navidrome/plugins/pdk/go/host"
|
||
)
|
||
|
||
// The path never changes, so read it once instead of per operation
|
||
var storageDir = host.StorageGetStoragePath() // "/storage"
|
||
|
||
err := os.WriteFile(filepath.Join(storageDir, "cache.json"), data, 0600)
|
||
content, err := os.ReadFile(filepath.Join(storageDir, "cache.json"))
|
||
entries, err := os.ReadDir(storageDir)
|
||
```
|
||
|
||
> **Security:** Plugins cannot create symlinks inside the mount, and `..` or absolute paths are rejected. Symlinks that already exist in the directory are still followed, so anything linked in from elsewhere remains reachable.
|
||
|
||
> **Note:** There is no size limit, unlike [KVStore](#kvstore). The directory is not deleted when a plugin is uninstalled.
|
||
|
||
### Task
|
||
|
||
Background task queue with retry support. Plugins enqueue tasks and process them by exporting the [`nd_task_execute`](#taskworker) capability function.
|
||
|
||
**Manifest permission:**
|
||
|
||
```json
|
||
{
|
||
"permissions": {
|
||
"taskqueue": {
|
||
"reason": "Process audio analysis in the background",
|
||
"maxConcurrency": 2
|
||
}
|
||
}
|
||
}
|
||
```
|
||
|
||
**Host functions:**
|
||
|
||
| Function | Parameters | Description |
|
||
|---------------------|---------------------------------------------------------------|----------------------------|
|
||
| `task_createqueue` | `name, concurrency, maxRetries, backoffMs, delayMs, retentionMs` | Create a named task queue |
|
||
| `task_enqueue` | `queueName, payload` | Add a task to the queue |
|
||
| `task_get` | `taskID` | Get task status and result |
|
||
| `task_cancel` | `taskID` | Cancel a pending task |
|
||
| `task_clearqueue` | `queueName` | Remove all tasks from queue|
|
||
|
||
Tasks are persisted to SQLite, so pending tasks survive server restarts. Queue behavior:
|
||
|
||
- `concurrency` – Parallel workers (default 1), capped by the manifest's `maxConcurrency`
|
||
- `maxRetries` – Retries for a failed task (default 0); `backoffMs` (default 1000) doubles on each retry
|
||
- `delayMs` – Minimum delay between consecutive task starts, useful for rate limiting (default 0)
|
||
- `retentionMs` – How long finished tasks are kept (default 1 hour, min 1 minute, max 1 week)
|
||
- Payloads are capped at 1MB
|
||
|
||
**Usage:**
|
||
|
||
```go
|
||
// Create a queue with retry configuration
|
||
host.TaskCreateQueue("analysis", host.QueueConfig{
|
||
Concurrency: 2,
|
||
MaxRetries: 3,
|
||
BackoffMs: 1000,
|
||
})
|
||
|
||
// Enqueue a task
|
||
taskID, err := host.TaskEnqueue("analysis", []byte(`{"trackId": "abc"}`))
|
||
|
||
// Check task status
|
||
info, err := host.TaskGet(taskID)
|
||
fmt.Printf("Status: %s, Attempt: %d\n", info.Status, info.Attempt)
|
||
```
|
||
|
||
### WebSocket
|
||
|
||
Establish persistent WebSocket connections to external services. Your plugin must export [WebSocketCallback](#websocketcallback) functions to receive events.
|
||
|
||
**Manifest permission:**
|
||
|
||
```json
|
||
{
|
||
"permissions": {
|
||
"websocket": {
|
||
"reason": "Real-time connection to service",
|
||
"requiredHosts": ["gateway.example.com", "*.discord.gg"]
|
||
}
|
||
}
|
||
}
|
||
```
|
||
|
||
**Host functions:**
|
||
|
||
| Function | Parameters | Description |
|
||
|----------------------------|---------------------------------|-------------------|
|
||
| `websocket_connect` | `url, headers?, connectionId?` | Open a connection |
|
||
| `websocket_sendtext` | `connectionId, message` | Send text message |
|
||
| `websocket_sendbinary` | `connectionId, data` | Send binary data |
|
||
| `websocket_closeconnection`| `connectionId, code?, reason?` | Close connection |
|
||
|
||
**Usage:**
|
||
|
||
```go
|
||
connID, err := host.WebSocketConnect("wss://gateway.example.com", nil, "")
|
||
host.WebSocketSendText(connID, `{"op": 1, "d": null}`)
|
||
host.WebSocketCloseConnection(connID, 1000, "done")
|
||
```
|
||
|
||
### Library
|
||
|
||
Access music library metadata and optionally read files from library directories.
|
||
|
||
**Manifest permission:**
|
||
|
||
```json
|
||
{
|
||
"permissions": {
|
||
"library": {
|
||
"reason": "Access library metadata for analysis",
|
||
"filesystem": false
|
||
}
|
||
}
|
||
}
|
||
```
|
||
|
||
- `filesystem` – Set to `true` to enable access to library directories, read-only unless an administrator grants write access (default: `false`)
|
||
|
||
**Host functions:**
|
||
|
||
| Function | Parameters | Returns |
|
||
|----------------------------|------------|---------------------------|
|
||
| `library_getlibrary` | `id` | Library metadata |
|
||
| `library_getalllibraries` | (none) | Array of library metadata |
|
||
|
||
**Library metadata:**
|
||
|
||
```json
|
||
{
|
||
"id": 1,
|
||
"name": "My Music",
|
||
"path": "/music/collection",
|
||
"mountPoint": "/libraries/1",
|
||
"lastScanAt": 1703270400,
|
||
"totalSongs": 5000,
|
||
"totalAlbums": 500,
|
||
"totalArtists": 200,
|
||
"totalSize": 50000000000,
|
||
"totalDuration": 1500000.5
|
||
}
|
||
```
|
||
|
||
> **Note:** The `path` and `mountPoint` fields are only included when `filesystem: true` is set in the permission.
|
||
|
||
**Filesystem access:**
|
||
|
||
When `filesystem: true`, your plugin can read files from library directories via WASI filesystem APIs. Each library is mounted at `/libraries/<id>`:
|
||
|
||
```go
|
||
import "os"
|
||
|
||
content, err := os.ReadFile("/libraries/1/Artist/Album/track.mp3")
|
||
entries, err := os.ReadDir("/libraries/1/Artist")
|
||
```
|
||
|
||
> **Security:** Plugins cannot create symlinks inside the mount, and `..` or absolute paths are rejected. Symlinks already present in the library are still followed, so folders linked in from elsewhere work as expected. Access is read-only unless an administrator grants the plugin write access (`navidrome plugin edit <name> --write-access`).
|
||
|
||
**Usage:**
|
||
|
||
```go
|
||
// Get a specific library
|
||
library, err := host.LibraryGetLibrary(1)
|
||
fmt.Printf("Library: %s (%d songs)\n", library.Name, library.TotalSongs)
|
||
|
||
// Get all libraries
|
||
libraries, err := host.LibraryGetAllLibraries()
|
||
for _, lib := range libraries {
|
||
fmt.Printf("Library: %s (%d songs)\n", lib.Name, lib.TotalSongs)
|
||
}
|
||
```
|
||
|
||
### Matcher
|
||
|
||
Match externally-obtained songs (e.g. results from a recommendation or similarity API) to tracks in the local library, reusing Navidrome's matching algorithm (ID > MBID > ISRC > fuzzy title).
|
||
|
||
**Manifest permission:**
|
||
|
||
```json
|
||
{
|
||
"permissions": {
|
||
"matcher": {
|
||
"reason": "Resolve external recommendations to library tracks"
|
||
},
|
||
"library": {
|
||
"reason": "Required by the matcher permission"
|
||
}
|
||
}
|
||
}
|
||
```
|
||
|
||
> **Important:** The `matcher` permission requires the `library` permission.
|
||
|
||
**Host functions:**
|
||
|
||
| Function | Parameters | Returns |
|
||
|----------------------|---------------|-------------------------|
|
||
| `matcher_matchsongs` | `songs, opts` | Array of matched tracks |
|
||
|
||
The result has one entry per input song, in the same order; the entry for a song with no match is empty. Results are limited to the libraries the plugin (and the scoped user, if any) can access. Set `opts.username` to run the match as a specific user: their favorites and ratings inform tiebreaking, and the returned tracks carry their annotations. User scoping additionally requires the [`users`](#users) permission, with users assigned to the plugin.
|
||
|
||
**Usage:**
|
||
|
||
```go
|
||
import "github.com/navidrome/navidrome/plugins/pdk/go/types"
|
||
|
||
matches, err := host.MatcherMatchSongs([]types.SongRef{
|
||
{Name: "Song Title", Artists: []types.ArtistRef{{Name: "Artist Name"}}},
|
||
}, host.MatchOptions{})
|
||
```
|
||
|
||
### Artwork
|
||
|
||
Generate public URLs for Navidrome artwork (albums, artists, tracks, playlists).
|
||
|
||
**Manifest permission:**
|
||
|
||
```json
|
||
{
|
||
"permissions": {
|
||
"artwork": {
|
||
"reason": "Get artwork URLs for display"
|
||
}
|
||
}
|
||
}
|
||
```
|
||
|
||
**Host functions:**
|
||
|
||
| Function | Parameters | Returns |
|
||
|--------------------------|------------|-------------|
|
||
| `artwork_getartisturl` | `id, size` | Artwork URL |
|
||
| `artwork_getalbumurl` | `id, size` | Artwork URL |
|
||
| `artwork_gettrackurl` | `id, size` | Artwork URL |
|
||
| `artwork_getplaylisturl` | `id, size` | Artwork URL |
|
||
|
||
**Usage:**
|
||
|
||
```go
|
||
url, err := host.ArtworkGetAlbumUrl("album-id", 300)
|
||
```
|
||
|
||
### SubsonicAPI
|
||
|
||
Call Navidrome's Subsonic API internally (no network round-trip).
|
||
|
||
**Manifest permission:**
|
||
|
||
```json
|
||
{
|
||
"permissions": {
|
||
"subsonicapi": {
|
||
"reason": "Access library data"
|
||
},
|
||
"users": {
|
||
"reason": "Access user information for SubsonicAPI authorization"
|
||
}
|
||
}
|
||
}
|
||
```
|
||
|
||
> **Important:** The `subsonicapi` permission requires the `users` permission. Which users the plugin can act as is controlled through the Navidrome UI.
|
||
|
||
**Host functions:**
|
||
|
||
| Function | Parameters | Returns |
|
||
|-----------------------|------------|--------------------------------|
|
||
| `subsonicapi_call` | `uri` | JSON response string |
|
||
| `subsonicapi_callraw` | `uri` | Content type + binary response |
|
||
|
||
**Usage:**
|
||
|
||
```go
|
||
// JSON response
|
||
response, err := host.SubsonicAPICall("getAlbumList2?type=random&size=10&u=username")
|
||
|
||
// Binary response (e.g., cover art, streams)
|
||
contentType, data, err := host.SubsonicAPICallRaw("getCoverArt?id=al-123&u=username")
|
||
```
|
||
|
||
### Config
|
||
|
||
Access plugin configuration values. Unlike `pdk.GetConfig()` which only retrieves individual values, this service can list all available configuration keys — useful for discovering dynamic configuration.
|
||
|
||
> **Note:** This service is always available and does not require a manifest permission.
|
||
|
||
**Host functions:**
|
||
|
||
| Function | Parameters | Returns |
|
||
|-----------------|------------|-----------------------------|
|
||
| `config_get` | `key` | `value, exists` |
|
||
| `config_getint` | `key` | `value, exists` |
|
||
| `config_keys` | `prefix` | Array of matching key names |
|
||
|
||
**Usage:**
|
||
|
||
```go
|
||
// Get a configuration value
|
||
value, exists := host.ConfigGet("api_key")
|
||
|
||
// Get an integer configuration value
|
||
count, exists := host.ConfigGetInt("max_retries")
|
||
|
||
// List all keys with a prefix (useful for user-specific config)
|
||
keys := host.ConfigKeys("user:")
|
||
|
||
// List all configuration keys
|
||
allKeys := host.ConfigKeys("")
|
||
```
|
||
|
||
### Users
|
||
|
||
Access user information for the users that the plugin has been granted access to.
|
||
|
||
**Manifest permission:**
|
||
|
||
```json
|
||
{
|
||
"permissions": {
|
||
"users": {
|
||
"reason": "Display user information in status updates"
|
||
}
|
||
}
|
||
}
|
||
```
|
||
|
||
**Important:** Before enabling a plugin that requires the `users` permission, an administrator must configure which users the plugin can access:
|
||
|
||
1. **Allow all users** – Enable the "Allow all users" toggle in the plugin settings
|
||
2. **Select specific users** – Choose individual users from the user list
|
||
|
||
If neither option is configured, the plugin cannot be enabled.
|
||
|
||
**Host functions:**
|
||
|
||
| Function | Parameters | Returns |
|
||
|------------------|------------|-----------------------|
|
||
| `users_getusers` | – | Array of User objects |
|
||
| `users_getadmins`| – | Array of admin Users |
|
||
|
||
**User object fields:**
|
||
|
||
| Field | Type | Description |
|
||
|------------|---------|--------------------------------|
|
||
| `userName` | string | The user's unique username |
|
||
| `name` | string | The user's display name |
|
||
| `isAdmin` | boolean | Whether the user is an admin |
|
||
|
||
> **Security:** Sensitive fields like passwords, email addresses, and internal IDs are never exposed to plugins.
|
||
|
||
**Usage:**
|
||
|
||
```go
|
||
users, err := host.UsersGetUsers()
|
||
for _, user := range users {
|
||
pdk.Log(pdk.LogInfo, "User: " + user.UserName + " (" + user.Name + ")")
|
||
}
|
||
|
||
admins, err := host.UsersGetAdmins()
|
||
```
|
||
|
||
### ScrobbleRetriever
|
||
|
||
Retrieve the scrobble history of users the plugin has been granted access to. Each scrobble carries only the media file ID and the submission time; use the Matcher host service to resolve them to track metadata.
|
||
|
||
**Manifest permission:**
|
||
|
||
```json
|
||
{
|
||
"permissions": {
|
||
"scrobbleRetriever": {
|
||
"reason": "Sync scrobble history to an external service"
|
||
},
|
||
"users": {
|
||
"reason": "Access user information for scrobble retrieval"
|
||
}
|
||
}
|
||
}
|
||
```
|
||
|
||
> **Important:** The `scrobbleRetriever` permission requires the `users` permission. Which users the plugin can act as is controlled through the Navidrome UI.
|
||
|
||
**Host functions:**
|
||
|
||
| Function | Parameters | Returns |
|
||
|---------------------------------------|-----------------------|----------------------------------|
|
||
| `scrobbleretriever_getfirsttimestamp` | `username` | Unix timestamp of oldest scrobble, or null |
|
||
| `scrobbleretriever_getlasttimestamp` | `username` | Unix timestamp of newest scrobble, or null |
|
||
| `scrobbleretriever_getscrobbles` | `username`, `options` | One page of scrobbles + options for the next page |
|
||
| `scrobbleretriever_getscrobblecount` | `username`, `options` | Number of scrobbles in the range |
|
||
|
||
**ScrobbleOptions fields** (all optional):
|
||
|
||
| Field | Type | Description |
|
||
|-----------------|---------|----------------------------------------------------------|
|
||
| `fromTimestamp` | int64 | Start of the range (inclusive). Default: first scrobble |
|
||
| `toTimestamp` | int64 | End of the range (inclusive). Default: last scrobble |
|
||
| `descending` | boolean | Newest first. Default: oldest first |
|
||
| `maxItems` | int | Page size, capped at 5000 (the default) |
|
||
| `offset` | int | Managed by the host for pagination. Never set it manually |
|
||
|
||
**ScrobbleRef fields:**
|
||
|
||
| Field | Type | Description |
|
||
|------------------|--------|------------------------------------------------|
|
||
| `id` | int64 | Scrobble ID, unique even for duplicate submissions |
|
||
| `mediaFileId` | string | The media file that was scrobbled |
|
||
| `submissionTime` | int64 | Unix timestamp of the submission |
|
||
|
||
**Usage:**
|
||
|
||
`GetScrobbles` returns one page plus the options to fetch the following page. Pass them back unchanged and repeat until they are nil:
|
||
|
||
```go
|
||
opts := host.ScrobbleOptions{MaxItems: 500}
|
||
var all []host.ScrobbleRef
|
||
for {
|
||
page, next, err := host.ScrobbleRetrieverGetScrobbles("username", opts)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
all = append(all, page...)
|
||
if next == nil {
|
||
break // no more scrobbles
|
||
}
|
||
opts = *next
|
||
}
|
||
|
||
// Range boundaries and counts
|
||
first, err := host.ScrobbleRetrieverGetFirstTimestamp("username") // nil if no scrobbles
|
||
count, err := host.ScrobbleRetrieverGetScrobbleCount("username", host.ScrobbleCountOptions{
|
||
FromTimestamp: first,
|
||
})
|
||
```
|
||
|
||
> **Note:** The returned `next` options carry an adjusted `fromTimestamp`/`toTimestamp`, so keep a copy of your original options if you still need the range.
|
||
|
||
---
|
||
|
||
## Configuration
|
||
|
||
### Server Configuration
|
||
|
||
Enable plugins in `navidrome.toml`:
|
||
|
||
```toml
|
||
[Plugins]
|
||
Enabled = true
|
||
Folder = "/path/to/plugins" # Default: DataFolder/plugins
|
||
AutoReload = true # Auto-reload on file changes (dev mode)
|
||
LogLevel = "debug" # Plugin-specific log level
|
||
CacheSize = "200MB" # Compilation cache size limit
|
||
```
|
||
|
||
### Plugin Configuration
|
||
|
||
Plugin configuration is managed through the Navidrome web UI. Navigate to the Plugins page, select a plugin, and edit its configuration as key-value pairs.
|
||
|
||
Access configuration values in your plugin:
|
||
|
||
```go
|
||
apiKey, ok := pdk.GetConfig("api_key")
|
||
if !ok {
|
||
pdk.SetErrorString("api_key configuration is required")
|
||
return 1
|
||
}
|
||
```
|
||
|
||
For more advanced access (listing keys, integer values), use the [Config](#config) host service.
|
||
|
||
---
|
||
|
||
## Command Line Interface
|
||
|
||
Manage plugins from the command line with `navidrome plugin`:
|
||
|
||
| Command | Description |
|
||
|--------------------------------------------------------|------------------------------------------------------------|
|
||
| `navidrome plugin list [-f table\|csv\|json]` | List installed plugins |
|
||
| `navidrome plugin info <id\|file.ndp> [-f text\|json]` | Show details for an installed plugin or a `.ndp` package |
|
||
| `navidrome plugin validate <id\|file.ndp>` | Validate an installed plugin or a `.ndp` package manifest |
|
||
| `navidrome plugin enable <id>` | Enable a plugin |
|
||
| `navidrome plugin disable <id>` | Disable a plugin |
|
||
| `navidrome plugin edit <id>` | Update a plugin's config and/or permissions |
|
||
| `navidrome plugin rescan` | Re-discover plugins in the plugins folder |
|
||
|
||
**`plugin edit` flags:**
|
||
|
||
- `--config <json>` / `--config-file <path>` – Set the plugin configuration (`-` reads from stdin)
|
||
- `--users <list>` / `--all-users` – Usernames the plugin may access (comma-separated or JSON array), or all users
|
||
- `--libraries <list>` / `--all-libraries` – Library IDs the plugin may access (comma-separated or JSON array), or all libraries
|
||
- `--write-access` / `--no-write-access` – Allow or deny the plugin write access to libraries
|
||
|
||
---
|
||
|
||
## Building Plugins
|
||
|
||
### Supported Languages
|
||
|
||
Plugins can be written in any language that Extism supports. We recommend:
|
||
|
||
- **Go** – Best overall experience with [TinyGo](https://tinygo.org/) and the [Go PDK](https://github.com/extism/go-pdk). Familiar syntax, excellent stdlib support.
|
||
- **Rust** – Best for performance-critical plugins. Smallest binaries, excellent type safety. Uses the [Rust PDK](https://github.com/extism/rust-pdk).
|
||
- **Python** – Best for rapid prototyping. Experimental support via [extism-py](https://github.com/extism/python-pdk). Note some limitations compared to compiled languages.
|
||
- **TypeScript** – Experimental support via [extism-js](https://github.com/extism/js-pdk).
|
||
|
||
### Go with TinyGo (Recommended)
|
||
|
||
```bash
|
||
# Install TinyGo: https://tinygo.org/getting-started/install/
|
||
|
||
# Build WebAssembly module
|
||
tinygo build -o plugin.wasm -target wasip1 -buildmode=c-shared .
|
||
|
||
# Package as .ndp
|
||
zip -j my-plugin.ndp manifest.json plugin.wasm
|
||
```
|
||
|
||
#### Using Go PDK Packages
|
||
|
||
Navidrome provides type-safe Go packages for each capability and host service in `plugins/pdk/go/`. Instead of manually exporting functions with `//go:wasmexport`, use the `Register()` pattern:
|
||
|
||
```go
|
||
package main
|
||
|
||
import "github.com/navidrome/navidrome/plugins/pdk/go/metadata"
|
||
|
||
type myPlugin struct{}
|
||
|
||
func (p *myPlugin) GetArtistBiography(input metadata.ArtistRequest) (*metadata.ArtistBiographyResponse, error) {
|
||
return &metadata.ArtistBiographyResponse{Biography: "Biography text..."}, nil
|
||
}
|
||
|
||
func init() { metadata.Register(&myPlugin{}) }
|
||
func main() {}
|
||
```
|
||
|
||
Add to your `go.mod`:
|
||
|
||
```
|
||
require github.com/navidrome/navidrome v0.0.0
|
||
replace github.com/navidrome/navidrome => ../../..
|
||
```
|
||
|
||
**Available capability packages:**
|
||
|
||
| Package | Import Path | Description |
|
||
|-------------------|--------------------------------------|--------------------------------------|
|
||
| `metadata` | `plugins/pdk/go/metadata` | Artist/album metadata providers |
|
||
| `scrobbler` | `plugins/pdk/go/scrobbler` | Scrobbling services |
|
||
| `lyrics` | `plugins/pdk/go/lyrics` | Lyrics providers |
|
||
| `sonicsimilarity` | `plugins/pdk/go/sonicsimilarity` | Audio similarity discovery |
|
||
| `taskworker` | `plugins/pdk/go/taskworker` | Background task processing |
|
||
| `lifecycle` | `plugins/pdk/go/lifecycle` | Plugin initialization |
|
||
| `scheduler` | `plugins/pdk/go/scheduler` | Scheduled task callbacks |
|
||
| `websocket` | `plugins/pdk/go/websocket` | WebSocket event handlers |
|
||
| `host` | `plugins/pdk/go/host` | Host service SDK (all services) |
|
||
| `types` | `plugins/pdk/go/types` | Shared data types (tracks, artists, song refs) |
|
||
| `pdk` | `plugins/pdk/go/pdk` | Low-level helpers (wraps extism/go-pdk: config, logging, memory) |
|
||
|
||
See the example plugins in [examples/](examples/) for complete usage patterns.
|
||
|
||
### Rust
|
||
|
||
```bash
|
||
# Build WebAssembly module
|
||
cargo build --release --target wasm32-wasip1
|
||
|
||
# Package as .ndp
|
||
zip -j my-plugin.ndp manifest.json target/wasm32-wasip1/release/plugin.wasm
|
||
```
|
||
|
||
#### Using Rust PDK
|
||
|
||
```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, Error};
|
||
use nd_pdk::register_scrobbler;
|
||
|
||
#[derive(Default)]
|
||
struct MyPlugin;
|
||
|
||
impl Scrobbler for MyPlugin {
|
||
fn is_authorized(&self, req: IsAuthorizedRequest) -> Result<bool, Error> {
|
||
Ok(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::set_string("my_key", "my_value", 3600)?;
|
||
scheduler::schedule_recurring("@every 5m", "payload", "task_id")?;
|
||
let libs = library::get_all_libraries()?;
|
||
```
|
||
|
||
See [pdk/rust/README.md](pdk/rust/README.md) for detailed documentation.
|
||
|
||
### Python (with extism-py)
|
||
|
||
```bash
|
||
# Build WebAssembly module (requires extism-py installed)
|
||
extism-py plugin.wasm -o plugin.wasm *.py
|
||
|
||
# Package as .ndp
|
||
zip -j my-plugin.ndp manifest.json plugin.wasm
|
||
```
|
||
|
||
### Using XTP CLI (Scaffolding)
|
||
|
||
Bootstrap a new plugin from a schema:
|
||
|
||
```bash
|
||
# Install XTP CLI: https://docs.xtp.dylibso.com/docs/cli
|
||
|
||
# Create a metadata agent plugin
|
||
xtp plugin init \
|
||
--schema-file plugins/capabilities/metadata_agent.yaml \
|
||
--template go \
|
||
--path ./my-agent \
|
||
--name my-agent
|
||
|
||
# Build and package
|
||
cd my-agent && xtp plugin build
|
||
zip -j my-agent.ndp manifest.json dist/plugin.wasm
|
||
```
|
||
|
||
See [capabilities/README.md](capabilities/README.md) for available schemas and scaffolding examples.
|
||
|
||
---
|
||
|
||
## Examples
|
||
|
||
See [examples/](examples/) for complete working plugins:
|
||
|
||
| Plugin | Language | Capabilities | Host Services | Description |
|
||
|----------------------------------------------------------------|----------------|---------------|--------------------------------------------|--------------------------------|
|
||
| [minimal](examples/minimal/) | Go | MetadataAgent | – | Basic structure example |
|
||
| [wikimedia](examples/wikimedia/) | Go | MetadataAgent | HTTP | Wikidata/Wikipedia integration |
|
||
| [coverartarchive-py](examples/coverartarchive-py/) | Python | MetadataAgent | HTTP | Cover Art Archive |
|
||
| [webhook-rs](examples/webhook-rs/) | Rust | Scrobbler | HTTP | HTTP webhooks |
|
||
| [nowplaying-py](examples/nowplaying-py/) | Python | Lifecycle, SchedulerCallback | Scheduler, SubsonicAPI | Periodic now-playing logger |
|
||
| [library-inspector-rs](examples/library-inspector-rs/) | Rust | Lifecycle, SchedulerCallback | Library, Scheduler | Periodic library stats logging |
|
||
| [crypto-ticker](examples/crypto-ticker/) | Go | Lifecycle, SchedulerCallback, WebSocketCallback | WebSocket, Scheduler | Real-time crypto prices demo |
|
||
| [discord-rich-presence-rs](examples/discord-rich-presence-rs/) | Rust | Scrobbler, SchedulerCallback, WebSocketCallback | HTTP, WebSocket, Cache, Scheduler, Artwork, Config | Discord integration |
|
||
|
||
---
|
||
|
||
## Security
|
||
|
||
Plugins run in a secure WebAssembly sandbox provided by [Extism](https://extism.org/) and the [Wazero](https://wazero.io/) runtime:
|
||
|
||
1. **Host Allowlisting** – Only explicitly allowed hosts are accessible via HTTP/WebSocket
|
||
2. **Limited File System** – Plugins cannot create symlinks inside a mount and `..` or absolute paths are rejected, though symlinks already present are followed. Library access requires the `library.filesystem` permission and is read-only unless an administrator grants write access; the `storage` permission grants a read-write directory private to the plugin
|
||
3. **No Network Listeners** – Plugins cannot bind ports
|
||
4. **Config Isolation** – Plugins only receive their own config section
|
||
5. **Memory Limits** – Controlled by the WebAssembly runtime
|
||
6. **User-Scoped Authorization** – Plugins with `subsonicapi`, `scrobbleRetriever`, or `scrobbler` capabilities can only access/receive events for users assigned to them through Navidrome's configuration
|
||
7. **Users Permission** – Plugins requesting user access must be explicitly configured with allowed users; sensitive data (passwords, emails) is never exposed
|
||
|
||
---
|
||
|
||
## Runtime Management
|
||
|
||
### Auto-Reload
|
||
|
||
With `AutoReload = true`, Navidrome watches the plugins folder and automatically detects when `.ndp` files are added, modified, or removed. When a plugin file changes, the plugin is disabled and its metadata is re-read from the archive.
|
||
|
||
If `AutoReload` is disabled, Navidrome needs to be restarted to pick up plugin changes.
|
||
|
||
### Enabling/Disabling Plugins
|
||
|
||
Plugins can be enabled/disabled via the Navidrome UI or the [`navidrome plugin` CLI](#command-line-interface). The plugin state is persisted in the database.
|
||
|
||
### Important Notes
|
||
|
||
- **In-flight requests** – When reloading, existing requests complete before the new version takes over
|
||
- **Config changes** – Changes to the plugin configuration in the UI are applied immediately
|
||
- **Cache persistence** – The in-memory cache is cleared when a plugin is unloaded |