mirror of
https://github.com/navidrome/navidrome.git
synced 2026-05-03 06:51:16 +00:00
945 lines
29 KiB
Markdown
945 lines
29 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, and other integrations through host services like scheduling, caching, WebSockets, and Subsonic API access.
|
||
|
||
The plugin system is built on **[Extism](https://extism.org/)**, a cross-language framework for building WebAssembly plugins. This means 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)
|
||
- [Lifecycle](#lifecycle)
|
||
- [Host Services](#host-services)
|
||
- [HTTP Requests](#http-requests)
|
||
- [Scheduler](#scheduler)
|
||
- [Cache](#cache)
|
||
- [KVStore](#kvstore)
|
||
- [WebSocket](#websocket)
|
||
- [Library](#library)
|
||
- [Artwork](#artwork)
|
||
- [SubsonicAPI](#subsonicapi)
|
||
- [Configuration](#configuration)
|
||
- [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 Package Structure
|
||
|
||
```
|
||
my-plugin.ndp (zip archive)
|
||
├── manifest.json # Required: Plugin metadata
|
||
└── plugin.wasm # Required: Compiled WebAssembly module
|
||
```
|
||
|
||
### 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",
|
||
"permissions": {
|
||
"http": {
|
||
"reason": "Fetch metadata from external API",
|
||
"allowedHosts": ["api.example.com", "*.musicbrainz.org"]
|
||
}
|
||
}
|
||
}
|
||
```
|
||
|
||
**Required fields:** `name`, `author`, `version`
|
||
|
||
#### Experimental Features
|
||
|
||
Plugins can opt-in to experimental WebAssembly features that may change or be removed in future versions. Currently supported:
|
||
|
||
- **`threads`** – Enables WebAssembly threads support (for plugins compiled with multi-threading)
|
||
|
||
```json
|
||
{
|
||
"name": "Threaded Plugin",
|
||
"author": "Author Name",
|
||
"version": "1.0.0",
|
||
"experimental": {
|
||
"threads": {
|
||
"reason": "Required for concurrent audio processing"
|
||
}
|
||
}
|
||
}
|
||
```
|
||
|
||
> **Note:** Experimental features may have compatibility or performance implications. Use only when necessary.
|
||
|
||
---
|
||
|
||
## Capabilities
|
||
|
||
Capabilities define what your plugin can do. They're automatically detected based on which functions you export.
|
||
|
||
### MetadataAgent
|
||
|
||
Provides artist and album metadata. Export one or more of these functions:
|
||
|
||
| 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 |
|
||
|
||
**Example:**
|
||
|
||
```go
|
||
type ArtistInput struct {
|
||
ID string `json:"id"`
|
||
Name string `json:"name"`
|
||
MBID string `json:"mbid,omitempty"`
|
||
}
|
||
|
||
type BiographyOutput struct {
|
||
Biography string `json:"biography"`
|
||
}
|
||
|
||
//go:wasmexport nd_get_artist_biography
|
||
func ndGetArtistBiography() int32 {
|
||
var input ArtistInput
|
||
if err := pdk.InputJSON(&input); err != nil {
|
||
pdk.SetError(err)
|
||
return 1
|
||
}
|
||
|
||
// Fetch biography from your data source...
|
||
output := BiographyOutput{Biography: "Artist biography..."}
|
||
pdk.OutputJSON(output)
|
||
return 0
|
||
}
|
||
```
|
||
|
||
To use the plugin as a metadata agent, add it to your config:
|
||
|
||
```toml
|
||
Agents = "lastfm,spotify,my-plugin"
|
||
```
|
||
|
||
### Scrobbler
|
||
|
||
Integrates with external scrobbling services. Export one or more of these functions:
|
||
|
||
| Function | Input | Output | Description |
|
||
|------------------------------|-----------------------|----------------|-----------------------------|
|
||
| `nd_scrobbler_is_authorized` | `{userId, 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 |
|
||
|
||
**NowPlaying/Scrobble Input:**
|
||
|
||
```json
|
||
{
|
||
"userId": "abc123",
|
||
"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
|
||
}
|
||
```
|
||
|
||
**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 error using predefined constants
|
||
return scrobbler.ScrobblerErrorNotAuthorized
|
||
return scrobbler.ScrobblerErrorRetryLater
|
||
return scrobbler.ScrobblerErrorUnrecoverable
|
||
```
|
||
|
||
### Lifecycle
|
||
|
||
Optional initialization callback. Export this function to run code when your plugin loads:
|
||
|
||
| Function | Input | Output | Description |
|
||
|--------------|-------|------------|--------------------------------|
|
||
| `nd_on_init` | `{}` | `{error?}` | Called once after plugin loads |
|
||
|
||
Useful for initializing connections, scheduling recurring tasks, etc.
|
||
|
||
---
|
||
|
||
## Host Services
|
||
|
||
Host services let your plugin call back into Navidrome for advanced functionality. Each service requires declaring the permission in your manifest.
|
||
|
||
### HTTP Requests
|
||
|
||
Make HTTP requests using the Extism PDK's built-in HTTP support. See your [Extism PDK documentation](https://extism.org/docs/concepts/pdk) for more details on making requests.
|
||
|
||
**Manifest permission:**
|
||
|
||
```json
|
||
{
|
||
"permissions": {
|
||
"http": {
|
||
"reason": "Fetch metadata from external API",
|
||
"allowedHosts": ["api.example.com", "*.musicbrainz.org"]
|
||
}
|
||
}
|
||
}
|
||
```
|
||
|
||
**Usage:**
|
||
|
||
```go
|
||
req := pdk.NewHTTPRequest(pdk.MethodGet, "https://api.example.com/data")
|
||
req.SetHeader("Authorization", "Bearer " + apiKey)
|
||
resp := req.Send()
|
||
|
||
if resp.Status() == 200 {
|
||
data := resp.Body()
|
||
// Process response...
|
||
}
|
||
```
|
||
|
||
### Scheduler
|
||
|
||
Schedule one-time or recurring tasks. Your plugin must export `nd_scheduler_callback` 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 |
|
||
|
||
**Callback function:**
|
||
|
||
```go
|
||
type SchedulerCallbackInput struct {
|
||
ScheduleID string `json:"scheduleId"`
|
||
Payload string `json:"payload"`
|
||
IsRecurring bool `json:"isRecurring"`
|
||
}
|
||
|
||
//go:wasmexport nd_scheduler_callback
|
||
func ndSchedulerCallback() int32 {
|
||
var input SchedulerCallbackInput
|
||
pdk.InputJSON(&input)
|
||
|
||
// Handle the scheduled task based on payload
|
||
pdk.Log(pdk.LogInfo, "Task fired: " + input.ScheduleID)
|
||
return 0
|
||
}
|
||
```
|
||
|
||
**Scheduling tasks (using generated SDK):**
|
||
|
||
Add the generated SDK 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 and use:
|
||
|
||
```go
|
||
import "github.com/navidrome/navidrome/plugins/pdk/go/host"
|
||
|
||
// 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
|
||
|
||
Store and retrieve data in an in-memory TTL-based cache. Each plugin has its own isolated namespace.
|
||
|
||
**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 (with generated SDK):**
|
||
|
||
Import the Go SDK (see [Scheduler](#scheduler) for `go.mod` setup):
|
||
|
||
```go
|
||
import "github.com/navidrome/navidrome/plugins/pdk/go/host"
|
||
|
||
// Cache a value for 1 hour
|
||
host.CacheSetString("api-response", responseData, 3600)
|
||
|
||
// Retrieve (check Exists before using Value)
|
||
result, err := host.CacheGetString("api-response")
|
||
if result.Exists {
|
||
data := result.Value
|
||
}
|
||
```
|
||
|
||
> **Note:** Cache is in-memory only and cleared on server restart.
|
||
|
||
### KVStore
|
||
|
||
Persistent key-value storage that survives server restarts. Each plugin has its own isolated SQLite database.
|
||
|
||
**Manifest permission:**
|
||
|
||
```json
|
||
{
|
||
"permissions": {
|
||
"kvstore": {
|
||
"reason": "Store OAuth tokens and plugin state",
|
||
"maxSize": "1MB"
|
||
}
|
||
}
|
||
}
|
||
```
|
||
|
||
**Permission options:**
|
||
- `maxSize`: Maximum storage size (e.g., `"1MB"`, `"500KB"`). Default: 1MB
|
||
|
||
**Host functions:**
|
||
|
||
| Function | Parameters | Description |
|
||
|--------------------------|--------------|-----------------------------------|
|
||
| `kvstore_set` | `key, value` | Store a byte value |
|
||
| `kvstore_get` | `key` | Retrieve a byte value |
|
||
| `kvstore_delete` | `key` | Delete a value |
|
||
| `kvstore_has` | `key` | Check if key exists |
|
||
| `kvstore_list` | `prefix` | List keys matching prefix |
|
||
| `kvstore_getstorageused` | - | Get current storage usage (bytes) |
|
||
|
||
**Key constraints:**
|
||
- Maximum key length: 256 bytes
|
||
- Keys must be valid UTF-8 strings
|
||
|
||
**Usage (with generated SDK):**
|
||
|
||
Import the Go SDK (see [Scheduler](#scheduler) for `go.mod` setup):
|
||
|
||
```go
|
||
import "github.com/navidrome/navidrome/plugins/pdk/go/host"
|
||
|
||
// Store a value (as raw bytes)
|
||
token := []byte(`{"access_token": "xyz", "refresh_token": "abc"}`)
|
||
_, err := host.KVStoreSet("oauth:spotify", token)
|
||
|
||
// Retrieve a value
|
||
result, err := host.KVStoreGet("oauth:spotify")
|
||
if result.Exists {
|
||
var tokenData map[string]string
|
||
json.Unmarshal(result.Value, &tokenData)
|
||
}
|
||
|
||
// List all keys with prefix
|
||
keysResult, err := host.KVStoreList("user:")
|
||
for _, key := range keysResult.Keys {
|
||
// Process each key
|
||
}
|
||
|
||
// Check storage usage
|
||
usageResult, err := host.KVStoreGetStorageUsed()
|
||
fmt.Printf("Using %d bytes\n", usageResult.Bytes)
|
||
|
||
// Delete a value
|
||
host.KVStoreDelete("oauth:spotify")
|
||
```
|
||
|
||
> **Note:** Unlike Cache, KVStore data persists across server restarts. Storage is located at `${DataFolder}/plugins/${pluginID}/kvstore.db`.
|
||
|
||
### WebSocket
|
||
|
||
Establish persistent WebSocket connections to external services.
|
||
|
||
**Manifest permission:**
|
||
|
||
```json
|
||
{
|
||
"permissions": {
|
||
"websocket": {
|
||
"reason": "Real-time connection to service",
|
||
"allowedHosts": ["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_close` | `connectionId, code?, reason?` | Close connection |
|
||
|
||
**Callback functions (export these to receive events):**
|
||
|
||
| 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 |
|
||
|
||
### 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 read-only access to library directories (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"
|
||
|
||
// Read a file from library 1
|
||
content, err := os.ReadFile("/libraries/1/Artist/Album/track.mp3")
|
||
|
||
// List directory contents
|
||
entries, err := os.ReadDir("/libraries/1/Artist")
|
||
```
|
||
|
||
> **Security:** Filesystem access is read-only and restricted to configured library paths only. Plugins cannot access other parts of the host filesystem.
|
||
|
||
**Usage (with generated SDK):**
|
||
|
||
Import the Go SDK (see [Scheduler](#scheduler) for `go.mod` setup). The `Library` struct is provided by the SDK:
|
||
|
||
```go
|
||
import "github.com/navidrome/navidrome/plugins/pdk/go/host"
|
||
|
||
// Get a specific library
|
||
resp, err := host.LibraryGetLibrary(1)
|
||
if err != nil {
|
||
// Handle error
|
||
}
|
||
library := resp.Result
|
||
|
||
// Get all libraries
|
||
resp, err := host.LibraryGetAllLibraries()
|
||
for _, lib := range resp.Result {
|
||
// lib is of type host.Library
|
||
fmt.Printf("Library: %s (%d songs)\n", lib.Name, lib.TotalSongs)
|
||
}
|
||
```
|
||
|
||
### 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 |
|
||
|
||
### SubsonicAPI
|
||
|
||
Call Navidrome's Subsonic API internally (no network round-trip).
|
||
|
||
**Manifest permission:**
|
||
|
||
```json
|
||
{
|
||
"permissions": {
|
||
"subsonicapi": {
|
||
"reason": "Access library data",
|
||
"allowedUsernames": ["user1", "user2"],
|
||
"allowAdmins": false
|
||
}
|
||
}
|
||
}
|
||
```
|
||
|
||
- `allowedUsernames` – Restrict which users the plugin can act as (empty = any user)
|
||
- `allowAdmins` – Whether plugin can call API as admin users (default: false)
|
||
|
||
**Host function:**
|
||
|
||
| Function | Parameters | Returns |
|
||
|--------------------|------------|---------------|
|
||
| `subsonicapi_call` | `uri` | JSON response |
|
||
|
||
**Usage:**
|
||
|
||
```go
|
||
// The URI must include the 'u' parameter with the username
|
||
response, err := SubsonicAPICall("getAlbumList2?type=random&size=10&u=username")
|
||
```
|
||
|
||
---
|
||
|
||
## 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
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
## Building Plugins
|
||
|
||
### Supported Languages
|
||
|
||
Plugins can be written in any language that Extism supports. Each language has its own PDK (Plugin Development Kit) that provides the APIs for I/O, logging, configuration, and HTTP requests. See the [Extism PDK documentation](https://extism.org/docs/concepts/pdk) for details.
|
||
|
||
We recommend:
|
||
|
||
- **Go** – Best experience with [TinyGo](https://tinygo.org/) and the [Go PDK](https://github.com/extism/go-pdk)
|
||
- **Rust** – Excellent performance with the [Rust PDK](https://github.com/extism/rust-pdk)
|
||
- **Python** – Experimental support via [extism-py](https://github.com/extism/python-pdk)
|
||
- **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 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 |
|
||
| `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 (HTTP, cache, etc.) |
|
||
|
||
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
|
||
|
||
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, 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 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
|
||
# 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.
|
||
|
||
### Using Host Service SDKs
|
||
|
||
Generated SDKs for calling host services are in `plugins/pdk/go/`, `plugins/pdk/python/` and `plugins/pdk/rust`.
|
||
|
||
**For Go plugins:** Import the SDK as a Go module:
|
||
|
||
```go
|
||
import "github.com/navidrome/navidrome/plugins/pdk/go/host"
|
||
```
|
||
|
||
Add 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
|
||
```
|
||
|
||
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
|
||
|
||
See [examples/](examples/) for complete working plugins:
|
||
|
||
| Plugin | Language | Capabilities | Host Services | Description |
|
||
|----------------------------------------------------------------|----------|---------------|--------------------------------------------|--------------------------------|
|
||
| [minimal](examples/minimal/) | Go | MetadataAgent | – | Basic structure example |
|
||
| [wikimedia](examples/wikimedia/) | Go | MetadataAgent | HTTP | Wikidata/Wikipedia integration |
|
||
| [coverartarchive-py](examples/coverartarchive-py/) | Python | MetadataAgent | HTTP | Cover Art Archive |
|
||
| [webhook-rs](examples/webhook-rs/) | Rust | Scrobbler | HTTP | HTTP webhooks |
|
||
| [nowplaying-py](examples/nowplaying-py/) | Python | Lifecycle | Scheduler, SubsonicAPI | Periodic now-playing logger |
|
||
| [library-inspector](examples/library-inspector-rs/) | Rust | Lifecycle | Library, Scheduler | Periodic library stats logging |
|
||
| [crypto-ticker](examples/crypto-ticker/) | Go | Lifecycle | WebSocket, Scheduler | Real-time crypto prices demo |
|
||
| [discord-rich-presence](examples/discord-rich-presence/) | Go | Scrobbler | HTTP, WebSocket, Cache, Scheduler, Artwork | Discord integration |
|
||
| [discord-rich-presence-rs](examples/discord-rich-presence-rs/) | Rust | Scrobbler | HTTP, WebSocket, Cache, Scheduler, Artwork | Discord integration (Rust) |
|
||
|
||
---
|
||
|
||
|
||
## 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 can only access library directories when explicitly granted the `library.filesystem` permission, and access is read-only
|
||
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. **SubsonicAPI Restrictions** – Configurable user/admin access controls
|
||
|
||
|
||
---
|
||
|
||
## 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 the `AutoReload` setting is disabled, Navidrome needs to be restarted to pick up plugin changes.
|
||
|
||
### Enabling/Disabling Plugins
|
||
|
||
Plugins can be enabled/disabled via the Navidrome UI. 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
|