navidrome/plugins
Deluan Quintão 0e5b9e3263
feat(plugins): share plugin DTOs via a types package (#5655)
* refactor(plugins): remove Python PDK generation from ndpgen

* feat(plugins): parse Go type aliases distinctly in ndpgen

* feat(plugins): resolve shared-type aliases against a registry in ndpgen

* fix(plugins): resolve host-service shared aliases package-wide

Mirror the capability approach in ParseDirectoryWithShared: do a first
pass over all package files to build a package-wide alias map, then pass
it into parseServiceFile so that a shared-type alias declared in a sibling
file is visible when resolving types in the service interface file.

Add a focused test that writes the alias in one file and the hostservice
in another, confirming RED before the fix and GREEN after. Also
strengthens the existing Task 3 test with an ArtistRef.Target assertion.

* feat(plugins): add ndpgen -shared-types mode for the Go types package

* feat(plugins): generate the nd-pdk-types Rust crate from -shared-types

* feat(plugins): inject types import and emit deprecated aliases in Go output

* feat(plugins): emit deprecated Rust aliases to the shared types crate

* feat(plugins): inline shared-type shapes into XTP schemas

* feat(plugins): add nd-pdk-types crate and wire dependents

* feat(plugins): move shared capability types to plugins/types with deprecated aliases

* fix(plugins): point Rust deprecated-alias note at the replacement type

* fix(plugins): include shared aliases in KnownStructs so Rust fields keep their type

Capability.KnownStructs() and Service.KnownStructs() previously only
registered names from .Structs. After the shared-types migration, types
like ArtistRef/TrackInfo/SongRef live in .SharedAliases instead, so
ToRustTypeWithStructs could not find them and fell back to serde_json::Value
for every struct field referencing a shared type.

Add the shared-alias names to the knownStructs map in both methods.
Regenerate the Rust capability files; track/song/artist fields now render
as their named types (TrackInfo, SongRef, ArtistRef, etc.).

Add a regression test that verifies a struct field whose type is only in
SharedAliases renders as the named type and not serde_json::Value.

* docs(plugins): remove stale Python references from ndpgen and plugins READMEs

ndpgen no longer has a -python flag; remove it from the usage synopsis,
flags table, and defaults note in ndpgen/README.md. Delete the "Python
Client Library" section that described its output.

plugins/README.md referenced plugins/pdk/python/host/ (deleted) as the
source for Python host-service stubs. Remove that paragraph; Python plugins
still work via the XTP-schema / extism-py path (see examples/*-py).

* refactor(plugins): dedupe ndpgen helpers and tidy shared-type codegen

* docs(plugins): restore Python as a supported XTP schema target

The ndpgen-generated Python PDK was removed, but the XTP YAML schemas are
language-neutral and the XTP CLI still generates Python bindings from them
(as the extism-py examples demonstrate). Only the ndpgen Python output was
dropped, not Python support itself.

* test(plugins): use the shared types package in test plugins

The test fixtures referenced the now-deprecated capability aliases
(sonicsimilarity.SongRef, metadata.ArtistRef/SongRef). Point them at the
canonical types package so our own fixtures don't depend on symbols slated
for removal.

* refactor(plugins): use the shared types package in host adapters

Replace deprecated capabilities.TrackInfo, capabilities.ArtistRef, and
capabilities.SongRef aliases with the canonical types.TrackInfo,
types.ArtistRef, and types.SongRef from plugins/types.

* fix(plugins): reference shared types by canonical path in generated Rust

Previously the generator emitted `pub field: SongRef` (the local deprecated
alias) for struct fields whose type came from SharedAliases. Refactored
ToRustTypeWithStructs into a private toRustType that accepts a shared map,
and added ToRustTypeWithShared which resolves shared-alias names to their
canonical nd_pdk_types::X path before falling through to the knownStructs
check. Both rustCapabilityFuncMap and rustFuncMap now build the shared map
from SharedAliases and use it for fieldRustType, so the generated capability
files reference nd_pdk_types::SongRef / nd_pdk_types::TrackInfo directly.
The deprecated pub type aliases remain in place as the external back-compat
surface. Deprecation warning count from cargo build drops to 0.

* fix(examples): implement missing Scrobbler.playback_report in Rust examples

The Scrobbler trait gained a playback_report method but the two Rust example
plugins (webhook-rs and discord-rich-presence-rs) were not updated, causing
E0046 compile errors. Added the missing fn playback_report to both: webhook-rs
logs and returns Ok(()) mirroring its now_playing handler; discord-rich-presence-rs
is a no-op since Discord presence does not need playback reports. make all-rust
now exits 0.

* refactor(plugins): point Rust deprecation notes at the nd_pdk::types umbrella path

Plugin authors depend on the nd-pdk umbrella crate, which re-exports
nd_pdk_types as 'types', so the migration target they should type is
nd_pdk::types::X. The alias target stays nd_pdk_types::X (the real path
inside nd-pdk-capabilities).

* fix(plugins): error when a shared-type alias can't be resolved against the registry

* refactor(plugins): parse each Go source file once in ndpgen

* fix(plugins): correct ndpgen review nits (flag name, unused dep, docs)

* refactor(plugins): drop the now-unused path param from parseServiceFile

* refactor(plugins): use shared types directly, rename TrackInfo to Track

Capability interfaces now reference the shared `types` package by qualified
name (types.Track, types.SongRef, types.ArtistRef) instead of the package-local
deprecated aliases, and the shared TrackInfo type is renamed to Track to match
its role as the plugin-facing projection of a library media file.

The deprecated bare aliases (scrobbler.TrackInfo, metadata.ArtistRef,
sonicsimilarity.SongRef, etc.) are kept as re-exports so existing plugins keep
compiling, with a deprecation warning steering them to the canonical types.

To support this, ndpgen now resolves qualified types.X references: it collects
them during type discovery, maps each used canonical type back to its declared
deprecated alias for re-export, emits nd_pdk_types::X paths in Rust, and names
the XTP schema components by their canonical type. Regenerated the Go and Rust
PDK and the XTP schemas, and added generator tests covering the qualified-ref
path. Also adds clarifying doc comments to the shared types.

* refactor(plugins): extract shared types selector into a named const

Replace the "types." string literal that detects and strips the shared types
package selector with a single sharedTypesPrefix constant across the ndpgen
generator (parser, types, generator, xtp_schema), giving the package one source
of truth for the selector.

Also restore the single reused scratch map (cleared each iteration) in the
resolveSharedAliases BFS instead of allocating a fresh map per shared-struct
field, matching the prior implementation.

Pure cleanup from a /simplify pass: regeneration produces byte-for-byte
identical Go, Rust, and XTP output.

* refactor(plugins): keep TrackInfo in the capability package for now

Move the track type back out of the shared plugins/types package: it is again
defined inline as TrackInfo in plugins/capabilities/scrobbler.go and referenced
directly by the scrobbler and lyrics capabilities, reverting the rename to
types.Track. The host helper is renamed back to mediaFileToTrackInfo and now
returns capabilities.TrackInfo. SongRef and ArtistRef stay in the shared types
package; TrackInfo keeps using types.ArtistRef for its artist lists.

This type is expected to be reshaped in upcoming work, so leaving it in the
capability package avoids churning the shared types twice. Regenerated the Go
and Rust PDK and the XTP schemas accordingly.

* fix(plugins): emit the Go types import for direct shared-type refs

ndpgen's Capability/Service.ImportsSharedTypes only reported a shared-types
dependency when a deprecated re-export alias (type X = types.X) was declared. A
struct field referencing the canonical form directly (e.g. types.SongRef) with
no such alias produced an empty SharedAliases slice, so the Go templates skipped
the import while still emitting fields/signatures using types.SongRef — leaving
generated PDK code for new shared DTOs uncompilable unless an otherwise
unnecessary alias was added.

ImportsSharedTypes now also returns true when any struct field references the
types. package by qualified name, via a new structsReferenceSharedTypes helper
that reuses collectReferencedTypes (so []types.X and map[...]types.X are covered
too).

* fix(plugins): preserve base64 encoding for shared byte fields in Rust

The Rust shared-types crate template rendered a []byte field as a plain Vec<u8>
without the base64_bytes serde override used by the capability/client templates.
Go's encoding/json serializes []byte as a base64 string, so a Rust plugin using
nd_pdk::types would have serialized an array of numbers instead of the wire
format the Go/server side expects.

GenerateSharedTypesRust now registers the base64_bytes partial and passes a
HasByteFields flag (new anyFieldIsByteSlice helper); types.rs.tmpl emits the
base64_bytes module and a #[serde(with = "base64_bytes")] attribute on []byte
fields, mirroring the capability template.

* fix(plugins): include directly-referenced shared types in XTP schemas

buildSchemas registered shared types into the schema components only by iterating
cap.SharedAliases, which records deprecated re-export aliases. A capability that
referenced a shared DTO solely as types.Foo (no declared alias) therefore never
got Foo into the component set, so the self-contained XTP schema rendered the
field as a generic object (or emitted a dangling $ref), breaking the direct
shared-type use case enabled by -shared.

resolveSharedAliases now also returns the resolved shapes of every used shared
type (alias or not); these are carried on the new Capability.SharedTypes field
and registered by buildSchemas alongside SharedAliases. Validated end-to-end with
the xtp CLI: a direct types.Foo reference now produces a proper component plus a
$ref, so xtp generates a typed struct instead of an untyped serde_json::Map.

* fix(plugins): resolve renamed shared aliases to canonical schema refs

When a deprecated alias renames its canonical type (e.g. type TrackInfo =
types.Track) and a capability field is typed with the alias name (TrackInfo),
buildProperty emitted a $ref to #/components/schemas/TrackInfo. Components are
keyed by the canonical name (Track), so no TrackInfo component was emitted,
leaving a dangling reference that crashes the xtp code generator.

buildSchemas now builds an alias->canonical map; buildProperty (and the slice
item path) resolves $ref targets through it, and a used alias name marks its
canonical component used so it is emitted. Validated with the xtp CLI: the
renamed-alias schema previously crashed xtp and now generates cleanly.

* fix(plugins): detect shared types used directly in method signatures

ImportsSharedTypes only inspected struct fields, so a capability method using a
shared type directly in its signature (e.g. types.SongRef as input/output rather
than inside a local struct) was not detected. The generated Go templates still
rendered the provider/export signatures with types.SongRef, so the capability
package omitted the types import and failed to compile; the same gap applied to
service params/returns.

ImportsSharedTypes now also scans capability method input/output types and
service method params/returns, via a typeReferencesSharedTypes helper that reuses
collectReferencedTypes (covering pointer/slice/map wrappers).

* fix(plugins): add base64 dependency to the shared Rust types crate

When a shared DTO has a []byte field, ndpgen emits the base64_bytes serde helper
and use base64::... imports into nd-pdk-types/src/lib.rs, but the crate manifest
declared only serde. In that case make gen produced a crate that failed to
compile with 'unresolved module base64'.

Add base64 = "0.22" (matching nd-pdk-capabilities) so the generated shared types
crate compiles whenever a []byte field is present. Verified by generating a
shared crate with a []byte field and confirming cargo check fails before and
passes after.

* fix(plugins): translate shared method types in generated Rust

A capability method using a shared DTO directly as input/output (e.g.
types.SongRef) was passed through rustOutputType unchanged, so the Rust template
emitted invalid trait and extism_pdk::Json<$crate::pkg::types.SongRef> signatures
that do not compile.

Method input/output types now resolve through the shared registry: trait
signatures use rustTraitType (shared -> nd_pdk_types::X, locals stay bare) and the
export macros use rustMethodType (fully qualified: shared -> nd_pdk_types::X,
primitives -> Rust, locals -> $crate::<pkg>::X). Verified end-to-end by compiling
a generated capability that takes types.SongRef directly against the real
nd-pdk-types crate.

* fix(plugins): canonicalize XTP export refs for renamed shared aliases

buildSchemas canonicalized alias-to-canonical references for struct-field $ref
targets, but buildExport built export input/output $refs straight from
fieldBaseType. A capability whose export used a renamed deprecated alias
directly (e.g. type TrackInfo = types.Track with NowPlaying(TrackInfo)) emitted
$ref: #/components/schemas/TrackInfo, while the component is emitted under the
canonical name Track — a dangling export reference.

Lift the alias-to-canonical map into GenerateSchema (buildAliasToCanonical) and
apply it to export refs via canonicalRefName, the same resolution already used
for field properties.

* fix(plugins): route shared macro types through $crate for plugin builds

When a capability method used a shared type directly, the generated export macro
named the type as nd_pdk_types::SongRef. The macro expands in the downstream
plugin crate, which depends on the umbrella nd-pdk crate and not on nd-pdk-types
directly, so that path is unresolvable there and the plugin fails to build.

rustMethodType (macro-facing) now emits $crate::types::X, and the generated
nd-pdk-capabilities lib.rs re-exports nd_pdk_types as types so $crate resolves
it. Trait signatures keep nd_pdk_types::X since they live in nd-pdk-capabilities,
which has the direct dependency. Verified end-to-end: a plugin crate depending
only on the umbrella that uses a capability with a direct types.X method now
compiles via the macro.

* fix(plugins): add nd-pdk-types dependency to the Rust host crate

When a host service uses a shared type, ndpgen emits nd_pdk_types::X into the
generated nd-pdk-host client wrappers, but the host crate's manifest did not
depend on nd-pdk-types, so the crate failed to compile with 'unresolved module
nd_pdk_types'. Host client wrappers are plain functions resolved in the host
crate's own context (not macros expanded downstream), so a direct dependency is
the right fix.

Add nd-pdk-types = { path = "../nd-pdk-types" } to nd-pdk-host, mirroring
nd-pdk-capabilities. Found while auditing all Rust paths against the realistic
crate topology after the capability-side $crate fix; verified by generating a
host service with a shared-type return and confirming cargo check fails before
and passes after.

* fix(plugins): resolve shared aliases in Rust host signatures

The Rust host client rendered method params and returns through
RustTypeWithStructs, which only consults KnownStructs. A host service using a
shared alias in a signature (e.g. type Track = types.Track plus
MatchSongs(...) ([]Track, error)) therefore emitted a bare Vec<Track>, but the
client template emits no Track alias or import, so the generated nd-pdk-host
crate did not compile. Only struct fields went through the shared map.

rustType/rustParamType now use the shared map too (RustTypeWithShared /
RustParamTypeWithShared), so an aliased param/return resolves to its canonical
nd_pdk_types::X path, matching field handling. Verified by generating a host
service returning a shared alias and confirming cargo check fails before and
passes after.
2026-06-29 21:20:33 -04:00
..
capabilities feat(plugins): share plugin DTOs via a types package (#5655) 2026-06-29 21:20:33 -04:00
cmd/ndpgen feat(plugins): share plugin DTOs via a types package (#5655) 2026-06-29 21:20:33 -04:00
examples feat(plugins): share plugin DTOs via a types package (#5655) 2026-06-29 21:20:33 -04:00
host feat(plugins): add NoFollowRedirects option to HTTPRequest 2026-03-20 18:16:07 -04:00
pdk feat(plugins): share plugin DTOs via a types package (#5655) 2026-06-29 21:20:33 -04:00
testdata feat(plugins): share plugin DTOs via a types package (#5655) 2026-06-29 21:20:33 -04:00
types feat(plugins): share plugin DTOs via a types package (#5655) 2026-06-29 21:20:33 -04:00
.gitignore feat(plugins): New Plugin System with multi-language PDK support (#4833) 2026-01-14 19:22:48 -05:00
capabilities.go refactor: run Go modernize (#5002) 2026-02-08 09:57:30 -05:00
capabilities_test.go feat(plugins): New Plugin System with multi-language PDK support (#4833) 2026-01-14 19:22:48 -05:00
capability_lifecycle.go feat(plugins): New Plugin System with multi-language PDK support (#4833) 2026-01-14 19:22:48 -05:00
config_validation.go feat(plugins): add JSONForms-based plugin configuration UI (#4911) 2026-01-19 20:51:00 -05:00
config_validation_test.go ci: run Go tests on Windows (#5380) 2026-04-19 13:16:47 -04:00
host_artwork.go feat(plugins): New Plugin System with multi-language PDK support (#4833) 2026-01-14 19:22:48 -05:00
host_artwork_test.go refactor(conf): replace eager dir creation with lazy Dir type (#5495) 2026-05-13 17:44:22 -03:00
host_cache.go feat(plugins): New Plugin System with multi-language PDK support (#4833) 2026-01-14 19:22:48 -05:00
host_cache_test.go refactor(conf): replace eager dir creation with lazy Dir type (#5495) 2026-05-13 17:44:22 -03:00
host_config.go feat(plugins): New Plugin System with multi-language PDK support (#4833) 2026-01-14 19:22:48 -05:00
host_config_test.go refactor(conf): replace eager dir creation with lazy Dir type (#5495) 2026-05-13 17:44:22 -03:00
host_httpclient.go feat(plugins): add NoFollowRedirects option to HTTPRequest 2026-03-20 18:16:07 -04:00
host_httpclient_test.go feat(plugins): add NoFollowRedirects option to HTTPRequest 2026-03-20 18:16:07 -04:00
host_kvstore.go refactor(conf): replace eager dir creation with lazy Dir type (#5495) 2026-05-13 17:44:22 -03:00
host_kvstore_test.go refactor: multiple syntax updates for Go 1.26 2026-05-19 18:02:36 -03:00
host_library.go feat(plugins): New Plugin System with multi-language PDK support (#4833) 2026-01-14 19:22:48 -05:00
host_library_test.go refactor: multiple syntax updates for Go 1.26 2026-05-19 18:02:36 -03:00
host_scheduler.go refactor(plugins): validate scheduler capability at load time 2026-02-26 16:30:50 -05:00
host_scheduler_test.go refactor(conf): replace eager dir creation with lazy Dir type (#5495) 2026-05-13 17:44:22 -03:00
host_subsonicapi.go feat(plugins): add SubsonicAPI CallRaw, with support for raw=true binary response for host functions (#4982) 2026-02-04 15:48:08 -05:00
host_subsonicapi_test.go refactor(conf): replace eager dir creation with lazy Dir type (#5495) 2026-05-13 17:44:22 -03:00
host_taskqueue.go chore: go fix 2026-05-28 22:13:05 -03:00
host_taskqueue_test.go chore: go fix 2026-05-28 22:13:05 -03:00
host_users.go feat(plugins): New Plugin System with multi-language PDK support (#4833) 2026-01-14 19:22:48 -05:00
host_users_test.go refactor(conf): replace eager dir creation with lazy Dir type (#5495) 2026-05-13 17:44:22 -03:00
host_websocket.go refactor: multiple syntax updates for Go 1.26 2026-05-19 18:02:36 -03:00
host_websocket_test.go refactor(conf): replace eager dir creation with lazy Dir type (#5495) 2026-05-13 17:44:22 -03:00
lyrics_adapter.go refactor(lyrics): single ParseLyrics entry point + all-format plugin lyrics (#5632) 2026-06-19 18:25:35 -04:00
lyrics_adapter_test.go refactor(lyrics): single ParseLyrics entry point + all-format plugin lyrics (#5632) 2026-06-19 18:25:35 -04:00
manager.go feat(cli): add a 'navidrome plugin' CLI for managing and inspecting plugins (#5682) 2026-06-28 13:26:32 -04:00
manager_cache.go feat(plugins): New Plugin System with multi-language PDK support (#4833) 2026-01-14 19:22:48 -05:00
manager_cache_test.go refactor: more warnings clean up 2026-05-20 17:43:12 -03:00
manager_call.go fix(plugins): don't recording metrics for not implemented plugin calls 2026-02-03 10:15:12 -05:00
manager_call_test.go test(plugins): speed up integration tests (~45% improvement) (#5137) 2026-03-02 16:18:30 -05:00
manager_loader.go feat(cli): add a 'navidrome plugin' CLI for managing and inspecting plugins (#5682) 2026-06-28 13:26:32 -04:00
manager_loader_test.go ci: run Go tests on Windows (#5380) 2026-04-19 13:16:47 -04:00
manager_plugin.go feat(plugins): add path to Scrobbler and Lyrics plugin TrackInfo (#5339) 2026-04-12 10:27:58 -04:00
manager_plugin_test.go feat(plugins): add path to Scrobbler and Lyrics plugin TrackInfo (#5339) 2026-04-12 10:27:58 -04:00
manager_sync.go feat(cli): add a 'navidrome plugin' CLI for managing and inspecting plugins (#5682) 2026-06-28 13:26:32 -04:00
manager_sync_test.go feat(cli): add a 'navidrome plugin' CLI for managing and inspecting plugins (#5682) 2026-06-28 13:26:32 -04:00
manager_test.go ci: run Go tests on Windows (#5380) 2026-04-19 13:16:47 -04:00
manager_watcher.go feat(cli): add a 'navidrome plugin' CLI for managing and inspecting plugins (#5682) 2026-06-28 13:26:32 -04:00
manager_watcher_test.go ci: run Go tests on Windows (#5380) 2026-04-19 13:16:47 -04:00
manifest-schema.json remove built-in Spotify integration (#5197) 2026-03-15 13:18:54 -04:00
manifest.go feat(cli): add a 'navidrome plugin' CLI for managing and inspecting plugins (#5682) 2026-06-28 13:26:32 -04:00
manifest_gen.go remove built-in Spotify integration (#5197) 2026-03-15 13:18:54 -04:00
manifest_test.go feat(cli): add a 'navidrome plugin' CLI for managing and inspecting plugins (#5682) 2026-06-28 13:26:32 -04:00
metadata_agent.go feat(plugins): share plugin DTOs via a types package (#5655) 2026-06-29 21:20:33 -04:00
metadata_agent_test.go feat(listenbrainz): match collaboration top-songs via all credited artist MBIDs (#5670) 2026-06-26 17:06:14 -04:00
migrate.go feat(plugins): add TTL support, batch operations, and hardening to kvstore (#5127) 2026-02-28 23:12:17 -05:00
migrate_test.go ci: run Go tests on Windows (#5380) 2026-04-19 13:16:47 -04:00
package.go feat(cli): add a 'navidrome plugin' CLI for managing and inspecting plugins (#5682) 2026-06-28 13:26:32 -04:00
package_test.go feat(cli): add a 'navidrome plugin' CLI for managing and inspecting plugins (#5682) 2026-06-28 13:26:32 -04:00
plugins_suite_test.go refactor(conf): replace eager dir creation with lazy Dir type (#5495) 2026-05-13 17:44:22 -03:00
plugins_suite_windows_test.go ci: run Go tests on Windows (#5380) 2026-04-19 13:16:47 -04:00
README.md feat(plugins): share plugin DTOs via a types package (#5655) 2026-06-29 21:20:33 -04:00
scrobbler_adapter.go feat(plugins): share plugin DTOs via a types package (#5655) 2026-06-29 21:20:33 -04:00
scrobbler_adapter_test.go feat(plugins): add PlaybackReport to scrobbler capability (#5452) 2026-05-02 16:14:53 -04:00
sonic_similarity_adapter.go feat(plugins): share plugin DTOs via a types package (#5655) 2026-06-29 21:20:33 -04:00
sonic_similarity_adapter_test.go feat(listenbrainz): match collaboration top-songs via all credited artist MBIDs (#5670) 2026-06-26 17:06:14 -04:00

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, experimental

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

Experimental Features

Plugins can opt-in to experimental WebAssembly features that may change or be removed in future versions. Currently supported:

  • threads Enables WebAssembly threads support (for plugins compiled with multi-threading)
{
  "experimental": {
    "threads": {
      "reason": "Required for concurrent audio processing"
    }
  }
}

Note: Experimental features may have compatibility or performance implications. Use only when necessary.


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 three 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

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
}

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. The method is optional — export it if your plugin uses the Task host service for background work.

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.

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

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

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)

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, ... 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

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

content, err := os.ReadFile("/libraries/1/Artist/Album/track.mp3")
entries, err := os.ReadDir("/libraries/1/Artist")

Security: Filesystem access is read-only and restricted to configured library paths only.

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)
}

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()

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.


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)

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
coverartarchive-as AssemblyScript MetadataAgent HTTP Cover Art Archive
webhook-rs Rust Scrobbler HTTP HTTP webhooks
nowplaying-py Python Lifecycle Scheduler, SubsonicAPI Periodic now-playing logger
library-inspector-rs Rust Lifecycle Library, Scheduler Periodic library stats logging
crypto-ticker Go Lifecycle WebSocket, Scheduler Real-time crypto prices demo
discord-rich-presence-rs Rust Scrobbler HTTP, WebSocket, Cache, Scheduler, Artwork 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 Read-only access to library directories, only when explicitly granted the library.filesystem permission
  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 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. 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