navidrome/plugins/README.md
Deluan Quintão 5c5b849a4e
Some checks are pending
Pipeline: Test, Lint, Build / Test Go code (push) Waiting to run
Pipeline: Test, Lint, Build / Get version info (push) Waiting to run
Pipeline: Test, Lint, Build / Lint Go code (push) Waiting to run
Pipeline: Test, Lint, Build / Validate DB migrations (push) Waiting to run
Pipeline: Test, Lint, Build / Test Go code (Windows) (push) Waiting to run
Pipeline: Test, Lint, Build / Test JS code (push) Waiting to run
Pipeline: Test, Lint, Build / Lint i18n files (push) Waiting to run
Pipeline: Test, Lint, Build / Check Docker configuration (push) Waiting to run
Pipeline: Test, Lint, Build / Build (push) Blocked by required conditions
Pipeline: Test, Lint, Build / Build-1 (push) Blocked by required conditions
Pipeline: Test, Lint, Build / Build-2 (push) Blocked by required conditions
Pipeline: Test, Lint, Build / Build-3 (push) Blocked by required conditions
Pipeline: Test, Lint, Build / Build-4 (push) Blocked by required conditions
Pipeline: Test, Lint, Build / Build-5 (push) Blocked by required conditions
Pipeline: Test, Lint, Build / Build-6 (push) Blocked by required conditions
Pipeline: Test, Lint, Build / Build-7 (push) Blocked by required conditions
Pipeline: Test, Lint, Build / Build-8 (push) Blocked by required conditions
Pipeline: Test, Lint, Build / Build-9 (push) Blocked by required conditions
Pipeline: Test, Lint, Build / Build-10 (push) Blocked by required conditions
Pipeline: Test, Lint, Build / Push to GHCR (push) Blocked by required conditions
Pipeline: Test, Lint, Build / Push to Docker Hub (push) Blocked by required conditions
Pipeline: Test, Lint, Build / Cleanup digest artifacts (push) Blocked by required conditions
Pipeline: Test, Lint, Build / Build Windows installers (push) Blocked by required conditions
Pipeline: Test, Lint, Build / Package/Release (push) Blocked by required conditions
Pipeline: Test, Lint, Build / Upload Linux PKG (push) Blocked by required conditions
docs(plugins): sync plugins README with the implementation (#5912)
* docs(plugins): document the Matcher host service

The Matcher host service (matcher_matchsongs, permission 'matcher') was
missing from the README entirely. Add its section with permission example,
the matcher-requires-library rule, result ordering/scoping semantics, and
a Go usage example.

* docs(plugins): add missing nd_scrobbler_playback_report to Scrobbler

The Scrobbler capability has four required methods, but the README said
three and omitted nd_scrobbler_playback_report from the function table.
Add the table row and document the PlaybackReport input payload and its
possible playback states.

* docs(plugins): remove stale coverartarchive-as example row

The AssemblyScript coverartarchive-as example no longer exists in
plugins/examples/ (and no AssemblyScript code remains in the repo), so
drop its row from the examples table.

* docs(plugins): add Command Line Interface section

The 'navidrome plugin' CLI (list, info, validate, enable, disable, edit,
rescan) was undocumented; the README's only mention was a single inline
'plugin edit --write-access' reference, and it claimed plugins could only
be enabled/disabled via the UI. Add a CLI section with all subcommands
and the 'plugin edit' flags, and link it from Runtime Management.

* docs(plugins): add pdk and types packages to Go PDK table

The Go PDK package table was missing two of the packages that ship under
plugins/pdk/go/: 'types' (shared DTOs used across capabilities and host
services) and 'pdk' (the extism/go-pdk wrapper the README's own examples
already import).

* docs(plugins): correct capabilities and host services in examples table

Several example rows understated what the plugins implement: the
scheduler/websocket callback capabilities were omitted for nowplaying-py,
library-inspector-rs, crypto-ticker, and discord-rich-presence-rs, and
the Discord example also uses the Config host service.

* docs(plugins): state that scheduler/taskqueue permissions require callbacks

Manifest validation rejects a plugin that declares the scheduler or
taskqueue permission without exporting nd_scheduler_callback or
nd_task_execute, but the README worded both callbacks as soft
suggestions (TaskWorker was even labeled optional). Make the load-time
failure explicit in both capability sections.

* docs(plugins): document HTTP, WebSocket, and Task limits and defaults

Add the runtime limits plugins actually hit: HTTP default 10s timeout,
5-redirect cap with per-hop host re-validation, and 10MB response cap;
the 30s timeout on WebSocket callbacks; and the full task_createqueue
parameter list (delayMs, retentionMs) with queue defaults, retention
bounds, the 1MB payload cap, and task persistence across restarts.

* docs(plugins): include scrobbleRetriever in user-scoped authorization note

The ScrobbleRetriever host service added in #5795 is user-scoped like
subsonicapi and scrobbler, but the Security section's user-scoped
authorization item didn't list it.

* docs(plugins): clarify matcher user scoping requires the users permission

The Matcher usage example set opts.Username while the section's manifest
example declares only matcher+library; without the users permission no
users can be assigned to the plugin, so userAccess.resolve rejects the
call at runtime. Make the example unscoped and state that user scoping
requires the users permission with users assigned. Raised by Codex
review on PR #5912.
2026-08-08 22:25:48 -04:00

47 KiB
Raw Permalink Blame History

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:

Table of Contents


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:

  1. manifest.json Plugin metadata (name, author, version, permissions)
  2. plugin.wasm Compiled WebAssembly module with capability functions

Plugin Naming

Plugins are identified by their filename (without .ndp extension), not the manifest name field:

  • my-plugin.ndp → plugin ID is my-plugin
  • The manifest name is the display name shown in the UI

This allows users to have multiple instances of the same plugin with different configs by renaming the files.

The Manifest

Every plugin must include a manifest.json file. Example:

{
  "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.

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 users permission 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-authorize
  • scrobbler(retry_later) Temporary failure, Navidrome will retry
  • scrobbler(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.01.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's maxConcurrency
  • maxRetries Retries for a failed task (default 0); backoffMs (default 1000) doubles on each retry
  • delayMs Minimum delay between consecutive task starts, useful for rate limiting (default 0)
  • retentionMs How long finished tasks are kept (default 1 hour, min 1 minute, max 1 week)
  • Payloads are capped at 1MB

Usage:

// 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 to true to enable access to library directories, read-only unless an administrator grants write access (default: false)

Host functions:

Function Parameters Returns
library_getlibrary id Library metadata
library_getalllibraries (none) Array of library metadata

Library metadata:

{
  "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>:

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 matcher permission requires the library permission.

Host functions:

Function Parameters Returns
matcher_matchsongs songs, opts Array of matched tracks

The result has one entry per input song, in the same order; the entry for a song with no match is empty. Results are limited to the libraries the plugin (and the scoped user, if any) can access. Set opts.username to run the match as a specific user: their favorites and ratings inform tiebreaking, and the returned tracks carry their annotations. User scoping additionally requires the users 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 subsonicapi permission requires the users permission. Which users the plugin can act as is controlled through the Navidrome UI.

Host functions:

Function Parameters Returns
subsonicapi_call uri JSON response string
subsonicapi_callraw uri Content type + binary response

Usage:

// 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:

  1. Allow all users Enable the "Allow all users" toggle in the plugin settings
  2. Select specific users Choose individual users from the user list

If neither option is configured, the plugin cannot be enabled.

Host functions:

Function Parameters Returns
users_getusers Array of User objects
users_getadmins Array of admin Users

User object fields:

Field Type Description
userName string The user's unique username
name string The user's display name
isAdmin boolean Whether the user is an admin

Security: Sensitive fields like passwords, email addresses, and internal IDs are never exposed to plugins.

Usage:

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 scrobbleRetriever permission requires the users permission. Which users the plugin can act as is controlled through the Navidrome UI.

Host functions:

Function Parameters Returns
scrobbleretriever_getfirsttimestamp username Unix timestamp of oldest scrobble, or null
scrobbleretriever_getlasttimestamp username Unix timestamp of newest scrobble, or null
scrobbleretriever_getscrobbles username, options One page of scrobbles + options for the next page
scrobbleretriever_getscrobblecount username, options Number of scrobbles in the range

ScrobbleOptions fields (all optional):

Field Type Description
fromTimestamp int64 Start of the range (inclusive). Default: first scrobble
toTimestamp int64 End of the range (inclusive). Default: last scrobble
descending boolean Newest first. Default: oldest first
maxItems int Page size, capped at 5000 (the default)
offset int Managed by the host for pagination. Never set it manually

ScrobbleRef fields:

Field Type Description
id int64 Scrobble ID, unique even for duplicate submissions
mediaFileId string The media file that was scrobbled
submissionTime int64 Unix timestamp of the submission

Usage:

GetScrobbles returns one page plus the options to fetch the following page. Pass them back unchanged and repeat until they are nil:

opts := host.ScrobbleOptions{MaxItems: 500}
var all []host.ScrobbleRef
for {
    page, next, err := host.ScrobbleRetrieverGetScrobbles("username", opts)
    if err != nil {
        return err
    }
    all = append(all, page...)
    if next == nil {
        break // no more scrobbles
    }
    opts = *next
}

// Range boundaries and counts
first, err := host.ScrobbleRetrieverGetFirstTimestamp("username") // nil if no scrobbles
count, err := host.ScrobbleRetrieverGetScrobbleCount("username", host.ScrobbleCountOptions{
    FromTimestamp: first,
})

Note: The returned next options carry an adjusted fromTimestamp/toTimestamp, so keep a copy of your original options if you still need the range.


Configuration

Server Configuration

Enable plugins in navidrome.toml:

[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.
# 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:

  1. Host Allowlisting Only explicitly allowed hosts are accessible via HTTP/WebSocket
  2. Limited File System Plugins cannot create symlinks inside a mount and .. or absolute paths are rejected, though symlinks already present are followed. Library access requires the library.filesystem permission and is read-only unless an administrator grants write access; the storage permission grants a read-write directory private to the plugin
  3. No Network Listeners Plugins cannot bind ports
  4. Config Isolation Plugins only receive their own config section
  5. Memory Limits Controlled by the WebAssembly runtime
  6. User-Scoped Authorization Plugins with subsonicapi, scrobbleRetriever, or scrobbler capabilities can only access/receive events for users assigned to them through Navidrome's configuration
  7. Users Permission Plugins requesting user access must be explicitly configured with allowed users; sensitive data (passwords, emails) is never exposed

Runtime Management

Auto-Reload

With AutoReload = true, Navidrome watches the plugins folder and automatically detects when .ndp files are added, modified, or removed. When a plugin file changes, the plugin is disabled and its metadata is re-read from the archive.

If AutoReload is disabled, Navidrome needs to be restarted to pick up plugin changes.

Enabling/Disabling Plugins

Plugins can be enabled/disabled via the Navidrome UI or the navidrome plugin CLI. 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