* refactor(lyrics): read sidecar files via library storage FS
Routes fromExternalFile reads through storage.For(mf.LibraryPath) instead
of os.Open on AbsolutePath, fixing sidecar reads for non-local backends.
UTF-16 LE/BE and BOM handling preserved via ioutils.UTF8Reader.
* refactor(lyrics): address review feedback on sidecar FS read
- Move blank local-storage import from sources.go into lyrics_suite_test.go
(the test suite already imports the local package for RegisterExtractor,
so local's init() runs; production binaries get the scheme via normal wiring)
- Fix misleading comment: model.ParseLyrics → model.ParseLyricsFile
- Replace what-comment with why-comment in BeforeSuite explaining the
log.Fatal guard that requires the no-op extractor registration
* test(lyrics): add subsonic e2e baseline for getLyrics endpoints
Establishes a behavioral baseline for getLyricsBySongId (v2 structured)
and getLyrics (legacy) before the lyrics parser refactor. Covers embedded
formats (LRC synced, plain text, TTML) and sidecar formats (LRC, SRT,
YAML), all isolated under a Lyrics/ fixture folder so the new fixtures
do not perturb existing test behavior beyond fixture counts.
Sidecar files are injected as raw &fstest.MapFile{Data: []byte(...)}
entries; the scanner skips non-audio extensions (.lrc, .srt, .yaml) so
they are invisible to scanning but reachable via the fake FS at request
time through fromExternalFile/storage.For.
Update album/artist/song counts in the album-list, multi-library, and
search3 empty-query tests to reflect the six new tracks (1 new artist,
1 new album, 6 new songs).
* test(lyrics): strengthen e2e lyrics baseline (lang assertions, rename helper)
Rename the local helper `main` to `firstLyric` to avoid collision with the
reserved-feeling built-in name. Add `Lang` assertions to both embedded and
sidecar DescribeTable entries, locking the current observed values: "xxx"
(ISO 639-2 "no language specified") for all embedded and LRC/SRT sidecars,
and "eng" for the YAML sidecar (which explicitly sets `language: eng`).
* feat(lyrics): detect Lyricsfile YAML in content-sniffing
* feat(plugins): content-sniff plugin lyrics for all formats
Replace model.ToLyrics (LRC/plain only) with model.ParseEmbedded so plugin
responses are content-sniffed for TTML, SRT, YAML, LRC, and plain text.
ParseEmbedded returns a LyricList, so the loop now flattens multiple tracks
per response entry.
The test-lyrics WASM plugin gains a "ttml" format mode (configured via
pdk.GetConfig) that returns a minimal TTML document; rebuilt with the
standard Go wasip1 toolchain (GOOS=wasip1 GOARCH=wasm). A new Ginkgo test
asserts Synced==true and the exact cue value, which the old plain-text path
could not produce.
GetLyrics doc comment updated to reflect content-sniffing; a later task will
retarget it to ParseLyrics once that function is introduced.
* test(plugins): validate plugin lyrics auto-detect across all formats
The test-lyrics WASM plugin now supports per-format modes via the
"format" config key: ttml, srt, yaml, lrc, and plain, in addition to
the existing default plain-text response. The plugin is rebuilt with the
standard Go wasip1 compiler.
lyrics_adapter_test.go gains a DescribeTable covering all five formats,
asserting both Synced (the discriminator that proves correct format
detection) and the exact line value. This validates the full
auto-detect chain (TTML → SRT → YAML/Lyricsfile → LRC → plain) end-to-end
through the real plugin → adapter → parser flow.
* refactor(lyrics): consolidate parsers into model.ParseLyrics
* refactor(lyrics): retarget legacy callers to model.ParseLyrics
Pin suffix to ".lrc" to preserve byte-identical output for stored
plain/LRC text that was previously handled by the now-removed ToLyrics.
* test(lyrics): fix lyrics tests after parser consolidation
- Rewrite the YAML-fallback test to assert the correct design: a
non-Lyricsfile .yaml sidecar returns as plain text and shadows
lower-priority sources (rather than falling through to .lrc).
- Add LibraryPath + relative Path split to the three subsonic tests
that read sidecar files via storage.For(), so they resolve against
the correct fixtures directory.
- Register a no-op extractor in api_suite_test.go BeforeSuite so
newLocalStorage does not fatal when storage.For is called during
sidecar-lyrics tests.
* test(lyrics): add per-format ParseLyrics benchmarks
Baseline measurements (count=2 runs) on M2:
BenchmarkParseLyrics_LRC-8 5725 178796 ns/op 49.78 MB/s 427877 B/op 523 allocs/op
BenchmarkParseLyrics_Plain-8 5425 230854 ns/op 32.44 MB/s 102508 B/op 16 allocs/op
BenchmarkParseLyrics_EnhancedLRC-8 1942 605893 ns/op 17.66 MB/s 860678 B/op 4256 allocs/op
BenchmarkParseLyrics_SRT-8 3249 373991 ns/op 25.91 MB/s 1113575 B/op 4407 allocs/op
BenchmarkParseLyrics_TTML-8 1483 813027 ns/op 13.86 MB/s 2198052 B/op 8665 allocs/op
BenchmarkParseLyrics_YAML-8 1700 678250 ns/op 13.01 MB/s 1235096 B/op 8288 allocs/op
BenchmarkParseLyrics_SniffTTML-8 1525 776482 ns/op 14.51 MB/s 2225448 B/op 8681 allocs/op
BenchmarkParseLyrics_SniffSRT-8 2528 451210 ns/op 21.48 MB/s 1157000 B/op 4422 allocs/op
BenchmarkParseLyrics_SniffYAML-8 1333 827152 ns/op 10.67 MB/s 1337195 B/op 8718 allocs/op
BenchmarkParseLyrics_SniffLRC-8 2820 413038 ns/op 21.55 MB/s 588934 B/op 1812 allocs/op
BenchmarkParseLyrics_SniffPlain-8 2968 409091 ns/op 18.31 MB/s 254470 B/op 1491 allocs/op
Content-sniff path overhead: 1.5–15% depending on format.
* test(lyrics): use real public-domain fixtures for parser benchmarks
Replace synthetic benchmark payloads with 'Auld Lang Syne' (Robert Burns,
1788, public domain) rendered into every supported format (LRC, plain,
enhanced LRC, SRT, TTML, Lyricsfile YAML) so the numbers reflect realistic
content. Same song across formats makes per-format cost comparable.
Baseline (Apple M-series, -benchmem, real fixtures):
LRC ~28 us/op 42 KB 147 allocs
Plain ~23 us/op 18 KB 22 allocs
EnhancedLRC ~37 us/op 51 KB 374 allocs
SRT ~52 us/op 139 KB 581 allocs
TTML ~119 us/op 276 KB 1227 allocs
YAML ~142 us/op 193 KB 1732 allocs
Sniff(LRC) ~47 us/op 57 KB 237 allocs
Sniff(TTML) ~122 us/op 282 KB 1250 allocs
Sniff(YAML) ~186 us/op 218 KB 1847 allocs
Fixtures in tests/fixtures/lyrics/.
* fix(lyrics): preserve [] (not null) for empty lyrics in backfill migration
ParseLyrics returns nil for zero-line input (whitespace-only stored
lyrics). json.Marshal(nil LyricList) produces null, violating the DB
invariant that media_file.lyrics uses [] for empty lyrics, never null.
Initialize to model.LyricList{} when ParseLyrics returns nil so the
marshalled result is always [].
* refactor(lyrics): unify parser dispatch and centralize empty-list invariant
Apply thermo-nuclear review findings (behavior-preserving):
- Replace the suffix switch + three single-use closure adapters
(parseTTMLKnown/parseSRTKnown + inline YAML closure) with a
bySuffix map of a single lyricParser(lang, contents) signature.
Normalize parseTTMLWithDefaultLang/parseSRTWithLanguage to that
(lang, contents) order so no adapter glue is needed.
- Collapse the parallel sniffLyrics engine into one parseFirstMatch
primitive shared by both the suffix and content-sniff paths
(sniffOrder candidate list). TTML stays gated via parseTTMLIfDocument
in sniff mode to avoid running the XML decoder on plain/LRC text.
- Add LyricList.MarshalJSON so empty/nil always serializes to [] (the
lyrics column invariant), in one canonical place. Delete the
migration's nil-guard, which the marshaler now subsumes.
Behavior verified unchanged: full suite + race + e2e green.
* refactor(lyrics): single registry drives both suffix dispatch and sniff order
Collapse the bySuffix map and sniffOrder slice into one ordered registry:
slice order is the content-sniff probe order, each row's suffixes drive
sidecar dispatch, and per-row bySuffix/byContent parsers preserve the
gated-TTML-when-sniffing distinction. One source of truth, no duplicated
parser references.
* refactor(lyrics): self-skipping parsers collapse the format table to one column
Move the TTML <tt>-document gate into parseTTMLWithDefaultLang itself (after
the encoding fixup, so UTF-16-declared docs are still recognized): non-TTML
content returns (nil, nil) to skip; a malformed <tt> document still errors.
SRT and Lyricsfile YAML already self-skip. With every structured parser
self-skipping, the format table drops to one {suffixes, parse} column named
lyricFormats — no bySuffix/byContent split, no separate sniff-only TTML gate.
Both the suffix and content-sniff paths share the same parser per format.
* refactor(lyrics): strip BOM once at ParseLyrics entry for all paths
Previously only the content-sniff path stripped the BOM; the suffix path
relied on its callers (fromExternalFile via UTF8Reader) having already
stripped it. That implicit contract was fragile — a caller passing raw
BOM-prefixed bytes with a suffix would reach the parsers with the BOM intact
(SanitizeText does not strip it). Strip once at entry so every path and
parser sees clean bytes regardless of caller. No-op for already-stripped
input.
* refactor(lyrics): trim verbose comments to essential why
* refactor(lyrics): move LRC parser to its own lyrics_lrc.go
Extract parseLRC, the enhanced-LRC helpers (parseEnhancedLine, adjustGroup,
stripEnhancedMarkers, shiftELRCCues), parseTime, and the LRC regexes from
lyrics.go into lyrics_lrc.go, with the parseLRC tests in lyrics_lrc_test.go.
This makes the layout symmetric — one file per format (lrc/srt/ttml/yaml) —
and leaves lyrics.go holding only shared types and cue normalization. All
moved symbols were already LRC-private; no behavior change.
* refactor(lyrics): collapse ParseLyrics suffix/sniff branches into one loop
Both modes differ only in which formats to try, so select candidates in a
single loop (all formats when sniffing, the suffix's own otherwise) and run
them through parseFirstMatch once. Drops the projected-slice make+index and
the ContainsFunc closure; unmatched suffixes yield no candidates and fall to
the plain-text floor, as before.
* refactor(lyrics): apply simplify-review cleanups
- stripBOM: bytes.TrimPrefix instead of []byte<->string round-trip (no alloc)
- ParseLyrics: pre-size the candidates slice
- move isTTMLDocument to lyrics_ttml.go beside its only caller (the dispatch
layer should hold no per-format knowledge)
* refactor(lyrics): simplify test descriptions for structured lyrics
Signed-off-by: Deluan <deluan@navidrome.org>
* refactor(lyrics): fold parseLyricsfile into lyricParser signature and rename file
- parseLyricsfile now matches the lyricParser signature directly (reads via
bytes.NewReader), removing the parseLyricsfileBytes adapter and the
string(contents) copy; the lyricFormats table references it directly.
- StructuredLyrics drops the vestigial LyricList{} init (json.Unmarshal
overwrites; MarshalJSON owns the empty->[] invariant).
- Rename lyricsfile.go -> lyrics_lyricsfile.go (and its test) to match the
lyrics_<format>.go convention used by lrc/srt/ttml.
* refactor(lyrics): move test-only parseTTML/parseSRT wrappers to test files
These zero-arg wrappers (defaulting lang to "xxx") had no production callers
after the consolidation — only the format tests used them. Move each beside
its tests so the production files carry no test-only code.
* build: exclude generated *_gen.go files from linting
The plugin host *_gen.go files (ndpgen output) were tripping the whitespace
linter despite carrying a generated marker. Exclude them by path so make lint
and the pre-push hook pass on untouched generated code.
* perf(lyrics): drop []byte/string round-trips in parsers
Apply code-review feedback to remove avoidable allocations in the lyrics
parsers. isTTMLDocument now takes []byte directly, so parseTTMLWithDefaultLang
no longer copies its buffer into a string before the TTML probe. parseSRTBlock
splits its block with strings.Split instead of converting to []byte and back
per line. ParseLyrics hoists strings.ToLower(suffix) out of the format loop.
No behavior change; the dropped len(scanner)==0 SRT guard was dead (strings.Split
never returns an empty slice, and the existing len(lines)==0 check still covers
empty input).
Signed-off-by: Deluan <deluan@navidrome.org>
* refactor(lyrics): colocate and unexport cue-normalization helpers
Move the cue-normalization machinery out of lyrics.go into a dedicated
lyrics_normalize.go (with lyrics_normalize_test.go), leaving lyrics.go to hold
just the shared lyric types and their methods. lyrics.go was mixing the domain
type/contract definitions with format-agnostic post-processing.
Unexport normalizeLyrics, normalizeCueLines, and normalizeLineTiming: they have
no callers outside the model package, so they should not be part of its public
API. NormalizeCueEnds stays exported because the Subsonic enhanced-lyrics
serializer (server/subsonic/lyrics.go) resolves cue ends per agent group while
building the response; that is the only legitimate cross-package caller.
Also includes a small no-op robustness tweak in parseLRC: len(times) == 0
instead of times == nil (equivalent here, more idiomatic).
No behavior change.
* test(lyrics): add direct coverage for NormalizeCueEnds
NormalizeCueEnds is exported and carries the most intricate logic in the
normalization cluster (fill-from-next, fill-from-fallback, both clamps, and the
all-or-none clear), but was only exercised transitively. Add a focused spec
covering each branch plus the empty-input and no-mutation guarantees, bringing
the function to 100% coverage.
* test(lyrics): cover legacy getLyrics across formats and sources
Expand the legacy getLyrics e2e coverage from a single embedded-plain case to a
table over all six fixtures: embedded LRC/plain/TTML and sidecar LRC/SRT/YAML.
Each case asserts the v1 plain-text fallback contract — the structured lyric is
flattened to LRC-style plain text with no timing markup leaking through (no LRC
brackets, SRT arrows, or XML tags), regardless of the source format or whether
it is embedded or a sidecar file. This pins the behavior that synced TTML/SRT/
YAML formats degrade gracefully to plain text on the legacy endpoint.
* test(lyrics): cover songLyrics v1 vs v2 with word-level fixtures
Correct and expand the e2e lyrics coverage to match the OpenSubsonic songLyrics
extension contract:
- v1 (getLyricsBySongId, no enhanced): line-level lyrics with no cueLine, kind,
or agents — even for word-level formats (ELRC, Lyricsfile YAML).
- v2 (getLyricsBySongId?enhanced=true): word-level cueLine surfaces for ELRC and
YAML sources; kind="main" is set; a line-level source (SRT) still yields no
cueLine even when enhanced.
- legacy getLyrics (artist/title): the original Subsonic endpoint, flattening any
format to plain text. A prior commit mislabeled this as the "v1 contract";
getLyrics predates OpenSubsonic and is unrelated to the extension versions.
Drive these with the public-domain tests/fixtures/lyrics files (the same set the
parser benchmarks use) so the e2e content stays in sync and actually carries the
word-level timing needed to distinguish v1 from v2. The embedded "synced LRC"
fixture is upgraded to ELRC (word-level); track counts are unchanged, so the
rest of the suite is unaffected.
* test(lyrics): parameterize v2 enhanced coverage across all formats
Convert the v2 (enhanced) e2e block from three ad-hoc cases into a DescribeTable
covering all six formats, matching the v1 and legacy tables. Each entry declares
whether the source carries word-level timing: ELRC, TTML, and Lyricsfile YAML
surface a cueLine; LRC, SRT, and plain text do not. All six get kind="main".
Add word-level <span> timing to the first line of the auld-lang-syne.ttml
fixture so TTML exercises the word-level cueLine path (the parser already
supports <span begin/end>, but the fixture was line-level only). The first line
now yields the same five word cues as the ELRC and YAML fixtures, keeping the
table assertions uniform across formats.
* fix(lyrics): honor caller language when Lyricsfile YAML omits it
parseLyricsfile discarded the caller's language argument, so a Lyricsfile YAML
parsed from an embedded tag or plugin response with no metadata.language was
labeled "xxx" even when ParseLyrics was given a language. The SRT and TTML
parsers already use the caller language as their default; fall back to it here
too, preferring the document's own metadata.language when present.
Also reword a misleading TTML comment: isTTMLDocument still runs an XML decode
(it stops at the first element), so the skip avoids the full TTML parse, not the
XML decoder entirely.
* refactor(lyrics): consolidate lyrics parsing functions names
Signed-off-by: Deluan <deluan@navidrome.org>
* test(lyrics): drop test-only parse wrappers after parser rename
Commit
|
||
|---|---|---|
| .. | ||
| capabilities | ||
| cmd/ndpgen | ||
| examples | ||
| host | ||
| pdk | ||
| testdata | ||
| .gitignore | ||
| capabilities.go | ||
| capabilities_test.go | ||
| capability_lifecycle.go | ||
| config_validation.go | ||
| config_validation_test.go | ||
| host_artwork.go | ||
| host_artwork_test.go | ||
| host_cache.go | ||
| host_cache_test.go | ||
| host_config.go | ||
| host_config_test.go | ||
| host_httpclient.go | ||
| host_httpclient_test.go | ||
| host_kvstore.go | ||
| host_kvstore_test.go | ||
| host_library.go | ||
| host_library_test.go | ||
| host_scheduler.go | ||
| host_scheduler_test.go | ||
| host_subsonicapi.go | ||
| host_subsonicapi_test.go | ||
| host_taskqueue.go | ||
| host_taskqueue_test.go | ||
| host_users.go | ||
| host_users_test.go | ||
| host_websocket.go | ||
| host_websocket_test.go | ||
| lyrics_adapter.go | ||
| lyrics_adapter_test.go | ||
| manager.go | ||
| manager_cache.go | ||
| manager_cache_test.go | ||
| manager_call.go | ||
| manager_call_test.go | ||
| manager_loader.go | ||
| manager_loader_test.go | ||
| manager_plugin.go | ||
| manager_plugin_test.go | ||
| manager_sync.go | ||
| manager_test.go | ||
| manager_watcher.go | ||
| manager_watcher_test.go | ||
| manifest-schema.json | ||
| manifest.go | ||
| manifest_gen.go | ||
| manifest_test.go | ||
| metadata_agent.go | ||
| metadata_agent_test.go | ||
| migrate.go | ||
| migrate_test.go | ||
| package.go | ||
| package_test.go | ||
| plugins_suite_test.go | ||
| plugins_suite_windows_test.go | ||
| README.md | ||
| scrobbler_adapter.go | ||
| scrobbler_adapter_test.go | ||
| sonic_similarity_adapter.go | ||
| sonic_similarity_adapter_test.go | ||
Navidrome Plugin System
Navidrome supports WebAssembly (Wasm) plugins for extending functionality. Plugins run in a secure sandbox and can provide metadata agents, scrobblers, lyrics providers, audio similarity, and other integrations through host services like scheduling, caching, task queues, WebSockets, and Subsonic API access.
The plugin system is built on Extism, a cross-language framework for building WebAssembly plugins. You can write plugins in any language that Extism supports (Go, Rust, Python, TypeScript, and more) using their Plugin Development Kits (PDKs).
Essential Extism Resources:
- Extism Documentation – Core concepts and architecture
- Plugin Development Kits (PDKs) – Language-specific libraries for writing plugins
- Go PDK – Recommended for Go plugins with TinyGo
- Rust PDK – For Rust plugins
- Python PDK – Experimental Python support
- JavaScript PDK – For TypeScript/JavaScript plugins
Table of Contents
- Quick Start
- Plugin Basics
- Capabilities
- Host Services
- Configuration
- Building Plugins
- Examples
- Security
Quick Start
1. Create a minimal plugin
Create main.go:
package main
import "github.com/extism/go-pdk"
func main() {}
// Implement your capability functions here
Create manifest.json:
{
"name": "My Plugin",
"author": "Your Name",
"version": "1.0.0"
}
2. Build with TinyGo and package as .ndp
# Compile to WebAssembly
tinygo build -o plugin.wasm -target wasip1 -buildmode=c-shared .
# Package as .ndp (zip archive)
zip -j my-plugin.ndp manifest.json plugin.wasm
3. Install
Copy my-plugin.ndp to your Navidrome plugins folder and enable plugins in your config:
[Plugins]
Enabled = true
Folder = "/path/to/plugins"
Plugin Basics
What is a Plugin?
A Navidrome plugin is an .ndp package file (zip archive) containing:
manifest.json– Plugin metadata (name, author, version, permissions)plugin.wasm– Compiled WebAssembly module with capability functions
Plugin Naming
Plugins are identified by their filename (without .ndp extension), not the manifest name field:
my-plugin.ndp→ plugin ID ismy-plugin- The manifest
nameis the display name shown in the UI
This allows users to have multiple instances of the same plugin with different configs by renaming the files.
The Manifest
Every plugin must include a manifest.json file. Example:
{
"name": "My Plugin",
"author": "Author Name",
"version": "1.0.0",
"description": "What this plugin does",
"website": "https://example.com",
"config": {
"schema": { ... },
"uiSchema": { ... }
},
"permissions": {
"http": {
"reason": "Fetch metadata from external API",
"requiredHosts": ["api.example.com", "*.musicbrainz.org"]
}
}
}
Required fields: name, author, version
Optional fields: description, website, config, permissions, 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
userspermission in their manifest. Scrobble events are only sent for users assigned to the plugin through Navidrome's configuration.
Manifest permission:
{
"permissions": {
"users": {
"reason": "Receive scrobble events for users assigned to this plugin"
}
}
}
NowPlaying/Scrobble Input:
{
"username": "john",
"track": {
"id": "track-id",
"title": "Song Title",
"album": "Album Name",
"artist": "Artist Name",
"albumArtist": "Album Artist",
"duration": 180.5,
"trackNumber": 1,
"discNumber": 1,
"mbzRecordingId": "...",
"mbzAlbumId": "...",
"mbzArtistId": "..."
},
"timestamp": 1703270400
}
Error Handling:
On success, return 0. On failure, use pdk.SetError() with one of these error types:
scrobbler(not_authorized)– User needs to re-authorizescrobbler(retry_later)– Temporary failure, Navidrome will retryscrobbler(unrecoverable)– Permanent failure, scrobble discarded
import "github.com/navidrome/navidrome/plugins/pdk/go/scrobbler"
return scrobbler.ScrobblerErrorNotAuthorized
return scrobbler.ScrobblerErrorRetryLater
return scrobbler.ScrobblerErrorUnrecoverable
Lyrics
Provides lyrics for tracks. The single method is required.
| Function | Input | Output | Description |
|---|---|---|---|
nd_lyrics_get_lyrics |
{artistName, title, ...} |
{lyrics: [{lang, text}]} |
Get lyrics |
Each returned lyric entry has a lang (language code) and text field. Multiple entries can be returned for different languages.
SonicSimilarity
Audio-similarity discovery based on acoustic features (e.g., embeddings). Both methods are required.
| Function | Input | Output | Description |
|---|---|---|---|
nd_get_sonic_similar_tracks |
{song, count} |
{matches: [{song, similarity}]} |
Find acoustically similar tracks |
nd_find_sonic_path |
{startSong, endSong, count} |
{matches: [{song, similarity}]} |
Find a path between two songs |
Each match contains a song reference and a similarity score (float64, 0.0–1.0).
TaskWorker
Processes tasks from a queue. 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 totrueto 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
pathandmountPointfields are only included whenfilesystem: trueis set in the permission.
Filesystem access:
When filesystem: true, your plugin can read files from library directories via WASI filesystem APIs. Each library is mounted at /libraries/<id>:
import "os"
content, err := os.ReadFile("/libraries/1/Artist/Album/track.mp3")
entries, err := os.ReadDir("/libraries/1/Artist")
Security: 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
subsonicapipermission requires theuserspermission. Which users the plugin can act as is controlled through the Navidrome UI.
Host functions:
| Function | Parameters | Returns |
|---|---|---|
subsonicapi_call |
uri |
JSON response string |
subsonicapi_callraw |
uri |
Content type + binary response |
Usage:
// JSON response
response, err := host.SubsonicAPICall("getAlbumList2?type=random&size=10&u=username")
// Binary response (e.g., cover art, streams)
contentType, data, err := host.SubsonicAPICallRaw("getCoverArt?id=al-123&u=username")
Config
Access plugin configuration values. Unlike pdk.GetConfig() which only retrieves individual values, this service can list all available configuration keys — useful for discovering dynamic configuration.
Note: This service is always available and does not require a manifest permission.
Host functions:
| Function | Parameters | Returns |
|---|---|---|
config_get |
key |
value, exists |
config_getint |
key |
value, exists |
config_keys |
prefix |
Array of matching key names |
Usage:
// Get a configuration value
value, exists := host.ConfigGet("api_key")
// Get an integer configuration value
count, exists := host.ConfigGetInt("max_retries")
// List all keys with a prefix (useful for user-specific config)
keys := host.ConfigKeys("user:")
// List all configuration keys
allKeys := host.ConfigKeys("")
Users
Access user information for the users that the plugin has been granted access to.
Manifest permission:
{
"permissions": {
"users": {
"reason": "Display user information in status updates"
}
}
}
Important: Before enabling a plugin that requires the users permission, an administrator must configure which users the plugin can access:
- Allow all users – Enable the "Allow all users" toggle in the plugin settings
- Select specific users – Choose individual users from the user list
If neither option is configured, the plugin cannot be enabled.
Host functions:
| Function | Parameters | Returns |
|---|---|---|
users_getusers |
– | Array of User objects |
users_getadmins |
– | Array of admin Users |
User object fields:
| Field | Type | Description |
|---|---|---|
userName |
string | The user's unique username |
name |
string | The user's display name |
isAdmin |
boolean | Whether the user is an admin |
Security: Sensitive fields like passwords, email addresses, and internal IDs are never exposed to plugins.
Usage:
users, err := host.UsersGetUsers()
for _, user := range users {
pdk.Log(pdk.LogInfo, "User: " + user.UserName + " (" + user.Name + ")")
}
admins, err := host.UsersGetAdmins()
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.
Go with TinyGo (Recommended)
# Install TinyGo: https://tinygo.org/getting-started/install/
# Build WebAssembly module
tinygo build -o plugin.wasm -target wasip1 -buildmode=c-shared .
# Package as .ndp
zip -j my-plugin.ndp manifest.json plugin.wasm
Using Go PDK Packages
Navidrome provides type-safe Go packages for each capability and host service in plugins/pdk/go/. Instead of manually exporting functions with //go:wasmexport, use the Register() pattern:
package main
import "github.com/navidrome/navidrome/plugins/pdk/go/metadata"
type myPlugin struct{}
func (p *myPlugin) GetArtistBiography(input metadata.ArtistRequest) (*metadata.ArtistBiographyResponse, error) {
return &metadata.ArtistBiographyResponse{Biography: "Biography text..."}, nil
}
func init() { metadata.Register(&myPlugin{}) }
func main() {}
Add to your go.mod:
require github.com/navidrome/navidrome v0.0.0
replace github.com/navidrome/navidrome => ../../..
Available capability packages:
| Package | Import Path | Description |
|---|---|---|
metadata |
plugins/pdk/go/metadata |
Artist/album metadata providers |
scrobbler |
plugins/pdk/go/scrobbler |
Scrobbling services |
lyrics |
plugins/pdk/go/lyrics |
Lyrics providers |
sonicsimilarity |
plugins/pdk/go/sonicsimilarity |
Audio similarity discovery |
taskworker |
plugins/pdk/go/taskworker |
Background task processing |
lifecycle |
plugins/pdk/go/lifecycle |
Plugin initialization |
scheduler |
plugins/pdk/go/scheduler |
Scheduled task callbacks |
websocket |
plugins/pdk/go/websocket |
WebSocket event handlers |
host |
plugins/pdk/go/host |
Host service SDK (all services) |
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
For Python host services: Copy functions from the nd_host_*.py files in plugins/pdk/python/host/ into your __init__.py (see comments in those files for extism-py limitations).
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:
- Host Allowlisting – Only explicitly allowed hosts are accessible via HTTP/WebSocket
- Limited File System – Read-only access to library directories, only when explicitly granted the
library.filesystempermission - No Network Listeners – Plugins cannot bind ports
- Config Isolation – Plugins only receive their own config section
- Memory Limits – Controlled by the WebAssembly runtime
- User-Scoped Authorization – Plugins with
subsonicapiorscrobblercapabilities can only access/receive events for users assigned to them through Navidrome's configuration - Users Permission – Plugins requesting user access must be explicitly configured with allowed users; sensitive data (passwords, emails) is never exposed
Runtime Management
Auto-Reload
With AutoReload = true, Navidrome watches the plugins folder and automatically detects when .ndp files are added, modified, or removed. When a plugin file changes, the plugin is disabled and its metadata is re-read from the archive.
If AutoReload is disabled, Navidrome needs to be restarted to pick up plugin changes.
Enabling/Disabling Plugins
Plugins can be enabled/disabled via the Navidrome UI. 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