2025-12-31 17:06:31 -05:00
..
2025-12-31 17:06:30 -05:00

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

1. Create a minimal plugin

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

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:

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

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

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:

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:

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

{
  "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. Navidrome controls which hosts are accessible.

Manifest permission:

{
  "permissions": {
    "http": {
      "reason": "Fetch metadata from external API",
      "allowedHosts": ["api.example.com", "*.musicbrainz.org"]
    }
  }
}

Usage:

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:

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

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:

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

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

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

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

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

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

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

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

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

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

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

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

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

# 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 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/ for complete working plugins:

Plugin Language Capabilities Description
minimal Go MetadataAgent Basic structure example
wikimedia Go MetadataAgent Wikidata/Wikipedia integration
discord-rich-presence Go Scrobbler, Scheduler, WebSocket Discord integration
coverartarchive-py Python MetadataAgent Cover Art Archive
webhook-rs Rust Scrobbler HTTP webhooks
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