Deluan 415eac5399 feat(plugins): integrate logs
Signed-off-by: Deluan <deluan@navidrome.org>
2025-12-31 17:06:28 -05:00
..
2025-12-31 17:06:28 -05:00
2025-12-31 17:06:28 -05:00
2025-12-31 17:06:28 -05:00

Navidrome Plugin System

Navidrome supports WebAssembly (Wasm) plugins for extending functionality. Plugins are loaded from the configured plugins folder and can provide additional metadata agents for fetching artist/album information.

Configuration

Enable plugins in your navidrome.toml:

[Plugins]
Enabled = true
Folder = "/path/to/plugins"   # Default: DataFolder/plugins

# Plugin-specific configuration (passed to plugins via Extism Config)
[PluginConfig.my-plugin]
api_key = "your-api-key"
custom_option = "value"

Plugin Structure

A Navidrome plugin is a WebAssembly (.wasm) file that:

  1. Exports nd_manifest: Returns a JSON manifest describing the plugin
  2. Exports capability functions: Implements the functions for its declared capabilities

Plugin Naming

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

  • Users to resolve name conflicts by renaming files
  • Multiple instances of the same plugin with different names/configs
  • Simple, predictable naming

Example: my-musicbrainz.wasm → plugin name is my-musicbrainz

Plugin Manifest

Plugins must export an nd_manifest function that returns JSON:

{
  "name": "My Plugin",
  "author": "Author Name",
  "version": "1.0.0",
  "description": "Plugin description",
  "website": "https://example.com",
  "permissions": {
    "http": {
      "reason": "Fetch metadata from external API",
      "allowedHosts": ["api.example.com", "*.musicbrainz.org"]
    }
  }
}

Note: Capabilities are auto-detected based on which functions the plugin exports. You don't need to declare them in the manifest.

Capabilities

Capabilities are automatically detected by examining which functions a plugin exports. There's no need to declare capabilities in the manifest.

MetadataAgent

Provides artist and album metadata. A plugin has this capability if it exports 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

Scrobbler

Provides scrobbling (listening history) integration with external services. A plugin has this capability if it exports 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 NowPlaying Input {error?, error_type?} Send now playing notification
nd_scrobbler_scrobble See Scrobble Input {error?, error_type?} Submit a scrobble

NowPlaying Input

{
  "user_id": "string",
  "username": "string",
  "track": {
    "id": "string",
    "title": "string",
    "album": "string",
    "artist": "string",
    "album_artist": "string",
    "duration": 180.5,
    "track_number": 1,
    "disc_number": 1,
    "mbz_recording_id": "string",
    "mbz_album_id": "string",
    "mbz_artist_id": "string",
    "mbz_release_group_id": "string",
    "mbz_album_artist_id": "string",
    "mbz_release_track_id": "string"
  },
  "position": 30
}

Scrobble Input

{
  "user_id": "string",
  "username": "string",
  "track": { /* same as NowPlaying */ },
  "timestamp": 1703270400
}

Scrobbler Output

The output for nd_scrobbler_now_playing and nd_scrobbler_scrobble is optional on success. If there is no error, the plugin can return nothing (empty output).

On error, return:

{
  "error": "error message",
  "error_type": "not_authorized|retry_later|unrecoverable"
}

Error types:

  • not_authorized: User needs to re-authorize with the scrobbling service
  • retry_later: Temporary failure, Navidrome will retry the scrobble later
  • unrecoverable: Permanent failure, scrobble will be discarded

Example Scrobbler Plugin

package main

import (
    "encoding/json"
    "github.com/extism/go-pdk"
)

type AuthInput struct {
    UserID   string `json:"user_id"`
    Username string `json:"username"`
}

type AuthOutput struct {
    Authorized bool `json:"authorized"`
}

type ScrobblerOutput struct {
    Error     string `json:"error,omitempty"`
    ErrorType string `json:"error_type,omitempty"`
}

//go:wasmexport nd_scrobbler_is_authorized
func ndScrobblerIsAuthorized() int32 {
    var input AuthInput
    if err := pdk.InputJSON(&input); err != nil {
        pdk.SetError(err)
        return 1
    }
    
    // Check if user is authorized with your scrobbling service
    // This could check a session key stored in plugin config
    sessionKey, hasKey := pdk.GetConfig("session_key_" + input.UserID)
    
    output := AuthOutput{Authorized: hasKey && sessionKey != ""}
    if err := pdk.OutputJSON(output); err != nil {
        pdk.SetError(err)
        return 1
    }
    return 0
}

//go:wasmexport nd_scrobbler_scrobble
func ndScrobblerScrobble() int32 {
    // Read input, send to external service...
    
    output := ScrobblerOutput{ErrorType: "none"}
    if err := pdk.OutputJSON(output); err != nil {
        pdk.SetError(err)
        return 1
    }
    return 0
}

func main() {}

Scrobbler plugins are automatically discovered and used by Navidrome's PlayTracker alongside built-in scrobblers (Last.fm, ListenBrainz).

Developing Plugins

Plugins can be written in any language that compiles to WebAssembly. We recommend using the Extism PDK for your language.

Go Example

package main

import (
    "encoding/json"
    "github.com/extism/go-pdk"
)

type Manifest struct {
    Name    string `json:"name"`
    Author  string `json:"author"`
    Version string `json:"version"`
}

//go:wasmexport nd_manifest
func ndManifest() int32 {
    manifest := Manifest{
        Name:    "My Plugin",
        Author:  "Me",
        Version: "1.0.0",
    }
    out, _ := json.Marshal(manifest)
    pdk.Output(out)
    return 0
}

type ArtistInput struct {
    ID   string `json:"id"`
    Name string `json:"name"`
}

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..."}
    if err := pdk.OutputJSON(output); err != nil {
        pdk.SetError(err)
        return 1
    }
    return 0
}

func main() {}

Build with TinyGo:

tinygo build -o my-plugin.wasm -target wasip1 -buildmode=c-shared ./main.go

Using HTTP

Plugins can make HTTP requests using the Extism PDK. The host controls which hosts are allowed via the permissions.http.allowedHosts manifest field.

//go:wasmexport nd_get_artist_biography
func ndGetArtistBiography() int32 {
    var input ArtistInput
    pdk.InputJSON(&input)
    
    req := pdk.NewHTTPRequest(pdk.MethodGet, 
        "https://api.example.com/artist/" + input.Name)
    resp := req.Send()
    
    // Process response...
    pdk.Output(resp.Body())
    return 0
}

Using Configuration

Plugins can read configuration values passed from navidrome.toml:

apiKey, ok := pdk.GetConfig("api_key")
if !ok {
    pdk.SetErrorString("api_key configuration is required")
    return 1
}

Runtime Loading

Navidrome supports loading, unloading, and reloading plugins at runtime without restarting the server.

Auto-Reload (File Watcher)

Enable automatic plugin reloading when files change:

[Plugins]
Enabled = true
AutoReload = true   # Default: false

When enabled, Navidrome watches the plugins folder and automatically:

  • Loads new .wasm files when they are created
  • Reloads plugins when their .wasm file is modified
  • Unloads plugins when their .wasm file is removed

This is especially useful during plugin development - just rebuild your plugin and it will be automatically reloaded.

Programmatic API

The plugin Manager exposes methods for runtime plugin management:

manager := plugins.GetManager()

// Load a new plugin (file must exist at <plugins_folder>/<name>.wasm)
err := manager.LoadPlugin("my-plugin")

// Unload a running plugin
err := manager.UnloadPlugin("my-plugin")

// Reload a plugin (unload + load)
err := manager.ReloadPlugin("my-plugin")

Notes on Runtime Loading

  • In-flight requests: When a plugin is unloaded, existing plugin instances continue working until their request completes. New requests use the reloaded version.
  • Config changes: Plugin configuration (PluginConfig.<name>) is read at load time. Changes require a reload.
  • Failed reloads: If loading fails after unloading, the plugin remains unloaded. Check logs for errors.

Security

Plugins run in a secure WebAssembly sandbox with these restrictions:

  1. Host Allowlisting: Only hosts listed in permissions.http.allowedHosts are accessible
  2. No File System Access: Plugins cannot access the file system
  3. No Network Listeners: Plugins cannot bind ports or create servers
  4. Config Isolation: Plugins receive only their own config section
  5. Memory Limits: Configurable via Extism

Using Plugins with Agents

To use a plugin as a metadata agent, add it to the Agents configuration:

Agents = "lastfm,spotify,my-plugin"  # my-plugin.wasm must be in the plugins folder

Plugins are tried in the order specified, just like built-in agents.