navidrome/plugins/README.md
2026-01-12 08:16:06 -05:00

687 lines
20 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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.
## 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)
- [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
```go
package main
import (
"encoding/json"
"github.com/extism/go-pdk"
)
//go:wasmexport nd_manifest
func ndManifest() int32 {
manifest := map[string]string{
"name": "My Plugin",
"author": "Your Name",
"version": "1.0.0",
}
out, _ := json.Marshal(manifest)
pdk.Output(out)
return 0
}
func main() {}
```
### 2. Build with TinyGo
```bash
tinygo build -o my-plugin.wasm -target wasip1 -buildmode=c-shared .
```
### 3. Install
Copy `my-plugin.wasm` 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 a WebAssembly (`.wasm`) file that:
1. **Exports `nd_manifest`** – Returns JSON describing the plugin
2. **Exports capability functions** – Implements one or more capabilities
### Plugin Naming
Plugins are identified by their **filename** (without `.wasm` extension), not the manifest `name` field:
- `my-plugin.wasm` → 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 export `nd_manifest` returning JSON:
```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`
**Capabilities are auto-detected** from which functions your plugin exports. You don't declare them in the manifest.
---
## 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` | `{user_id, username}` | `{authorized}` | Check if user is authorized |
| `nd_scrobbler_now_playing` | See below | `{error?, error_type?}` | Send now playing |
| `nd_scrobbler_scrobble` | See below | `{error?, error_type?}` | Submit a scrobble |
**NowPlaying/Scrobble Input:**
```json
{
"user_id": "abc123",
"username": "john",
"track": {
"id": "track-id",
"title": "Song Title",
"album": "Album Name",
"artist": "Artist Name",
"album_artist": "Album Artist",
"duration": 180.5,
"track_number": 1,
"disc_number": 1,
"mbz_recording_id": "...",
"mbz_album_id": "...",
"mbz_artist_id": "..."
},
"timestamp": 1703270400
}
```
**Error Output (on failure):**
```json
{
"error": "error message",
"error_type": "not_authorized|retry_later|unrecoverable"
}
```
- `not_authorized` – User needs to re-authorize
- `retry_later` – Temporary failure, Navidrome will retry
- `unrecoverable` – Permanent failure, scrobble discarded
On success, return empty JSON `{}` or omit output entirely.
### 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](https://extism.org/docs/concepts/pdk). Navidrome controls which hosts are accessible.
**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` | `delay_seconds, payload, schedule_id?` | Schedule one-time callback |
| `scheduler_schedulerecurring` | `cron_expression, payload, schedule_id?` | Schedule recurring callback |
| `scheduler_cancelschedule` | `schedule_id` | Cancel a scheduled task |
**Callback function:**
```go
type SchedulerCallbackInput struct {
ScheduleID string `json:"schedule_id"`
Payload string `json:"payload"`
IsRecurring bool `json:"is_recurring"`
}
//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):**
Copy `plugins/host/go/nd_host_scheduler.go` to your plugin and use:
```go
// Schedule one-time task in 60 seconds
scheduleID, err := SchedulerScheduleOneTime(60, "my-payload", "")
// Schedule recurring task with cron expression (every hour)
scheduleID, err := SchedulerScheduleRecurring("0 * * * *", "hourly-task", "")
// Cancel a task
err := 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):**
Copy `plugins/host/go/nd_host_cache.go` to your plugin:
```go
// Cache a value for 1 hour
CacheSetString("api-response", responseData, 3600)
// Retrieve (check Exists before using Value)
result, err := CacheGetString("api-response")
if result.Exists {
data := result.Value
}
```
> **Note:** Cache is in-memory only and cleared on server restart.
### 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?, connection_id?` | Open a connection |
| `websocket_sendtext` | `connection_id, message` | Send text message |
| `websocket_sendbinary` | `connection_id, data` | Send binary data |
| `websocket_close` | `connection_id, code?, reason?` | Close connection |
**Callback functions (export these to receive events):**
| Function | Input | Description |
|----------------------------------|---------------------------------|----------------------------------|
| `nd_websocket_on_text_message` | `{connection_id, message}` | Text message received |
| `nd_websocket_on_binary_message` | `{connection_id, data}` | Binary message received (base64) |
| `nd_websocket_on_error` | `{connection_id, error}` | Connection error |
| `nd_websocket_on_close` | `{connection_id, 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):**
Copy `plugins/host/go/nd_host_library.go` to your plugin. You'll also need to add the `Library` struct definition:
```go
// Library represents a music library with metadata.
type Library struct {
ID int32 `json:"id"`
Name string `json:"name"`
Path string `json:"path,omitempty"`
MountPoint string `json:"mountPoint,omitempty"`
LastScanAt int64 `json:"lastScanAt"`
TotalSongs int32 `json:"totalSongs"`
TotalAlbums int32 `json:"totalAlbums"`
TotalArtists int32 `json:"totalArtists"`
TotalSize int64 `json:"totalSize"`
TotalDuration float64 `json:"totalDuration"`
}
// Get a specific library
resp, err := LibraryGetLibrary(1)
if err != nil {
// Handle error
}
library := resp.Result
// Get all libraries
resp, err := LibraryGetAllLibraries()
for _, lib := range resp.Result {
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 = "100MB" # 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 a JSON object with string 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 compiles to WebAssembly. We recommend:
- **Go** – Best experience with TinyGo
- **Rust** – Excellent performance
- **Python** – Via extism-py (experimental)
- **TypeScript** – Via extism-js
### Go with TinyGo (Recommended)
```bash
# Install TinyGo: https://tinygo.org/getting-started/install/
# Build
tinygo build -o my-plugin.wasm -target wasip1 -buildmode=c-shared .
```
### 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/schemas/metadata_agent.yaml \
--template go \
--path ./my-agent \
--name my-agent
# Build
cd my-agent && xtp plugin build
```
See [schemas/README.md](schemas/README.md) for available schemas.
### Using Host Service SDKs
Generated SDKs for calling host services are in `plugins/host/go/` and `plugins/host/python/`.
**For Go plugins:** Copy the needed `nd_host_*.go` file to your plugin directory.
**For Python plugins:** Copy functions from `nd_host_*.py` into your `__init__.py` (see comments in those files for extism-py limitations).
---
## Examples
See [examples/](examples/) for complete working plugins:
| Plugin | Language | Capabilities | Description |
|----------------------------------------------------------|----------|------------------------------------------|--------------------------------|
| [minimal](examples/minimal/) | Go | MetadataAgent | Basic structure example |
| [wikimedia](examples/wikimedia/) | Go | MetadataAgent | Wikidata/Wikipedia integration |
| [discord-rich-presence](examples/discord-rich-presence/) | Go | Scrobbler, Scheduler, WebSocket | Discord integration |
| [coverartarchive-py](examples/coverartarchive-py/) | Python | MetadataAgent | Cover Art Archive |
| [webhook-rs](examples/webhook-rs/) | Rust | Scrobbler | HTTP webhooks |
| [library-inspector](examples/library-inspector/) | Rust | Library, Scheduler | Periodic library stats logging |
---
## Security
Plugins run in a secure WebAssembly sandbox:
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 (Development)
With `AutoReload = true`, Navidrome watches the plugins folder and automatically reloads plugins when files change.
### Programmatic Control
Plugins can be enabled/disabled via the Navidrome UI or API. 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** – Plugin configuration is read at load time; changes require a reload
- **Cache persistence** – The in-memory cache is cleared when a plugin is unloaded