* feat(agents): retry-later error type with optional server delay
Add agents.ErrRetryLater and agents.RetryLaterError, which carries the
delay requested by an external service (e.g. ListenBrainz's
X-RateLimit-Reset-In). scrobbler.ErrRetryLater becomes an alias of the new
sentinel, so existing errors.Is checks and the plugin error-string protocol
keep working unchanged. Groundwork for honoring server-requested retry
delays across scrobbling, metadata agents and artwork.
Song.Equals tests moved to song_test.go to enable external test package.
* fix(scrobbler): honor backoff window and server-requested retry delay
ListenBrainz 429s were decoded into a typed error that classified as
unrecoverable, silently discarding the scrobble (a JSON-bodied 429 was
measured live). The client now maps any 429 to agents.RetryLaterError,
carrying X-RateLimit-Reset-In when present (capped at 1h). Last.fm error 29
(rate limit) is now retryable like 11/16. The buffer's drain loop no longer
lets wake signals bypass an active backoff window - new plays enqueue but
drain only when the window closes - and the wait honors the server delay
via max(backoff, retryIn).
* feat(agents): skip cooling-down agents in aggregate calls
When an agent reports retry-later, remember a per-agent cooldown deadline
(the server-requested delay, or 1 minute when unspecified) and skip that
agent in all aggregate metadata calls until it passes. A round that found
no data but skipped or saw a throttled agent returns ErrRetryLater instead
of ErrNotFound, so callers cannot mistake rate limiting for a definitive
'no data' answer.
* feat(artwork): honor server-requested retry delay when rescheduling
When an external image lookup fails with a retry-later error carrying a
delay (e.g. a 429 with X-RateLimit-Reset-In), the chain trace carries the
largest such hint back to the worker, which reschedules the item at
max(exponential backoff, server delay) instead of backoff alone.
* feat(plugins): retry-later with optional delay for scrobbler and agent plugins
Scrobbler plugins can now return scrobbler(retry_later:N) to request a
retry in N seconds (capped at 1h); the bare token keeps its old meaning.
Metadata-agent plugins, which had no error vocabulary at all, gain the
parallel agent(retry_later[:N]) token, mapped to agents.RetryLaterError so
the aggregate's cooldown and the artwork worker honor plugin throttling
the same way as built-in agents.
* fix: address whole-branch review findings for retry-later handling
Narrow the aggregate's throttled rule to the spec sentence: core.Agents returns
ErrRetryLater only when no agent answered at all (all skipped-cooling or
retry-later). An agent that does not implement the called method now returns an
internal errUnsupported instead of ErrNotFound, so it counts as "did not run" —
without that, the always-appended local agent would answer for biography, URL
and images and make ErrRetryLater unreachable.
Wire the consequence in core/external: a throttled round no longer stamps
ExternalInfoUpdatedAt (artist and album), so the empty result is not cached for
the TTL, and TopSongs maps ErrRetryLater to the same empty-200 the not-found
path already produced instead of a new client-facing error.
Move the Last.fm code-29 mapping into the client's central error construction so
every metadata path produces RetryLaterError, and map ListenBrainz's body-level
code 429 (sent with a non-429 HTTP status) the same way.
Clamp server- and plugin-requested delays in seconds before scaling to a
Duration, in all three parse sites: a header of 18446744074 wrapped past 2^64 and
came out as a 0.29s delay.
Also: extract the artwork worker's reschedule computation into retryDelay() and
cover both it and the trace RetryIn wiring with tests; collapse the double regex
call in mapScrobblerError; drop capabilities.ScrobblerErrorRetryLaterIn (ndpgen
never emits funcs, so plugin authors could not reach it); regenerate the PDKs so
MetadataAgentError reaches the Go and Rust SDKs; de-flake the cooldown tests
(long RetryIn for the skip case, separate expiry spec); and cover the max()
retry-delay aggregation across users in the scrobble buffer.
* refactor: dedupe retry-later parsing and simplify error collection
- Add agents.NewRetryLater and agents.RetryLaterFromSeconds, with a single
1h cap, replacing the parse+clamp+multiply logic and the maxRetryInSeconds
constant duplicated across listenbrainz, plugins and the agent adapter.
- Move HTTP header parsing to httpclient.RetryAfter, so the transport layer
owns it and stays domain-agnostic; drop retryInFromHeaders from the
ListenBrainz client. Covered by a new Ginkgo table in that package.
- Collapse the two near-identical plugin retry_later regexes into one
parseRetryLater(prefix, msg) shared by the agent and scrobbler adapters.
- Fold the duplicated noteRetryIn snippet from fetchArtistImage and
fetchAlbumImage into recordAgent, which already branched on the same
isTransientExternal condition.
- Replace the atomic.Bool + note() closure in populateArtistInfo with
errgroup's own error collection; the group carries no context, so a
returned error does not cancel its siblings.
- Reuse recoveringScrobbler for the per-user delay test instead of a third
double, and switch fakeScrobbler's mutex-guarded error to the
atomic.Pointer idiom already used in the same package.
* refactor(listenbrainz): keep rate-limit header parsing in the adapter
The X-RateLimit-Reset-In header is ListenBrainz's own convention, not a
shared one: Last.fm sends no rate-limit headers at all and reports its
limit as a body code, and no other integration in tree sends Retry-After.
A parser in utils/httpclient implied a uniformity across services that
does not exist, so it moves back next to the only client that can know
which header its service sends.
* refactor(agents): collapse the retry-later sentinel and error into one type
ErrRetryLater is now the zero-delay RetryLaterError rather than a separate
errors.New value, so errors.Is and errors.AsType both match the sentinel and
every delay-carrying variant. That removes the trap where a bare sentinel
silently skipped the AsType path, and lets every consumer read the delay off
the error directly: the RetryIn accessor and the two constructors are gone,
with the policy cap applied where untrusted input is parsed.
* refactor(agents): split the cooldown store from the per-dispatch tally
The cooldown map and mutex become a cooldowns value with active/park, holding
no knowledge of errors; agentAttempts records one dispatch's outcomes and owns
the classification that noteAgentError used to hide behind a bool. The three
dispatch loops now touch a single object: skip folds the cooldown check and the
throttled flag into one call, so the store never appears in the loops.
* refactor(agents): share one dispatch loop between the agent call helpers
callAgentMethod and callAgentSliceMethod ran identical loops, differing only in
how they test a result for emptiness: a slice cannot be compared against its
zero value, so the two could not share a constraint. Both now delegate to
callAgent, which takes that test as a parameter. Keeping the loop in one place
matters more than the lines saved: it holds the cooldown skip, the attempt
recording and the empty-dispatch verdict, and a fix applied to one copy but not
the other would be silent.
* test: cover the two retry-later paths a mutation could break silently
Both gaps were proven, not guessed: making the artwork worker pass 0 instead
of the collected hint left all 386 specs green, and replacing the default
agent cooldown with 0 left the agents suite green. The worker test drives a
throttled image agent through drain and asserts the persisted retry_at, and
the cooldown test parks an agent that asked to be retried without naming a
delay, which is what Last.fm does on every rate limit.
* refactor(artwork): carry the external failure as an error, not a flag plus a trace field
The retry delay was riding on ChainTrace, a diagnostic that gets persisted, while
the very same signal — an external source faulted — already travelled by value as
resolution.extError. That was two mechanisms for one idea, and it put control-flow
state inside a serializable trace.
resolution.extError and chainState.extErr become the error itself, so a caller
checks err != nil for the fault and errors.AsType for the delay the provider asked
for. The agent loops return that error last, per convention, and longerRetry keeps
whichever failure wants the longer wait. ChainTrace goes back to holding only steps
and no longer imports core/agents.
* fix(artwork): check the resolve error before reading its resolution
Reading res.extError before the err check was safe only because every error path
in resolve returns a bare resolution{}; a future path returning a partly-filled
one would have been read silently. The failure path now returns no delay
explicitly.
* test(artwork): assert the delay acquire reports, not just its downstream effect
acquire's retry delay was only covered through the worker's persisted retry_at,
one layer away from where the value is computed. Both outcomes are now pinned at
the processor: a plain failure asks for nothing, a throttled provider's delay is
passed through.
* refactor: share the retry-seconds parse and drop the backoff deadline arithmetic
The clamp-before-scaling invariant lived in two parsers and was independently
re-tested in three files with the same magic number; a fix applied to one copy
would have left the others wrapping a huge value down to a fraction of a second.
It moves to agents.ParseRetryIn.
The buffer tracked an absolute retryDeadline only to re-arm a timer that was
already armed for the same instant; a backingOff flag says the same thing without
the arithmetic. The plugin token regex now carries its capability in the pattern
instead of capturing and comparing, so another capability's token in the same
message cannot mask it. resolution.extError becomes extErr, matching its
chainState counterpart.
* fix(agents): keep the longer cooldown when parks overlap
Calls to one agent overlap, so a short cooldown could land after a long one
started and cut it short. park now keeps whichever deadline is later, matching
the rule longerRetry already applies on the artwork side. No in-tree provider
can currently produce two different delays for the same agent, so this is
hardening rather than a fix for observed behaviour.
* fix(agents): parse the retry delay at a fixed width
strconv.Atoi parses into the native int, so on the 32-bit targets we ship
(linux/386, windows/386, three ARM variants) a delay above MaxInt32 seconds
overflowed and became unspecified instead of being capped. No provider sends a
68-year delay, so this is not user-visible, but the overflow tests asserted the
cap and would have failed on those architectures, where tests never run.
* fix(plugins): anchor the retry_later regex to a word boundary
Prevents a superstring like useragent(retry_later) from matching the
agent capability token.
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, 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 – Core concepts and architecture
- Plugin Development Kits (PDKs) – Language-specific libraries for writing plugins
- Go PDK – Recommended for Go plugins with TinyGo
- Rust PDK – For Rust plugins
- Python PDK – Experimental Python support
- JavaScript PDK – For TypeScript/JavaScript plugins
Table of Contents
- Quick Start
- Plugin Basics
- Capabilities
- Host Services
- Configuration
- Command Line Interface
- Building Plugins
- Examples
- Security
Quick Start
1. Create a minimal plugin
Create main.go:
package main
import "github.com/extism/go-pdk"
func main() {}
// Implement your capability functions here
Create manifest.json:
{
"name": "My Plugin",
"author": "Your Name",
"version": "1.0.0"
}
2. Build with TinyGo and package as .ndp
# 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:
[Plugins]
Enabled = true
Folder = "/path/to/plugins"
Plugin Basics
What is a Plugin?
A Navidrome plugin is an .ndp package file (zip archive) containing:
manifest.json– Plugin metadata (name, author, version, permissions)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 ismy-plugin- The manifest
nameis 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:
{
"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) and an optional JSONForms UI schema for rendering in the Navidrome web UI:
{
"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:
Agents = "lastfm,spotify,my-plugin"
Example (using Go PDK package):
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: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
userspermission in their manifest. Scrobble events are only sent for users assigned to the plugin through Navidrome's configuration.
Manifest permission:
{
"permissions": {
"users": {
"reason": "Receive scrobble events for users assigned to this plugin"
}
}
}
NowPlaying/Scrobble Input:
{
"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:
{
"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-authorizescrobbler(retry_later)– Temporary failure, Navidrome will retryscrobbler(unrecoverable)– Permanent failure, scrobble discarded
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 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 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 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) 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:
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:
{
"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:
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 function to receive events.
Manifest permission:
{
"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:
// 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:
{
"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:
// 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:
{
"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:
// 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:
{
"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:
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. 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 capability function.
Manifest permission:
{
"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'smaxConcurrencymaxRetries– Retries for a failed task (default 0);backoffMs(default 1000) doubles on each retrydelayMs– 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:
// 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 functions to receive events.
Manifest permission:
{
"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:
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:
{
"permissions": {
"library": {
"reason": "Access library metadata for analysis",
"filesystem": false
}
}
}
filesystem– Set totrueto 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:
{
"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
pathandmountPointfields are only included whenfilesystem: trueis 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>:
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:
// 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:
{
"permissions": {
"matcher": {
"reason": "Resolve external recommendations to library tracks"
},
"library": {
"reason": "Required by the matcher permission"
}
}
}
Important: The
matcherpermission requires thelibrarypermission.
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 permission, with users assigned to the plugin.
Usage:
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:
{
"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:
url, err := host.ArtworkGetAlbumUrl("album-id", 300)
SubsonicAPI
Call Navidrome's Subsonic API internally (no network round-trip).
Manifest permission:
{
"permissions": {
"subsonicapi": {
"reason": "Access library data"
},
"users": {
"reason": "Access user information for SubsonicAPI authorization"
}
}
}
Important: The
subsonicapipermission requires theuserspermission. 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:
// 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:
// 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:
{
"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:
- Allow all users – Enable the "Allow all users" toggle in the plugin settings
- 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:
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:
{
"permissions": {
"scrobbleRetriever": {
"reason": "Sync scrobble history to an external service"
},
"users": {
"reason": "Access user information for scrobble retrieval"
}
}
}
Important: The
scrobbleRetrieverpermission requires theuserspermission. 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:
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
nextoptions carry an adjustedfromTimestamp/toTimestamp, so keep a copy of your original options if you still need the range.
Configuration
Server Configuration
Enable plugins in navidrome.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:
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 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 and the Go PDK. Familiar syntax, excellent stdlib support.
- Rust – Best for performance-critical plugins. Smallest binaries, excellent type safety. Uses the Rust PDK.
- Python – Best for rapid prototyping. Experimental support via extism-py. Note some limitations compared to compiled languages.
- TypeScript – Experimental support via extism-js.
Go with TinyGo (Recommended)
# 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:
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/ for complete usage patterns.
Rust
# 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
# 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:
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:
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 for detailed documentation.
Python (with extism-py)
# 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:
# 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 for available schemas and scaffolding examples.
Examples
See examples/ for complete working plugins:
| Plugin | Language | Capabilities | Host Services | Description |
|---|---|---|---|---|
| minimal | Go | MetadataAgent | – | Basic structure example |
| wikimedia | Go | MetadataAgent | HTTP | Wikidata/Wikipedia integration |
| coverartarchive-py | Python | MetadataAgent | HTTP | Cover Art Archive |
| webhook-rs | Rust | Scrobbler | HTTP | HTTP webhooks |
| nowplaying-py | Python | Lifecycle, SchedulerCallback | Scheduler, SubsonicAPI | Periodic now-playing logger |
| library-inspector-rs | Rust | Lifecycle, SchedulerCallback | Library, Scheduler | Periodic library stats logging |
| crypto-ticker | Go | Lifecycle, SchedulerCallback, WebSocketCallback | WebSocket, Scheduler | Real-time crypto prices demo |
| 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 and the Wazero runtime:
- Host Allowlisting – Only explicitly allowed hosts are accessible via HTTP/WebSocket
- 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 thelibrary.filesystempermission and is read-only unless an administrator grants write access; thestoragepermission grants a read-write directory private to the plugin - No Network Listeners – Plugins cannot bind ports
- Config Isolation – Plugins only receive their own config section
- Memory Limits – Controlled by the WebAssembly runtime
- User-Scoped Authorization – Plugins with
subsonicapi,scrobbleRetriever, orscrobblercapabilities can only access/receive events for users assigned to them through Navidrome's configuration - 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. 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