Compare commits

..

2 Commits

Author SHA1 Message Date
Deluan
6920ce9f6e test(plugins): add lyrics capability integration test with test plugin 2026-02-28 15:55:43 -05:00
Deluan
4a53650981 feat(plugins): add lyrics provider plugin capability
Refactor the lyrics system from a static function to an interface-based
service that supports WASM plugin providers. Plugins listed in the
LyricsPriority config (alongside "embedded" and file extensions) are
now resolved through the plugin system.

Includes capability definition, Go/Rust PDK, adapter, Wire integration,
and tests for plugin fallback behavior.
2026-02-28 15:40:39 -05:00
40 changed files with 1039 additions and 1548 deletions

View File

@@ -1,6 +1,6 @@
// Code generated by Wire. DO NOT EDIT.
//go:generate go run -mod=mod github.com/google/wire/cmd/wire gen -tags "netgo"
//go:generate go run -mod=mod github.com/google/wire/cmd/wire gen -tags "netgo sqlite_fts5"
//go:build !wireinject
// +build !wireinject
@@ -16,6 +16,7 @@ import (
"github.com/navidrome/navidrome/core/artwork"
"github.com/navidrome/navidrome/core/external"
"github.com/navidrome/navidrome/core/ffmpeg"
"github.com/navidrome/navidrome/core/lyrics"
"github.com/navidrome/navidrome/core/metrics"
"github.com/navidrome/navidrome/core/playback"
"github.com/navidrome/navidrome/core/playlists"
@@ -103,7 +104,8 @@ func CreateSubsonicAPIRouter(ctx context.Context) *subsonic.Router {
modelScanner := scanner.New(ctx, dataStore, cacheWarmer, broker, playlistsPlaylists, metricsMetrics)
playTracker := scrobbler.GetPlayTracker(dataStore, broker, manager)
playbackServer := playback.GetInstance(dataStore)
router := subsonic.New(dataStore, artworkArtwork, mediaStreamer, archiver, players, provider, modelScanner, broker, playlistsPlaylists, playTracker, share, playbackServer, metricsMetrics)
lyricsLyrics := lyrics.NewLyrics(manager)
router := subsonic.New(dataStore, artworkArtwork, mediaStreamer, archiver, players, provider, modelScanner, broker, playlistsPlaylists, playTracker, share, playbackServer, metricsMetrics, lyricsLyrics)
return router
}
@@ -207,7 +209,7 @@ func getPluginManager() *plugins.Manager {
// wire_injectors.go:
var allProviders = wire.NewSet(core.Set, artwork.Set, server.New, subsonic.New, nativeapi.New, public.New, persistence.New, lastfm.NewRouter, listenbrainz.NewRouter, events.GetBroker, scanner.New, scanner.GetWatcher, metrics.GetPrometheusInstance, db.Db, plugins.GetManager, wire.Bind(new(agents.PluginLoader), new(*plugins.Manager)), wire.Bind(new(scrobbler.PluginLoader), new(*plugins.Manager)), wire.Bind(new(nativeapi.PluginManager), new(*plugins.Manager)), wire.Bind(new(core.PluginUnloader), new(*plugins.Manager)), wire.Bind(new(plugins.PluginMetricsRecorder), new(metrics.Metrics)), wire.Bind(new(core.Watcher), new(scanner.Watcher)))
var allProviders = wire.NewSet(core.Set, artwork.Set, server.New, subsonic.New, nativeapi.New, public.New, persistence.New, lastfm.NewRouter, listenbrainz.NewRouter, events.GetBroker, scanner.New, scanner.GetWatcher, metrics.GetPrometheusInstance, db.Db, plugins.GetManager, wire.Bind(new(agents.PluginLoader), new(*plugins.Manager)), wire.Bind(new(scrobbler.PluginLoader), new(*plugins.Manager)), wire.Bind(new(lyrics.PluginLoader), new(*plugins.Manager)), wire.Bind(new(nativeapi.PluginManager), new(*plugins.Manager)), wire.Bind(new(core.PluginUnloader), new(*plugins.Manager)), wire.Bind(new(plugins.PluginMetricsRecorder), new(metrics.Metrics)), wire.Bind(new(core.Watcher), new(scanner.Watcher)))
func GetPluginManager(ctx context.Context) *plugins.Manager {
manager := getPluginManager()

View File

@@ -11,6 +11,7 @@ import (
"github.com/navidrome/navidrome/core"
"github.com/navidrome/navidrome/core/agents"
"github.com/navidrome/navidrome/core/artwork"
"github.com/navidrome/navidrome/core/lyrics"
"github.com/navidrome/navidrome/core/metrics"
"github.com/navidrome/navidrome/core/playback"
"github.com/navidrome/navidrome/core/scrobbler"
@@ -44,6 +45,7 @@ var allProviders = wire.NewSet(
plugins.GetManager,
wire.Bind(new(agents.PluginLoader), new(*plugins.Manager)),
wire.Bind(new(scrobbler.PluginLoader), new(*plugins.Manager)),
wire.Bind(new(lyrics.PluginLoader), new(*plugins.Manager)),
wire.Bind(new(nativeapi.PluginManager), new(*plugins.Manager)),
wire.Bind(new(core.PluginUnloader), new(*plugins.Manager)),
wire.Bind(new(plugins.PluginMetricsRecorder), new(metrics.Metrics)),

View File

@@ -9,7 +9,29 @@ import (
"github.com/navidrome/navidrome/model"
)
func GetLyrics(ctx context.Context, mf *model.MediaFile) (model.LyricList, error) {
// Lyrics can fetch lyrics for a media file.
type Lyrics interface {
GetLyrics(ctx context.Context, mf *model.MediaFile) (model.LyricList, error)
}
// PluginLoader discovers and loads lyrics provider plugins.
type PluginLoader interface {
LoadLyricsProvider(name string) (Lyrics, bool)
}
type lyricsService struct {
pluginLoader PluginLoader
}
// NewLyrics creates a new lyrics service. pluginLoader may be nil if no plugin
// system is available.
func NewLyrics(pluginLoader PluginLoader) Lyrics {
return &lyricsService{pluginLoader: pluginLoader}
}
// GetLyrics returns lyrics for the given media file, trying sources in the
// order specified by conf.Server.LyricsPriority.
func (l *lyricsService) GetLyrics(ctx context.Context, mf *model.MediaFile) (model.LyricList, error) {
var lyricsList model.LyricList
var err error
@@ -21,11 +43,11 @@ func GetLyrics(ctx context.Context, mf *model.MediaFile) (model.LyricList, error
case strings.HasPrefix(pattern, "."):
lyricsList, err = fromExternalFile(ctx, mf, pattern)
default:
log.Error(ctx, "Invalid lyric pattern", "pattern", pattern)
lyricsList, err = l.fromPlugin(ctx, mf, pattern)
}
if err != nil {
log.Error(ctx, "error parsing lyrics", "source", pattern, err)
log.Error(ctx, "error getting lyrics", "source", pattern, err)
}
if len(lyricsList) > 0 {

View File

@@ -3,6 +3,7 @@ package lyrics_test
import (
"context"
"encoding/json"
"fmt"
"os"
"github.com/navidrome/navidrome/conf"
@@ -72,7 +73,8 @@ var _ = Describe("sources", func() {
DescribeTable("Lyrics Priority", func(priority string, expected model.LyricList) {
conf.Server.LyricsPriority = priority
list, err := lyrics.GetLyrics(ctx, &mf)
svc := lyrics.NewLyrics(nil)
list, err := svc.GetLyrics(ctx, &mf)
Expect(err).To(BeNil())
Expect(list).To(Equal(expected))
},
@@ -107,7 +109,8 @@ var _ = Describe("sources", func() {
It("should fallback to embedded if an error happens when parsing file", func() {
conf.Server.LyricsPriority = ".mp3,embedded"
list, err := lyrics.GetLyrics(ctx, &mf)
svc := lyrics.NewLyrics(nil)
list, err := svc.GetLyrics(ctx, &mf)
Expect(err).To(BeNil())
Expect(list).To(Equal(embeddedLyrics))
})
@@ -115,10 +118,94 @@ var _ = Describe("sources", func() {
It("should return nothing if error happens when trying to parse file", func() {
conf.Server.LyricsPriority = ".mp3"
list, err := lyrics.GetLyrics(ctx, &mf)
svc := lyrics.NewLyrics(nil)
list, err := svc.GetLyrics(ctx, &mf)
Expect(err).To(BeNil())
Expect(list).To(BeEmpty())
})
})
})
Context("plugin sources", func() {
var mockLoader *mockPluginLoader
BeforeEach(func() {
mockLoader = &mockPluginLoader{}
})
It("should return lyrics from a plugin", func() {
conf.Server.LyricsPriority = "test-lyrics-plugin"
mockLoader.lyrics = unsyncedLyrics
svc := lyrics.NewLyrics(mockLoader)
list, err := svc.GetLyrics(ctx, &mf)
Expect(err).To(BeNil())
Expect(list).To(Equal(unsyncedLyrics))
})
It("should try plugin after embedded returns nothing", func() {
conf.Server.LyricsPriority = "embedded,test-lyrics-plugin"
mf.Lyrics = "" // No embedded lyrics
mockLoader.lyrics = unsyncedLyrics
svc := lyrics.NewLyrics(mockLoader)
list, err := svc.GetLyrics(ctx, &mf)
Expect(err).To(BeNil())
Expect(list).To(Equal(unsyncedLyrics))
})
It("should skip plugin if embedded has lyrics", func() {
conf.Server.LyricsPriority = "embedded,test-lyrics-plugin"
mockLoader.lyrics = unsyncedLyrics
svc := lyrics.NewLyrics(mockLoader)
list, err := svc.GetLyrics(ctx, &mf)
Expect(err).To(BeNil())
Expect(list).To(Equal(embeddedLyrics)) // embedded wins
})
It("should skip unknown plugin names gracefully", func() {
conf.Server.LyricsPriority = "nonexistent-plugin,embedded"
mockLoader.notFound = true
svc := lyrics.NewLyrics(mockLoader)
list, err := svc.GetLyrics(ctx, &mf)
Expect(err).To(BeNil())
Expect(list).To(Equal(embeddedLyrics)) // falls through to embedded
})
It("should handle plugin error gracefully", func() {
conf.Server.LyricsPriority = "test-lyrics-plugin,embedded"
mockLoader.err = fmt.Errorf("plugin error")
svc := lyrics.NewLyrics(mockLoader)
list, err := svc.GetLyrics(ctx, &mf)
Expect(err).To(BeNil())
Expect(list).To(Equal(embeddedLyrics)) // falls through to embedded
})
})
})
type mockPluginLoader struct {
lyrics model.LyricList
err error
notFound bool
}
func (m *mockPluginLoader) PluginNames(_ string) []string {
if m.notFound {
return nil
}
return []string{"test-lyrics-plugin"}
}
func (m *mockPluginLoader) LoadLyricsProvider(_ string) (lyrics.Lyrics, bool) {
if m.notFound {
return nil, false
}
return &mockLyricsProvider{lyrics: m.lyrics, err: m.err}, true
}
type mockLyricsProvider struct {
lyrics model.LyricList
err error
}
func (m *mockLyricsProvider) GetLyrics(_ context.Context, _ *model.MediaFile) (model.LyricList, error) {
return m.lyrics, m.err
}

View File

@@ -49,3 +49,27 @@ func fromExternalFile(ctx context.Context, mf *model.MediaFile, suffix string) (
return model.LyricList{*lyrics}, nil
}
// fromPlugin attempts to load lyrics from a plugin with the given name.
func (l *lyricsService) fromPlugin(ctx context.Context, mf *model.MediaFile, pluginName string) (model.LyricList, error) {
if l.pluginLoader == nil {
log.Debug(ctx, "Invalid lyric source", "source", pluginName)
return nil, nil
}
provider, ok := l.pluginLoader.LoadLyricsProvider(pluginName)
if !ok {
log.Warn(ctx, "Lyrics plugin not found", "plugin", pluginName)
return nil, nil
}
lyricsList, err := provider.GetLyrics(ctx, mf)
if err != nil {
return nil, err
}
if len(lyricsList) > 0 {
log.Trace(ctx, "Retrieved lyrics from plugin", "plugin", pluginName, "count", len(lyricsList))
}
return lyricsList, nil
}

View File

@@ -5,6 +5,7 @@ import (
"github.com/navidrome/navidrome/core/agents"
"github.com/navidrome/navidrome/core/external"
"github.com/navidrome/navidrome/core/ffmpeg"
"github.com/navidrome/navidrome/core/lyrics"
"github.com/navidrome/navidrome/core/metrics"
"github.com/navidrome/navidrome/core/playback"
"github.com/navidrome/navidrome/core/playlists"
@@ -28,4 +29,5 @@ var Set = wire.NewSet(
scrobbler.GetPlayTracker,
playback.GetInstance,
metrics.GetInstance,
lyrics.NewLyrics,
)

View File

@@ -0,0 +1,26 @@
package capabilities
// Lyrics provides lyrics for a given track from external sources.
//
//nd:capability name=lyrics required=true
type Lyrics interface {
//nd:export name=nd_lyrics_get_lyrics
GetLyrics(GetLyricsRequest) (GetLyricsResponse, error)
}
// GetLyricsRequest contains the track information for lyrics lookup.
type GetLyricsRequest struct {
Track TrackInfo `json:"track"`
}
// GetLyricsResponse contains the lyrics returned by the plugin.
type GetLyricsResponse struct {
Lyrics []LyricsText `json:"lyrics"`
}
// LyricsText represents a single set of lyrics in raw text format.
// Text can be plain text or LRC format — Navidrome will parse it.
type LyricsText struct {
Lang string `json:"lang,omitempty"`
Text string `json:"text"`
}

View File

@@ -0,0 +1,115 @@
version: v1-draft
exports:
nd_lyrics_get_lyrics:
input:
$ref: '#/components/schemas/GetLyricsRequest'
contentType: application/json
output:
$ref: '#/components/schemas/GetLyricsResponse'
contentType: application/json
components:
schemas:
ArtistRef:
description: ArtistRef is a reference to an artist with name and optional MBID.
properties:
id:
type: string
description: ID is the internal Navidrome artist ID (if known).
name:
type: string
description: Name is the artist name.
mbid:
type: string
description: MBID is the MusicBrainz ID for the artist.
required:
- name
GetLyricsRequest:
description: GetLyricsRequest contains the track information for lyrics lookup.
properties:
track:
$ref: '#/components/schemas/TrackInfo'
required:
- track
GetLyricsResponse:
description: GetLyricsResponse contains the lyrics returned by the plugin.
properties:
lyrics:
type: array
items:
$ref: '#/components/schemas/LyricsText'
required:
- lyrics
LyricsText:
description: |-
LyricsText represents a single set of lyrics in raw text format.
Text can be plain text or LRC format — Navidrome will parse it.
properties:
lang:
type: string
text:
type: string
required:
- text
TrackInfo:
description: TrackInfo contains track metadata for scrobbling.
properties:
id:
type: string
description: ID is the internal Navidrome track ID.
title:
type: string
description: Title is the track title.
album:
type: string
description: Album is the album name.
artist:
type: string
description: Artist is the formatted artist name for display (e.g., "Artist1 • Artist2").
albumArtist:
type: string
description: AlbumArtist is the formatted album artist name for display.
artists:
type: array
description: Artists is the list of track artists.
items:
$ref: '#/components/schemas/ArtistRef'
albumArtists:
type: array
description: AlbumArtists is the list of album artists.
items:
$ref: '#/components/schemas/ArtistRef'
duration:
type: number
format: float
description: Duration is the track duration in seconds.
trackNumber:
type: integer
format: int32
description: TrackNumber is the track number on the album.
discNumber:
type: integer
format: int32
description: DiscNumber is the disc number.
mbzRecordingId:
type: string
description: MBZRecordingID is the MusicBrainz recording ID.
mbzAlbumId:
type: string
description: MBZAlbumID is the MusicBrainz album/release ID.
mbzReleaseGroupId:
type: string
description: MBZReleaseGroupID is the MusicBrainz release group ID.
mbzReleaseTrackId:
type: string
description: MBZReleaseTrackID is the MusicBrainz release track ID.
required:
- id
- title
- album
- artist
- albumArtist
- artists
- albumArtists
- duration
- trackNumber
- discNumber

View File

@@ -23,20 +23,6 @@ type KVStoreService interface {
//nd:hostfunc
Set(ctx context.Context, key string, value []byte) error
// SetWithTTL stores a byte value with the given key and a time-to-live.
//
// After ttlSeconds, the key is treated as non-existent and will be
// cleaned up lazily. ttlSeconds must be greater than 0.
//
// Parameters:
// - key: The storage key (max 256 bytes, UTF-8)
// - value: The byte slice to store
// - ttlSeconds: Time-to-live in seconds (must be > 0)
//
// Returns an error if the storage limit would be exceeded or the operation fails.
//nd:hostfunc
SetWithTTL(ctx context.Context, key string, value []byte, ttlSeconds int64) error
// Get retrieves a byte value from storage.
//
// Parameters:
@@ -46,15 +32,14 @@ type KVStoreService interface {
//nd:hostfunc
Get(ctx context.Context, key string) (value []byte, exists bool, err error)
// GetMany retrieves multiple values in a single call.
// Delete removes a value from storage.
//
// Parameters:
// - keys: The storage keys to retrieve
// - key: The storage key
//
// Returns a map of key to value for keys that exist and have not expired.
// Missing or expired keys are omitted from the result.
// Returns an error if the operation fails. Does not return an error if the key doesn't exist.
//nd:hostfunc
GetMany(ctx context.Context, keys []string) (values map[string][]byte, err error)
Delete(ctx context.Context, key string) error
// Has checks if a key exists in storage.
//
@@ -74,24 +59,6 @@ type KVStoreService interface {
//nd:hostfunc
List(ctx context.Context, prefix string) (keys []string, err error)
// Delete removes a value from storage.
//
// Parameters:
// - key: The storage key
//
// Returns an error if the operation fails. Does not return an error if the key doesn't exist.
//nd:hostfunc
Delete(ctx context.Context, key string) error
// DeleteByPrefix removes all keys matching the given prefix.
//
// Parameters:
// - prefix: Key prefix to match (must not be empty)
//
// Returns the number of keys deleted. Includes expired keys.
//nd:hostfunc
DeleteByPrefix(ctx context.Context, prefix string) (deletedCount int64, err error)
// GetStorageUsed returns the total storage used by this plugin in bytes.
//nd:hostfunc
GetStorageUsed(ctx context.Context) (bytes int64, err error)

View File

@@ -20,18 +20,6 @@ type KVStoreSetResponse struct {
Error string `json:"error,omitempty"`
}
// KVStoreSetWithTTLRequest is the request type for KVStore.SetWithTTL.
type KVStoreSetWithTTLRequest struct {
Key string `json:"key"`
Value []byte `json:"value"`
TtlSeconds int64 `json:"ttlSeconds"`
}
// KVStoreSetWithTTLResponse is the response type for KVStore.SetWithTTL.
type KVStoreSetWithTTLResponse struct {
Error string `json:"error,omitempty"`
}
// KVStoreGetRequest is the request type for KVStore.Get.
type KVStoreGetRequest struct {
Key string `json:"key"`
@@ -44,15 +32,14 @@ type KVStoreGetResponse struct {
Error string `json:"error,omitempty"`
}
// KVStoreGetManyRequest is the request type for KVStore.GetMany.
type KVStoreGetManyRequest struct {
Keys []string `json:"keys"`
// KVStoreDeleteRequest is the request type for KVStore.Delete.
type KVStoreDeleteRequest struct {
Key string `json:"key"`
}
// KVStoreGetManyResponse is the response type for KVStore.GetMany.
type KVStoreGetManyResponse struct {
Values map[string][]byte `json:"values,omitempty"`
Error string `json:"error,omitempty"`
// KVStoreDeleteResponse is the response type for KVStore.Delete.
type KVStoreDeleteResponse struct {
Error string `json:"error,omitempty"`
}
// KVStoreHasRequest is the request type for KVStore.Has.
@@ -77,27 +64,6 @@ type KVStoreListResponse struct {
Error string `json:"error,omitempty"`
}
// KVStoreDeleteRequest is the request type for KVStore.Delete.
type KVStoreDeleteRequest struct {
Key string `json:"key"`
}
// KVStoreDeleteResponse is the response type for KVStore.Delete.
type KVStoreDeleteResponse struct {
Error string `json:"error,omitempty"`
}
// KVStoreDeleteByPrefixRequest is the request type for KVStore.DeleteByPrefix.
type KVStoreDeleteByPrefixRequest struct {
Prefix string `json:"prefix"`
}
// KVStoreDeleteByPrefixResponse is the response type for KVStore.DeleteByPrefix.
type KVStoreDeleteByPrefixResponse struct {
DeletedCount int64 `json:"deletedCount,omitempty"`
Error string `json:"error,omitempty"`
}
// KVStoreGetStorageUsedResponse is the response type for KVStore.GetStorageUsed.
type KVStoreGetStorageUsedResponse struct {
Bytes int64 `json:"bytes,omitempty"`
@@ -109,13 +75,10 @@ type KVStoreGetStorageUsedResponse struct {
func RegisterKVStoreHostFunctions(service KVStoreService) []extism.HostFunction {
return []extism.HostFunction{
newKVStoreSetHostFunction(service),
newKVStoreSetWithTTLHostFunction(service),
newKVStoreGetHostFunction(service),
newKVStoreGetManyHostFunction(service),
newKVStoreDeleteHostFunction(service),
newKVStoreHasHostFunction(service),
newKVStoreListHostFunction(service),
newKVStoreDeleteHostFunction(service),
newKVStoreDeleteByPrefixHostFunction(service),
newKVStoreGetStorageUsedHostFunction(service),
}
}
@@ -151,37 +114,6 @@ func newKVStoreSetHostFunction(service KVStoreService) extism.HostFunction {
)
}
func newKVStoreSetWithTTLHostFunction(service KVStoreService) extism.HostFunction {
return extism.NewHostFunctionWithStack(
"kvstore_setwithttl",
func(ctx context.Context, p *extism.CurrentPlugin, stack []uint64) {
// Read JSON request from plugin memory
reqBytes, err := p.ReadBytes(stack[0])
if err != nil {
kvstoreWriteError(p, stack, err)
return
}
var req KVStoreSetWithTTLRequest
if err := json.Unmarshal(reqBytes, &req); err != nil {
kvstoreWriteError(p, stack, err)
return
}
// Call the service method
if svcErr := service.SetWithTTL(ctx, req.Key, req.Value, req.TtlSeconds); svcErr != nil {
kvstoreWriteError(p, stack, svcErr)
return
}
// Write JSON response to plugin memory
resp := KVStoreSetWithTTLResponse{}
kvstoreWriteResponse(p, stack, resp)
},
[]extism.ValueType{extism.ValueTypePTR},
[]extism.ValueType{extism.ValueTypePTR},
)
}
func newKVStoreGetHostFunction(service KVStoreService) extism.HostFunction {
return extism.NewHostFunctionWithStack(
"kvstore_get",
@@ -217,9 +149,9 @@ func newKVStoreGetHostFunction(service KVStoreService) extism.HostFunction {
)
}
func newKVStoreGetManyHostFunction(service KVStoreService) extism.HostFunction {
func newKVStoreDeleteHostFunction(service KVStoreService) extism.HostFunction {
return extism.NewHostFunctionWithStack(
"kvstore_getmany",
"kvstore_delete",
func(ctx context.Context, p *extism.CurrentPlugin, stack []uint64) {
// Read JSON request from plugin memory
reqBytes, err := p.ReadBytes(stack[0])
@@ -227,23 +159,20 @@ func newKVStoreGetManyHostFunction(service KVStoreService) extism.HostFunction {
kvstoreWriteError(p, stack, err)
return
}
var req KVStoreGetManyRequest
var req KVStoreDeleteRequest
if err := json.Unmarshal(reqBytes, &req); err != nil {
kvstoreWriteError(p, stack, err)
return
}
// Call the service method
values, svcErr := service.GetMany(ctx, req.Keys)
if svcErr != nil {
if svcErr := service.Delete(ctx, req.Key); svcErr != nil {
kvstoreWriteError(p, stack, svcErr)
return
}
// Write JSON response to plugin memory
resp := KVStoreGetManyResponse{
Values: values,
}
resp := KVStoreDeleteResponse{}
kvstoreWriteResponse(p, stack, resp)
},
[]extism.ValueType{extism.ValueTypePTR},
@@ -319,71 +248,6 @@ func newKVStoreListHostFunction(service KVStoreService) extism.HostFunction {
)
}
func newKVStoreDeleteHostFunction(service KVStoreService) extism.HostFunction {
return extism.NewHostFunctionWithStack(
"kvstore_delete",
func(ctx context.Context, p *extism.CurrentPlugin, stack []uint64) {
// Read JSON request from plugin memory
reqBytes, err := p.ReadBytes(stack[0])
if err != nil {
kvstoreWriteError(p, stack, err)
return
}
var req KVStoreDeleteRequest
if err := json.Unmarshal(reqBytes, &req); err != nil {
kvstoreWriteError(p, stack, err)
return
}
// Call the service method
if svcErr := service.Delete(ctx, req.Key); svcErr != nil {
kvstoreWriteError(p, stack, svcErr)
return
}
// Write JSON response to plugin memory
resp := KVStoreDeleteResponse{}
kvstoreWriteResponse(p, stack, resp)
},
[]extism.ValueType{extism.ValueTypePTR},
[]extism.ValueType{extism.ValueTypePTR},
)
}
func newKVStoreDeleteByPrefixHostFunction(service KVStoreService) extism.HostFunction {
return extism.NewHostFunctionWithStack(
"kvstore_deletebyprefix",
func(ctx context.Context, p *extism.CurrentPlugin, stack []uint64) {
// Read JSON request from plugin memory
reqBytes, err := p.ReadBytes(stack[0])
if err != nil {
kvstoreWriteError(p, stack, err)
return
}
var req KVStoreDeleteByPrefixRequest
if err := json.Unmarshal(reqBytes, &req); err != nil {
kvstoreWriteError(p, stack, err)
return
}
// Call the service method
deletedcount, svcErr := service.DeleteByPrefix(ctx, req.Prefix)
if svcErr != nil {
kvstoreWriteError(p, stack, svcErr)
return
}
// Write JSON response to plugin memory
resp := KVStoreDeleteByPrefixResponse{
DeletedCount: deletedcount,
}
kvstoreWriteResponse(p, stack, resp)
},
[]extism.ValueType{extism.ValueTypePTR},
[]extism.ValueType{extism.ValueTypePTR},
)
}
func newKVStoreGetStorageUsedHostFunction(service KVStoreService) extism.HostFunction {
return extism.NewHostFunctionWithStack(
"kvstore_getstorageused",

View File

@@ -7,16 +7,14 @@ import (
"fmt"
"os"
"path/filepath"
"slices"
"strings"
"time"
"sync/atomic"
"github.com/dustin/go-humanize"
_ "github.com/mattn/go-sqlite3"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/plugins/host"
"github.com/navidrome/navidrome/utils/slice"
)
const (
@@ -24,22 +22,17 @@ const (
maxKeyLength = 256 // Max key length in bytes
)
// notExpiredFilter is the SQL condition to exclude expired keys.
const notExpiredFilter = "(expires_at IS NULL OR expires_at > datetime('now'))"
const cleanupInterval = 1 * time.Hour
// kvstoreServiceImpl implements the host.KVStoreService interface.
// Each plugin gets its own SQLite database for isolation.
type kvstoreServiceImpl struct {
pluginName string
db *sql.DB
maxSize int64
pluginName string
db *sql.DB
maxSize int64
currentSize atomic.Int64 // cached total size, updated on Set/Delete
}
// newKVStoreService creates a new kvstoreServiceImpl instance with its own SQLite database.
// The provided context controls the lifetime of the background cleanup goroutine.
func newKVStoreService(ctx context.Context, pluginName string, perm *KVStorePermission) (*kvstoreServiceImpl, error) {
func newKVStoreService(pluginName string, perm *KVStorePermission) (*kvstoreServiceImpl, error) {
// Parse max size from permission, default to 1MB
maxSize := int64(defaultMaxKVStoreSize)
if perm != nil && perm.MaxSize != nil && *perm.MaxSize != "" {
@@ -66,69 +59,46 @@ func newKVStoreService(ctx context.Context, pluginName string, perm *KVStorePerm
db.SetMaxOpenConns(3)
db.SetMaxIdleConns(1)
// Apply schema migrations
// Create schema
if err := createKVStoreSchema(db); err != nil {
db.Close()
return nil, fmt.Errorf("migrating kvstore schema: %w", err)
return nil, fmt.Errorf("creating kvstore schema: %w", err)
}
log.Debug("Initialized plugin kvstore", "plugin", pluginName, "path", dbPath, "maxSize", humanize.Bytes(uint64(maxSize)))
// Load current storage size from database
var currentSize int64
if err := db.QueryRow(`SELECT COALESCE(SUM(size), 0) FROM kvstore`).Scan(&currentSize); err != nil {
db.Close()
return nil, fmt.Errorf("loading storage size: %w", err)
}
log.Debug("Initialized plugin kvstore", "plugin", pluginName, "path", dbPath, "maxSize", humanize.Bytes(uint64(maxSize)), "currentSize", humanize.Bytes(uint64(currentSize)))
svc := &kvstoreServiceImpl{
pluginName: pluginName,
db: db,
maxSize: maxSize,
}
go svc.cleanupLoop(ctx)
svc.currentSize.Store(currentSize)
return svc, nil
}
// createKVStoreSchema applies schema migrations to the kvstore database.
// New migrations must be appended at the end of the slice.
func createKVStoreSchema(db *sql.DB) error {
return migrateDB(db, []string{
`CREATE TABLE IF NOT EXISTS kvstore (
_, err := db.Exec(`
CREATE TABLE IF NOT EXISTS kvstore (
key TEXT PRIMARY KEY NOT NULL,
value BLOB NOT NULL,
size INTEGER NOT NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
)`,
`ALTER TABLE kvstore ADD COLUMN expires_at DATETIME DEFAULT NULL`,
`CREATE INDEX idx_kvstore_expires_at ON kvstore(expires_at)`,
})
)
`)
return err
}
// storageUsed returns the current total storage used by non-expired keys.
func (s *kvstoreServiceImpl) storageUsed(ctx context.Context) (int64, error) {
var used int64
err := s.db.QueryRowContext(ctx, `SELECT COALESCE(SUM(size), 0) FROM kvstore WHERE `+notExpiredFilter).Scan(&used)
if err != nil {
return 0, fmt.Errorf("calculating storage used: %w", err)
}
return used, nil
}
// checkStorageLimit verifies that adding delta bytes would not exceed the storage limit.
func (s *kvstoreServiceImpl) checkStorageLimit(ctx context.Context, delta int64) error {
if delta <= 0 {
return nil
}
used, err := s.storageUsed(ctx)
if err != nil {
return err
}
newTotal := used + delta
if newTotal > s.maxSize {
return fmt.Errorf("storage limit exceeded: would use %s of %s allowed",
humanize.Bytes(uint64(newTotal)), humanize.Bytes(uint64(s.maxSize)))
}
return nil
}
// setValue is the shared implementation for Set and SetWithTTL.
// A ttlSeconds of 0 means no expiration.
func (s *kvstoreServiceImpl) setValue(ctx context.Context, key string, value []byte, ttlSeconds int64) error {
// Set stores a byte value with the given key.
func (s *kvstoreServiceImpl) Set(ctx context.Context, key string, value []byte) error {
// Validate key
if len(key) == 0 {
return fmt.Errorf("key cannot be empty")
}
@@ -138,59 +108,46 @@ func (s *kvstoreServiceImpl) setValue(ctx context.Context, key string, value []b
newValueSize := int64(len(value))
// Get current size of this key (if it exists and not expired) to calculate delta
// Get current size of this key (if it exists) to calculate delta
var oldSize int64
err := s.db.QueryRowContext(ctx, `SELECT COALESCE(size, 0) FROM kvstore WHERE key = ? AND `+notExpiredFilter, key).Scan(&oldSize)
err := s.db.QueryRowContext(ctx, `SELECT COALESCE(size, 0) FROM kvstore WHERE key = ?`, key).Scan(&oldSize)
if err != nil && !errors.Is(err, sql.ErrNoRows) {
return fmt.Errorf("checking existing key: %w", err)
}
if err := s.checkStorageLimit(ctx, newValueSize-oldSize); err != nil {
return err
}
// Compute expires_at: sql.NullString{Valid:false} sends NULL (no expiration),
// otherwise we send a concrete timestamp.
var expiresAt sql.NullString
if ttlSeconds > 0 {
expiresAt = sql.NullString{String: fmt.Sprintf("+%d seconds", ttlSeconds), Valid: true}
// Check size limits using cached total
delta := newValueSize - oldSize
newTotal := s.currentSize.Load() + delta
if newTotal > s.maxSize {
return fmt.Errorf("storage limit exceeded: would use %s of %s allowed",
humanize.Bytes(uint64(newTotal)), humanize.Bytes(uint64(s.maxSize)))
}
// Upsert the value
_, err = s.db.ExecContext(ctx, `
INSERT INTO kvstore (key, value, size, created_at, updated_at, expires_at)
VALUES (?, ?, ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, datetime('now', ?))
ON CONFLICT(key) DO UPDATE SET
value = excluded.value,
INSERT INTO kvstore (key, value, size, created_at, updated_at)
VALUES (?, ?, ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
ON CONFLICT(key) DO UPDATE SET
value = excluded.value,
size = excluded.size,
updated_at = CURRENT_TIMESTAMP,
expires_at = excluded.expires_at
`, key, value, newValueSize, expiresAt)
updated_at = CURRENT_TIMESTAMP
`, key, value, newValueSize)
if err != nil {
return fmt.Errorf("storing value: %w", err)
}
log.Trace(ctx, "KVStore.Set", "plugin", s.pluginName, "key", key, "size", newValueSize, "ttlSeconds", ttlSeconds)
// Update cached size
s.currentSize.Add(delta)
log.Trace(ctx, "KVStore.Set", "plugin", s.pluginName, "key", key, "size", newValueSize)
return nil
}
// Set stores a byte value with the given key.
func (s *kvstoreServiceImpl) Set(ctx context.Context, key string, value []byte) error {
return s.setValue(ctx, key, value, 0)
}
// SetWithTTL stores a byte value with the given key and a time-to-live.
func (s *kvstoreServiceImpl) SetWithTTL(ctx context.Context, key string, value []byte, ttlSeconds int64) error {
if ttlSeconds <= 0 {
return fmt.Errorf("ttlSeconds must be greater than 0")
}
return s.setValue(ctx, key, value, ttlSeconds)
}
// Get retrieves a byte value from storage.
func (s *kvstoreServiceImpl) Get(ctx context.Context, key string) ([]byte, bool, error) {
var value []byte
err := s.db.QueryRowContext(ctx, `SELECT value FROM kvstore WHERE key = ? AND `+notExpiredFilter, key).Scan(&value)
if errors.Is(err, sql.ErrNoRows) {
err := s.db.QueryRowContext(ctx, `SELECT value FROM kvstore WHERE key = ?`, key).Scan(&value)
if err == sql.ErrNoRows {
return nil, false, nil
}
if err != nil {
@@ -203,11 +160,25 @@ func (s *kvstoreServiceImpl) Get(ctx context.Context, key string) ([]byte, bool,
// Delete removes a value from storage.
func (s *kvstoreServiceImpl) Delete(ctx context.Context, key string) error {
_, err := s.db.ExecContext(ctx, `DELETE FROM kvstore WHERE key = ?`, key)
// Get size of the key being deleted to update cache
var oldSize int64
err := s.db.QueryRowContext(ctx, `SELECT size FROM kvstore WHERE key = ?`, key).Scan(&oldSize)
if errors.Is(err, sql.ErrNoRows) {
// Key doesn't exist, nothing to delete
return nil
}
if err != nil {
return fmt.Errorf("checking key size: %w", err)
}
_, err = s.db.ExecContext(ctx, `DELETE FROM kvstore WHERE key = ?`, key)
if err != nil {
return fmt.Errorf("deleting value: %w", err)
}
// Update cached size
s.currentSize.Add(-oldSize)
log.Trace(ctx, "KVStore.Delete", "plugin", s.pluginName, "key", key)
return nil
}
@@ -215,7 +186,7 @@ func (s *kvstoreServiceImpl) Delete(ctx context.Context, key string) error {
// Has checks if a key exists in storage.
func (s *kvstoreServiceImpl) Has(ctx context.Context, key string) (bool, error) {
var count int
err := s.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM kvstore WHERE key = ? AND `+notExpiredFilter, key).Scan(&count)
err := s.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM kvstore WHERE key = ?`, key).Scan(&count)
if err != nil {
return false, fmt.Errorf("checking key: %w", err)
}
@@ -229,12 +200,12 @@ func (s *kvstoreServiceImpl) List(ctx context.Context, prefix string) ([]string,
var err error
if prefix == "" {
rows, err = s.db.QueryContext(ctx, `SELECT key FROM kvstore WHERE `+notExpiredFilter+` ORDER BY key`)
rows, err = s.db.QueryContext(ctx, `SELECT key FROM kvstore ORDER BY key`)
} else {
// Escape special LIKE characters in prefix
escapedPrefix := strings.ReplaceAll(prefix, "%", "\\%")
escapedPrefix = strings.ReplaceAll(escapedPrefix, "_", "\\_")
rows, err = s.db.QueryContext(ctx, `SELECT key FROM kvstore WHERE key LIKE ? ESCAPE '\' AND `+notExpiredFilter+` ORDER BY key`, escapedPrefix+"%")
rows, err = s.db.QueryContext(ctx, `SELECT key FROM kvstore WHERE key LIKE ? ESCAPE '\' ORDER BY key`, escapedPrefix+"%")
}
if err != nil {
return nil, fmt.Errorf("listing keys: %w", err)
@@ -260,113 +231,16 @@ func (s *kvstoreServiceImpl) List(ctx context.Context, prefix string) ([]string,
// GetStorageUsed returns the total storage used by this plugin in bytes.
func (s *kvstoreServiceImpl) GetStorageUsed(ctx context.Context) (int64, error) {
used, err := s.storageUsed(ctx)
if err != nil {
return 0, err
}
used := s.currentSize.Load()
log.Trace(ctx, "KVStore.GetStorageUsed", "plugin", s.pluginName, "bytes", used)
return used, nil
}
// DeleteByPrefix removes all keys matching the given prefix.
func (s *kvstoreServiceImpl) DeleteByPrefix(ctx context.Context, prefix string) (int64, error) {
if prefix == "" {
return 0, fmt.Errorf("prefix cannot be empty")
}
escapedPrefix := strings.ReplaceAll(prefix, "%", "\\%")
escapedPrefix = strings.ReplaceAll(escapedPrefix, "_", "\\_")
result, err := s.db.ExecContext(ctx, `DELETE FROM kvstore WHERE key LIKE ? ESCAPE '\'`, escapedPrefix+"%")
if err != nil {
return 0, fmt.Errorf("deleting keys: %w", err)
}
count, err := result.RowsAffected()
if err != nil {
return 0, fmt.Errorf("getting deleted count: %w", err)
}
log.Trace(ctx, "KVStore.DeleteByPrefix", "plugin", s.pluginName, "prefix", prefix, "deletedCount", count)
return count, nil
}
// GetMany retrieves multiple values in a single call, processing keys in batches.
func (s *kvstoreServiceImpl) GetMany(ctx context.Context, keys []string) (map[string][]byte, error) {
if len(keys) == 0 {
return map[string][]byte{}, nil
}
const batchSize = 200
result := make(map[string][]byte)
for chunk := range slice.CollectChunks(slices.Values(keys), batchSize) {
placeholders := make([]string, len(chunk))
args := make([]any, len(chunk))
for i, key := range chunk {
placeholders[i] = "?"
args[i] = key
}
query := `SELECT key, value FROM kvstore WHERE key IN (` + strings.Join(placeholders, ",") + `) AND ` + notExpiredFilter //nolint:gosec // placeholders are always "?"
rows, err := s.db.QueryContext(ctx, query, args...)
if err != nil {
return nil, fmt.Errorf("querying values: %w", err)
}
for rows.Next() {
var key string
var value []byte
if err := rows.Scan(&key, &value); err != nil {
rows.Close()
return nil, fmt.Errorf("scanning value: %w", err)
}
result[key] = value
}
if err := rows.Err(); err != nil {
rows.Close()
return nil, fmt.Errorf("iterating values: %w", err)
}
rows.Close()
}
log.Trace(ctx, "KVStore.GetMany", "plugin", s.pluginName, "requested", len(keys), "found", len(result))
return result, nil
}
// cleanupLoop periodically removes expired keys from the database.
// It stops when the provided context is cancelled.
func (s *kvstoreServiceImpl) cleanupLoop(ctx context.Context) {
ticker := time.NewTicker(cleanupInterval)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
s.cleanupExpired(ctx)
}
}
}
// cleanupExpired removes all expired keys from the database to reclaim disk space.
func (s *kvstoreServiceImpl) cleanupExpired(ctx context.Context) {
result, err := s.db.ExecContext(ctx, `DELETE FROM kvstore WHERE expires_at IS NOT NULL AND expires_at <= datetime('now')`)
if err != nil {
log.Error(ctx, "KVStore cleanup: failed to delete expired keys", "plugin", s.pluginName, err)
return
}
if count, err := result.RowsAffected(); err == nil && count > 0 {
log.Debug("KVStore cleanup completed", "plugin", s.pluginName, "deletedKeys", count)
}
}
// Close runs a final cleanup and closes the SQLite database connection.
// The cleanup goroutine is stopped by the context passed to newKVStoreService.
// Close closes the SQLite database connection.
// This is called when the plugin is unloaded.
func (s *kvstoreServiceImpl) Close() error {
if s.db != nil {
log.Debug("Closing plugin kvstore", "plugin", s.pluginName)
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
s.cleanupExpired(ctx)
return s.db.Close()
}
return nil

View File

@@ -12,7 +12,6 @@ import (
"os"
"path/filepath"
"strings"
"time"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/conf/configtest"
@@ -38,7 +37,7 @@ var _ = Describe("KVStoreService", func() {
// Create service with 1KB limit for testing
maxSize := "1KB"
service, err = newKVStoreService(ctx, "test_plugin", &KVStorePermission{MaxSize: &maxSize})
service, err = newKVStoreService("test_plugin", &KVStorePermission{MaxSize: &maxSize})
Expect(err).ToNot(HaveOccurred())
})
@@ -254,7 +253,7 @@ var _ = Describe("KVStoreService", func() {
Expect(service.Close()).To(Succeed())
maxSize := "1KB"
service2, err := newKVStoreService(ctx, "test_plugin", &KVStorePermission{MaxSize: &maxSize})
service2, err := newKVStoreService("test_plugin", &KVStorePermission{MaxSize: &maxSize})
Expect(err).ToNot(HaveOccurred())
defer service2.Close()
@@ -303,7 +302,7 @@ var _ = Describe("KVStoreService", func() {
Describe("Plugin Isolation", func() {
It("isolates data between plugins", func() {
service2, err := newKVStoreService(ctx, "other_plugin", &KVStorePermission{})
service2, err := newKVStoreService("other_plugin", &KVStorePermission{})
Expect(err).ToNot(HaveOccurred())
defer service2.Close()
@@ -322,7 +321,7 @@ var _ = Describe("KVStoreService", func() {
})
It("creates separate database files per plugin", func() {
service2, err := newKVStoreService(ctx, "other_plugin", &KVStorePermission{})
service2, err := newKVStoreService("other_plugin", &KVStorePermission{})
Expect(err).ToNot(HaveOccurred())
defer service2.Close()
@@ -344,309 +343,6 @@ var _ = Describe("KVStoreService", func() {
Expect(err).To(HaveOccurred())
})
})
Describe("TTL Expiration", func() {
It("Get returns not-exists for expired keys", func() {
_, err := service.db.Exec(`
INSERT INTO kvstore (key, value, size, expires_at)
VALUES ('expired_key', 'old', 3, datetime('now', '-1 seconds'))
`)
Expect(err).ToNot(HaveOccurred())
value, exists, err := service.Get(ctx, "expired_key")
Expect(err).ToNot(HaveOccurred())
Expect(exists).To(BeFalse())
Expect(value).To(BeNil())
})
It("Has returns false for expired keys", func() {
_, err := service.db.Exec(`
INSERT INTO kvstore (key, value, size, expires_at)
VALUES ('expired_has', 'old', 3, datetime('now', '-1 seconds'))
`)
Expect(err).ToNot(HaveOccurred())
exists, err := service.Has(ctx, "expired_has")
Expect(err).ToNot(HaveOccurred())
Expect(exists).To(BeFalse())
})
It("List excludes expired keys", func() {
Expect(service.Set(ctx, "live:1", []byte("alive"))).To(Succeed())
_, err := service.db.Exec(`
INSERT INTO kvstore (key, value, size, expires_at)
VALUES ('live:expired', 'dead', 4, datetime('now', '-1 seconds'))
`)
Expect(err).ToNot(HaveOccurred())
keys, err := service.List(ctx, "live:")
Expect(err).ToNot(HaveOccurred())
Expect(keys).To(HaveLen(1))
Expect(keys).To(ContainElement("live:1"))
})
It("Get returns value for non-expired keys with TTL", func() {
_, err := service.db.Exec(`
INSERT INTO kvstore (key, value, size, expires_at)
VALUES ('future_key', 'still alive', 11, datetime('now', '+3600 seconds'))
`)
Expect(err).ToNot(HaveOccurred())
value, exists, err := service.Get(ctx, "future_key")
Expect(err).ToNot(HaveOccurred())
Expect(exists).To(BeTrue())
Expect(value).To(Equal([]byte("still alive")))
})
It("Set clears expires_at from a key previously set with TTL", func() {
// Insert a key with a TTL that has already expired
_, err := service.db.Exec(`
INSERT INTO kvstore (key, value, size, expires_at)
VALUES ('ttl_then_set', 'temp', 4, datetime('now', '-1 seconds'))
`)
Expect(err).ToNot(HaveOccurred())
// Overwrite with Set (no TTL) — should become permanent
err = service.Set(ctx, "ttl_then_set", []byte("permanent"))
Expect(err).ToNot(HaveOccurred())
// Should exist because Set cleared expires_at
value, exists, err := service.Get(ctx, "ttl_then_set")
Expect(err).ToNot(HaveOccurred())
Expect(exists).To(BeTrue())
Expect(value).To(Equal([]byte("permanent")))
// Verify expires_at is actually NULL
var expiresAt *string
Expect(service.db.QueryRow(`SELECT expires_at FROM kvstore WHERE key = 'ttl_then_set'`).Scan(&expiresAt)).To(Succeed())
Expect(expiresAt).To(BeNil())
})
It("expired keys are not counted in storage used", func() {
_, err := service.db.Exec(`
INSERT INTO kvstore (key, value, size, expires_at)
VALUES ('expired_key', '12345', 5, datetime('now', '-1 seconds'))
`)
Expect(err).ToNot(HaveOccurred())
// Expired keys should not be counted
used, err := service.GetStorageUsed(ctx)
Expect(err).ToNot(HaveOccurred())
Expect(used).To(Equal(int64(0)))
})
It("cleanup removes expired rows from disk", func() {
_, err := service.db.Exec(`
INSERT INTO kvstore (key, value, size, expires_at)
VALUES ('cleanup_me', '12345', 5, datetime('now', '-1 seconds'))
`)
Expect(err).ToNot(HaveOccurred())
// Row exists in DB but is logically expired
var count int
Expect(service.db.QueryRow(`SELECT COUNT(*) FROM kvstore`).Scan(&count)).To(Succeed())
Expect(count).To(Equal(1))
service.cleanupExpired(ctx)
// Row should be physically deleted
Expect(service.db.QueryRow(`SELECT COUNT(*) FROM kvstore`).Scan(&count)).To(Succeed())
Expect(count).To(Equal(0))
})
})
Describe("SetWithTTL", func() {
It("stores value that is retrievable before expiry", func() {
err := service.SetWithTTL(ctx, "ttl_key", []byte("ttl_value"), 3600)
Expect(err).ToNot(HaveOccurred())
value, exists, err := service.Get(ctx, "ttl_key")
Expect(err).ToNot(HaveOccurred())
Expect(exists).To(BeTrue())
Expect(value).To(Equal([]byte("ttl_value")))
})
It("value is not retrievable after expiry", func() {
// Insert a key with an already-expired TTL
_, err := service.db.Exec(`
INSERT INTO kvstore (key, value, size, expires_at)
VALUES ('short_ttl', 'gone_soon', 9, datetime('now', '-1 seconds'))
`)
Expect(err).ToNot(HaveOccurred())
_, exists, err := service.Get(ctx, "short_ttl")
Expect(err).ToNot(HaveOccurred())
Expect(exists).To(BeFalse())
})
It("rejects ttlSeconds <= 0", func() {
err := service.SetWithTTL(ctx, "bad_ttl", []byte("value"), 0)
Expect(err).To(HaveOccurred())
Expect(err.Error()).To(ContainSubstring("ttlSeconds must be greater than 0"))
err = service.SetWithTTL(ctx, "bad_ttl", []byte("value"), -5)
Expect(err).To(HaveOccurred())
Expect(err.Error()).To(ContainSubstring("ttlSeconds must be greater than 0"))
})
It("validates key same as Set", func() {
err := service.SetWithTTL(ctx, "", []byte("value"), 60)
Expect(err).To(HaveOccurred())
Expect(err.Error()).To(ContainSubstring("key cannot be empty"))
})
It("enforces size limits same as Set", func() {
bigValue := make([]byte, 2048)
err := service.SetWithTTL(ctx, "big_ttl", bigValue, 60)
Expect(err).To(HaveOccurred())
Expect(err.Error()).To(ContainSubstring("storage limit exceeded"))
})
It("overwrites existing key and updates TTL", func() {
// Insert a key with an already-expired TTL
_, err := service.db.Exec(`
INSERT INTO kvstore (key, value, size, expires_at)
VALUES ('overwrite_ttl', 'first', 5, datetime('now', '-1 seconds'))
`)
Expect(err).ToNot(HaveOccurred())
// Overwrite with a long TTL — should be retrievable
err = service.SetWithTTL(ctx, "overwrite_ttl", []byte("second"), 3600)
Expect(err).ToNot(HaveOccurred())
value, exists, err := service.Get(ctx, "overwrite_ttl")
Expect(err).ToNot(HaveOccurred())
Expect(exists).To(BeTrue())
Expect(value).To(Equal([]byte("second")))
})
It("tracks storage correctly", func() {
err := service.SetWithTTL(ctx, "sized_ttl", []byte("12345"), 3600)
Expect(err).ToNot(HaveOccurred())
used, err := service.GetStorageUsed(ctx)
Expect(err).ToNot(HaveOccurred())
Expect(used).To(Equal(int64(5)))
})
})
Describe("DeleteByPrefix", func() {
BeforeEach(func() {
Expect(service.Set(ctx, "cache:user:1", []byte("Alice"))).To(Succeed())
Expect(service.Set(ctx, "cache:user:2", []byte("Bob"))).To(Succeed())
Expect(service.Set(ctx, "cache:item:1", []byte("Widget"))).To(Succeed())
Expect(service.Set(ctx, "data:important", []byte("keep"))).To(Succeed())
})
It("deletes all keys with the given prefix", func() {
deleted, err := service.DeleteByPrefix(ctx, "cache:user:")
Expect(err).ToNot(HaveOccurred())
Expect(deleted).To(Equal(int64(2)))
keys, err := service.List(ctx, "")
Expect(err).ToNot(HaveOccurred())
Expect(keys).To(HaveLen(2))
Expect(keys).To(ContainElements("cache:item:1", "data:important"))
})
It("rejects empty prefix", func() {
_, err := service.DeleteByPrefix(ctx, "")
Expect(err).To(HaveOccurred())
Expect(err.Error()).To(ContainSubstring("prefix cannot be empty"))
})
It("returns 0 when no keys match", func() {
deleted, err := service.DeleteByPrefix(ctx, "nonexistent:")
Expect(err).ToNot(HaveOccurred())
Expect(deleted).To(Equal(int64(0)))
})
It("updates storage size correctly", func() {
usedBefore, _ := service.GetStorageUsed(ctx)
Expect(usedBefore).To(BeNumerically(">", 0))
_, err := service.DeleteByPrefix(ctx, "cache:")
Expect(err).ToNot(HaveOccurred())
usedAfter, _ := service.GetStorageUsed(ctx)
Expect(usedAfter).To(Equal(int64(4)))
})
It("handles special LIKE characters in prefix", func() {
Expect(service.Set(ctx, "test%special", []byte("v1"))).To(Succeed())
Expect(service.Set(ctx, "test_special", []byte("v2"))).To(Succeed())
Expect(service.Set(ctx, "testXspecial", []byte("v3"))).To(Succeed())
deleted, err := service.DeleteByPrefix(ctx, "test%")
Expect(err).ToNot(HaveOccurred())
Expect(deleted).To(Equal(int64(1)))
exists, _ := service.Has(ctx, "test_special")
Expect(exists).To(BeTrue())
exists, _ = service.Has(ctx, "testXspecial")
Expect(exists).To(BeTrue())
})
It("also deletes expired keys matching prefix", func() {
_, err := service.db.Exec(`
INSERT INTO kvstore (key, value, size, expires_at)
VALUES ('cache:expired', 'old', 3, datetime('now', '-1 seconds'))
`)
Expect(err).ToNot(HaveOccurred())
deleted, err := service.DeleteByPrefix(ctx, "cache:")
Expect(err).ToNot(HaveOccurred())
Expect(deleted).To(Equal(int64(4)))
})
})
Describe("GetMany", func() {
BeforeEach(func() {
Expect(service.Set(ctx, "key1", []byte("value1"))).To(Succeed())
Expect(service.Set(ctx, "key2", []byte("value2"))).To(Succeed())
Expect(service.Set(ctx, "key3", []byte("value3"))).To(Succeed())
})
It("retrieves multiple values at once", func() {
values, err := service.GetMany(ctx, []string{"key1", "key2", "key3"})
Expect(err).ToNot(HaveOccurred())
Expect(values).To(HaveLen(3))
Expect(values["key1"]).To(Equal([]byte("value1")))
Expect(values["key2"]).To(Equal([]byte("value2")))
Expect(values["key3"]).To(Equal([]byte("value3")))
})
It("omits missing keys from result", func() {
values, err := service.GetMany(ctx, []string{"key1", "missing", "key3"})
Expect(err).ToNot(HaveOccurred())
Expect(values).To(HaveLen(2))
Expect(values["key1"]).To(Equal([]byte("value1")))
Expect(values["key3"]).To(Equal([]byte("value3")))
_, hasMissing := values["missing"]
Expect(hasMissing).To(BeFalse())
})
It("returns empty map for empty keys slice", func() {
values, err := service.GetMany(ctx, []string{})
Expect(err).ToNot(HaveOccurred())
Expect(values).To(BeEmpty())
})
It("returns empty map for nil keys slice", func() {
values, err := service.GetMany(ctx, nil)
Expect(err).ToNot(HaveOccurred())
Expect(values).To(BeEmpty())
})
It("excludes expired keys", func() {
_, err := service.db.Exec(`
INSERT INTO kvstore (key, value, size, expires_at)
VALUES ('expired_many', 'old', 3, datetime('now', '-1 seconds'))
`)
Expect(err).ToNot(HaveOccurred())
values, err := service.GetMany(ctx, []string{"key1", "expired_many"})
Expect(err).ToNot(HaveOccurred())
Expect(values).To(HaveLen(1))
Expect(values["key1"]).To(Equal([]byte("value1")))
})
It("handles all keys missing", func() {
values, err := service.GetMany(ctx, []string{"nope1", "nope2"})
Expect(err).ToNot(HaveOccurred())
Expect(values).To(BeEmpty())
})
})
})
var _ = Describe("KVStoreService Integration", Ordered, func() {
@@ -720,21 +416,17 @@ var _ = Describe("KVStoreService Integration", Ordered, func() {
Describe("KVStore Operations via Plugin", func() {
type testKVStoreInput struct {
Operation string `json:"operation"`
Key string `json:"key"`
Value []byte `json:"value,omitempty"`
Prefix string `json:"prefix,omitempty"`
TTLSeconds int64 `json:"ttl_seconds,omitempty"`
Keys []string `json:"keys,omitempty"`
Operation string `json:"operation"`
Key string `json:"key"`
Value []byte `json:"value,omitempty"`
Prefix string `json:"prefix,omitempty"`
}
type testKVStoreOutput struct {
Value []byte `json:"value,omitempty"`
Values map[string][]byte `json:"values,omitempty"`
Exists bool `json:"exists,omitempty"`
Keys []string `json:"keys,omitempty"`
StorageUsed int64 `json:"storage_used,omitempty"`
DeletedCount int64 `json:"deleted_count,omitempty"`
Error *string `json:"error,omitempty"`
Value []byte `json:"value,omitempty"`
Exists bool `json:"exists,omitempty"`
Keys []string `json:"keys,omitempty"`
StorageUsed int64 `json:"storage_used,omitempty"`
Error *string `json:"error,omitempty"`
}
callTestKVStore := func(ctx context.Context, input testKVStoreInput) (*testKVStoreOutput, error) {
@@ -902,107 +594,6 @@ var _ = Describe("KVStoreService Integration", Ordered, func() {
Expect(output.Exists).To(BeTrue())
Expect(output.Value).To(Equal(binaryData))
})
It("should set value with TTL and expire it", func() {
ctx := GinkgoT().Context()
// Set value with 1 second TTL
_, err := callTestKVStore(ctx, testKVStoreInput{
Operation: "set_with_ttl",
Key: "ttl_key",
Value: []byte("temporary"),
TTLSeconds: 1,
})
Expect(err).ToNot(HaveOccurred())
// Immediately should exist
output, err := callTestKVStore(ctx, testKVStoreInput{
Operation: "get",
Key: "ttl_key",
})
Expect(err).ToNot(HaveOccurred())
Expect(output.Exists).To(BeTrue())
Expect(output.Value).To(Equal([]byte("temporary")))
// Wait for expiration
time.Sleep(2 * time.Second)
// Should no longer exist
output, err = callTestKVStore(ctx, testKVStoreInput{
Operation: "get",
Key: "ttl_key",
})
Expect(err).ToNot(HaveOccurred())
Expect(output.Exists).To(BeFalse())
})
It("should delete keys by prefix", func() {
ctx := GinkgoT().Context()
// Set multiple keys with shared prefix
for _, key := range []string{"del_prefix:a", "del_prefix:b", "keep:c"} {
_, err := callTestKVStore(ctx, testKVStoreInput{
Operation: "set",
Key: key,
Value: []byte("value"),
})
Expect(err).ToNot(HaveOccurred())
}
// Delete by prefix
output, err := callTestKVStore(ctx, testKVStoreInput{
Operation: "delete_by_prefix",
Prefix: "del_prefix:",
})
Expect(err).ToNot(HaveOccurred())
Expect(output.DeletedCount).To(Equal(int64(2)))
// Verify remaining key
getOutput, err := callTestKVStore(ctx, testKVStoreInput{
Operation: "has",
Key: "keep:c",
})
Expect(err).ToNot(HaveOccurred())
Expect(getOutput.Exists).To(BeTrue())
// Verify deleted keys are gone
getOutput, err = callTestKVStore(ctx, testKVStoreInput{
Operation: "has",
Key: "del_prefix:a",
})
Expect(err).ToNot(HaveOccurred())
Expect(getOutput.Exists).To(BeFalse())
})
It("should get many values at once", func() {
ctx := GinkgoT().Context()
// Set multiple keys
for _, kv := range []struct{ k, v string }{
{"many:1", "val1"},
{"many:2", "val2"},
{"many:3", "val3"},
} {
_, err := callTestKVStore(ctx, testKVStoreInput{
Operation: "set",
Key: kv.k,
Value: []byte(kv.v),
})
Expect(err).ToNot(HaveOccurred())
}
// Get many, including a missing key
output, err := callTestKVStore(ctx, testKVStoreInput{
Operation: "get_many",
Keys: []string{"many:1", "many:3", "many:missing"},
})
Expect(err).ToNot(HaveOccurred())
Expect(output.Values).To(HaveLen(2))
Expect(output.Values["many:1"]).To(Equal([]byte("val1")))
Expect(output.Values["many:3"]).To(Equal([]byte("val3")))
_, hasMissing := output.Values["many:missing"]
Expect(hasMissing).To(BeFalse())
})
})
Describe("Database Isolation", func() {

55
plugins/lyrics_adapter.go Normal file
View File

@@ -0,0 +1,55 @@
package plugins
import (
"context"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/plugins/capabilities"
)
const CapabilityLyrics Capability = "Lyrics"
const (
FuncLyricsGetLyrics = "nd_lyrics_get_lyrics"
)
func init() {
registerCapability(
CapabilityLyrics,
FuncLyricsGetLyrics,
)
}
// LyricsPlugin adapts a WASM plugin with the Lyrics capability.
type LyricsPlugin struct {
name string
plugin *plugin
}
// GetLyrics calls the plugin to fetch lyrics, then parses the raw text responses
// using model.ToLyrics.
func (l *LyricsPlugin) GetLyrics(ctx context.Context, mf *model.MediaFile) (model.LyricList, error) {
req := capabilities.GetLyricsRequest{
Track: mediaFileToTrackInfo(mf),
}
resp, err := callPluginFunction[capabilities.GetLyricsRequest, capabilities.GetLyricsResponse](
ctx, l.plugin, FuncLyricsGetLyrics, req,
)
if err != nil {
return nil, err
}
var result model.LyricList
for _, lt := range resp.Lyrics {
parsed, err := model.ToLyrics(lt.Lang, lt.Text)
if err != nil {
log.Warn(ctx, "Error parsing plugin lyrics", "plugin", l.name, err)
continue
}
if parsed != nil && !parsed.IsEmpty() {
result = append(result, *parsed)
}
}
return result, nil
}

View File

@@ -0,0 +1,84 @@
//go:build !windows
package plugins
import (
"github.com/navidrome/navidrome/model"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
var _ = Describe("LyricsPlugin", Ordered, func() {
var (
lyricsManager *Manager
provider *LyricsPlugin
)
BeforeAll(func() {
lyricsManager, _ = createTestManagerWithPlugins(nil,
"test-lyrics"+PackageExtension,
"test-metadata-agent"+PackageExtension,
)
p, ok := lyricsManager.LoadLyricsProvider("test-lyrics")
Expect(ok).To(BeTrue())
provider = p.(*LyricsPlugin)
})
Describe("LoadLyricsProvider", func() {
It("returns a lyrics provider for a plugin with Lyrics capability", func() {
Expect(provider).ToNot(BeNil())
})
It("returns false for a plugin without Lyrics capability", func() {
_, ok := lyricsManager.LoadLyricsProvider("test-metadata-agent")
Expect(ok).To(BeFalse())
})
It("returns false for non-existent plugin", func() {
_, ok := lyricsManager.LoadLyricsProvider("non-existent")
Expect(ok).To(BeFalse())
})
})
Describe("GetLyrics", func() {
It("successfully returns lyrics from the plugin", func() {
track := &model.MediaFile{
ID: "track-1",
Title: "Test Song",
Artist: "Test Artist",
}
result, err := provider.GetLyrics(GinkgoT().Context(), track)
Expect(err).ToNot(HaveOccurred())
Expect(result).To(HaveLen(1))
Expect(result[0].Line).ToNot(BeEmpty())
Expect(result[0].Line[0].Value).To(ContainSubstring("Test Song"))
})
It("returns error when plugin returns error", func() {
manager, _ := createTestManagerWithPlugins(map[string]map[string]string{
"test-lyrics": {"error": "service unavailable"},
}, "test-lyrics"+PackageExtension)
p, ok := manager.LoadLyricsProvider("test-lyrics")
Expect(ok).To(BeTrue())
track := &model.MediaFile{ID: "track-1", Title: "Test Song"}
_, err := p.GetLyrics(GinkgoT().Context(), track)
Expect(err).To(HaveOccurred())
})
})
Describe("PluginNames", func() {
It("returns plugin names with Lyrics capability", func() {
names := lyricsManager.PluginNames("Lyrics")
Expect(names).To(ContainElement("test-lyrics"))
})
It("does not return metadata agent plugins for Lyrics capability", func() {
names := lyricsManager.PluginNames("Lyrics")
Expect(names).ToNot(ContainElement("test-metadata-agent"))
})
})
})

View File

@@ -16,6 +16,7 @@ import (
extism "github.com/extism/go-sdk"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/core/agents"
"github.com/navidrome/navidrome/core/lyrics"
"github.com/navidrome/navidrome/core/scrobbler"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model"
@@ -276,6 +277,22 @@ func (m *Manager) LoadScrobbler(name string) (scrobbler.Scrobbler, bool) {
}, true
}
// LoadLyricsProvider loads and returns a lyrics provider plugin by name.
func (m *Manager) LoadLyricsProvider(name string) (lyrics.Lyrics, bool) {
m.mu.RLock()
plugin, ok := m.plugins[name]
m.mu.RUnlock()
if !ok || !hasCapability(plugin.capabilities, CapabilityLyrics) {
return nil, false
}
return &LyricsPlugin{
name: plugin.name,
plugin: plugin,
}, true
}
// PluginInfo contains basic information about a plugin for metrics/insights.
type PluginInfo struct {
Name string

View File

@@ -103,7 +103,7 @@ var hostServices = []hostServiceEntry{
hasPermission: func(p *Permissions) bool { return p != nil && p.Kvstore != nil },
create: func(ctx *serviceContext) ([]extism.HostFunction, io.Closer) {
perm := ctx.permissions.Kvstore
service, err := newKVStoreService(ctx.manager.ctx, ctx.pluginName, perm)
service, err := newKVStoreService(ctx.pluginName, perm)
if err != nil {
log.Error("Failed to create KVStore service", "plugin", ctx.pluginName, err)
return nil, nil

View File

@@ -1,47 +0,0 @@
package plugins
import (
"database/sql"
"fmt"
)
// migrateDB applies schema migrations to a SQLite database.
//
// Each entry in migrations is a single SQL statement. The current schema version
// is tracked using SQLite's built-in PRAGMA user_version. Only statements after
// the current version are executed, within a single transaction.
func migrateDB(db *sql.DB, migrations []string) error {
var version int
if err := db.QueryRow(`PRAGMA user_version`).Scan(&version); err != nil {
return fmt.Errorf("reading schema version: %w", err)
}
if version >= len(migrations) {
return nil
}
tx, err := db.Begin()
if err != nil {
return fmt.Errorf("starting migration transaction: %w", err)
}
defer func() { _ = tx.Rollback() }()
for i := version; i < len(migrations); i++ {
if _, err := tx.Exec(migrations[i]); err != nil {
return fmt.Errorf("migration %d failed: %w", i+1, err)
}
}
// PRAGMA statements cannot be executed inside a transaction in some SQLite
// drivers, but with mattn/go-sqlite3 this works. We set it inside the tx
// so that a failed commit leaves the version unchanged.
if _, err := tx.Exec(fmt.Sprintf(`PRAGMA user_version = %d`, len(migrations))); err != nil {
return fmt.Errorf("updating schema version: %w", err)
}
if err := tx.Commit(); err != nil {
return fmt.Errorf("committing migrations: %w", err)
}
return nil
}

View File

@@ -1,99 +0,0 @@
//go:build !windows
package plugins
import (
"database/sql"
_ "github.com/mattn/go-sqlite3"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
var _ = Describe("migrateDB", func() {
var db *sql.DB
BeforeEach(func() {
var err error
db, err = sql.Open("sqlite3", ":memory:")
Expect(err).ToNot(HaveOccurred())
})
AfterEach(func() {
if db != nil {
db.Close()
}
})
getUserVersion := func() int {
var version int
Expect(db.QueryRow(`PRAGMA user_version`).Scan(&version)).To(Succeed())
return version
}
It("applies all migrations on a fresh database", func() {
migrations := []string{
`CREATE TABLE test (id INTEGER PRIMARY KEY, name TEXT)`,
`ALTER TABLE test ADD COLUMN email TEXT`,
}
Expect(migrateDB(db, migrations)).To(Succeed())
Expect(getUserVersion()).To(Equal(2))
// Verify schema
_, err := db.Exec(`INSERT INTO test (id, name, email) VALUES (1, 'Alice', 'alice@test.com')`)
Expect(err).ToNot(HaveOccurred())
})
It("skips already applied migrations", func() {
migrations1 := []string{
`CREATE TABLE test (id INTEGER PRIMARY KEY, name TEXT)`,
}
Expect(migrateDB(db, migrations1)).To(Succeed())
Expect(getUserVersion()).To(Equal(1))
// Add a new migration
migrations2 := []string{
`CREATE TABLE test (id INTEGER PRIMARY KEY, name TEXT)`,
`ALTER TABLE test ADD COLUMN email TEXT`,
}
Expect(migrateDB(db, migrations2)).To(Succeed())
Expect(getUserVersion()).To(Equal(2))
// Verify the new column exists
_, err := db.Exec(`INSERT INTO test (id, name, email) VALUES (1, 'Alice', 'alice@test.com')`)
Expect(err).ToNot(HaveOccurred())
})
It("is a no-op when all migrations are applied", func() {
migrations := []string{
`CREATE TABLE test (id INTEGER PRIMARY KEY)`,
}
Expect(migrateDB(db, migrations)).To(Succeed())
Expect(migrateDB(db, migrations)).To(Succeed())
Expect(getUserVersion()).To(Equal(1))
})
It("is a no-op with empty migrations slice", func() {
Expect(migrateDB(db, nil)).To(Succeed())
Expect(getUserVersion()).To(Equal(0))
})
It("rolls back on failure", func() {
migrations := []string{
`CREATE TABLE test (id INTEGER PRIMARY KEY)`,
`INVALID SQL STATEMENT`,
}
err := migrateDB(db, migrations)
Expect(err).To(HaveOccurred())
Expect(err.Error()).To(ContainSubstring("migration 2 failed"))
// Version should remain 0 (rolled back)
Expect(getUserVersion()).To(Equal(0))
// Table should not exist (rolled back)
_, err = db.Exec(`INSERT INTO test (id) VALUES (1)`)
Expect(err).To(HaveOccurred())
})
})

View File

@@ -19,20 +19,15 @@ import (
//go:wasmimport extism:host/user kvstore_set
func kvstore_set(uint64) uint64
// kvstore_setwithttl is the host function provided by Navidrome.
//
//go:wasmimport extism:host/user kvstore_setwithttl
func kvstore_setwithttl(uint64) uint64
// kvstore_get is the host function provided by Navidrome.
//
//go:wasmimport extism:host/user kvstore_get
func kvstore_get(uint64) uint64
// kvstore_getmany is the host function provided by Navidrome.
// kvstore_delete is the host function provided by Navidrome.
//
//go:wasmimport extism:host/user kvstore_getmany
func kvstore_getmany(uint64) uint64
//go:wasmimport extism:host/user kvstore_delete
func kvstore_delete(uint64) uint64
// kvstore_has is the host function provided by Navidrome.
//
@@ -44,16 +39,6 @@ func kvstore_has(uint64) uint64
//go:wasmimport extism:host/user kvstore_list
func kvstore_list(uint64) uint64
// kvstore_delete is the host function provided by Navidrome.
//
//go:wasmimport extism:host/user kvstore_delete
func kvstore_delete(uint64) uint64
// kvstore_deletebyprefix is the host function provided by Navidrome.
//
//go:wasmimport extism:host/user kvstore_deletebyprefix
func kvstore_deletebyprefix(uint64) uint64
// kvstore_getstorageused is the host function provided by Navidrome.
//
//go:wasmimport extism:host/user kvstore_getstorageused
@@ -64,12 +49,6 @@ type kVStoreSetRequest struct {
Value []byte `json:"value"`
}
type kVStoreSetWithTTLRequest struct {
Key string `json:"key"`
Value []byte `json:"value"`
TtlSeconds int64 `json:"ttlSeconds"`
}
type kVStoreGetRequest struct {
Key string `json:"key"`
}
@@ -80,13 +59,8 @@ type kVStoreGetResponse struct {
Error string `json:"error,omitempty"`
}
type kVStoreGetManyRequest struct {
Keys []string `json:"keys"`
}
type kVStoreGetManyResponse struct {
Values map[string][]byte `json:"values,omitempty"`
Error string `json:"error,omitempty"`
type kVStoreDeleteRequest struct {
Key string `json:"key"`
}
type kVStoreHasRequest struct {
@@ -107,19 +81,6 @@ type kVStoreListResponse struct {
Error string `json:"error,omitempty"`
}
type kVStoreDeleteRequest struct {
Key string `json:"key"`
}
type kVStoreDeleteByPrefixRequest struct {
Prefix string `json:"prefix"`
}
type kVStoreDeleteByPrefixResponse struct {
DeletedCount int64 `json:"deletedCount,omitempty"`
Error string `json:"error,omitempty"`
}
type kVStoreGetStorageUsedResponse struct {
Bytes int64 `json:"bytes,omitempty"`
Error string `json:"error,omitempty"`
@@ -166,52 +127,6 @@ func KVStoreSet(key string, value []byte) error {
return nil
}
// KVStoreSetWithTTL calls the kvstore_setwithttl host function.
// SetWithTTL stores a byte value with the given key and a time-to-live.
//
// After ttlSeconds, the key is treated as non-existent and will be
// cleaned up lazily. ttlSeconds must be greater than 0.
//
// Parameters:
// - key: The storage key (max 256 bytes, UTF-8)
// - value: The byte slice to store
// - ttlSeconds: Time-to-live in seconds (must be > 0)
//
// Returns an error if the storage limit would be exceeded or the operation fails.
func KVStoreSetWithTTL(key string, value []byte, ttlSeconds int64) error {
// Marshal request to JSON
req := kVStoreSetWithTTLRequest{
Key: key,
Value: value,
TtlSeconds: ttlSeconds,
}
reqBytes, err := json.Marshal(req)
if err != nil {
return err
}
reqMem := pdk.AllocateBytes(reqBytes)
defer reqMem.Free()
// Call the host function
responsePtr := kvstore_setwithttl(reqMem.Offset())
// Read the response from memory
responseMem := pdk.FindMemory(responsePtr)
responseBytes := responseMem.ReadBytes()
// Parse error-only response
var response struct {
Error string `json:"error,omitempty"`
}
if err := json.Unmarshal(responseBytes, &response); err != nil {
return err
}
if response.Error != "" {
return errors.New(response.Error)
}
return nil
}
// KVStoreGet calls the kvstore_get host function.
// Get retrieves a byte value from storage.
//
@@ -252,45 +167,43 @@ func KVStoreGet(key string) ([]byte, bool, error) {
return response.Value, response.Exists, nil
}
// KVStoreGetMany calls the kvstore_getmany host function.
// GetMany retrieves multiple values in a single call.
// KVStoreDelete calls the kvstore_delete host function.
// Delete removes a value from storage.
//
// Parameters:
// - keys: The storage keys to retrieve
// - key: The storage key
//
// Returns a map of key to value for keys that exist and have not expired.
// Missing or expired keys are omitted from the result.
func KVStoreGetMany(keys []string) (map[string][]byte, error) {
// Returns an error if the operation fails. Does not return an error if the key doesn't exist.
func KVStoreDelete(key string) error {
// Marshal request to JSON
req := kVStoreGetManyRequest{
Keys: keys,
req := kVStoreDeleteRequest{
Key: key,
}
reqBytes, err := json.Marshal(req)
if err != nil {
return nil, err
return err
}
reqMem := pdk.AllocateBytes(reqBytes)
defer reqMem.Free()
// Call the host function
responsePtr := kvstore_getmany(reqMem.Offset())
responsePtr := kvstore_delete(reqMem.Offset())
// Read the response from memory
responseMem := pdk.FindMemory(responsePtr)
responseBytes := responseMem.ReadBytes()
// Parse the response
var response kVStoreGetManyResponse
// Parse error-only response
var response struct {
Error string `json:"error,omitempty"`
}
if err := json.Unmarshal(responseBytes, &response); err != nil {
return nil, err
return err
}
// Convert Error field to Go error
if response.Error != "" {
return nil, errors.New(response.Error)
return errors.New(response.Error)
}
return response.Values, nil
return nil
}
// KVStoreHas calls the kvstore_has host function.
@@ -373,85 +286,6 @@ func KVStoreList(prefix string) ([]string, error) {
return response.Keys, nil
}
// KVStoreDelete calls the kvstore_delete host function.
// Delete removes a value from storage.
//
// Parameters:
// - key: The storage key
//
// Returns an error if the operation fails. Does not return an error if the key doesn't exist.
func KVStoreDelete(key string) error {
// Marshal request to JSON
req := kVStoreDeleteRequest{
Key: key,
}
reqBytes, err := json.Marshal(req)
if err != nil {
return err
}
reqMem := pdk.AllocateBytes(reqBytes)
defer reqMem.Free()
// Call the host function
responsePtr := kvstore_delete(reqMem.Offset())
// Read the response from memory
responseMem := pdk.FindMemory(responsePtr)
responseBytes := responseMem.ReadBytes()
// Parse error-only response
var response struct {
Error string `json:"error,omitempty"`
}
if err := json.Unmarshal(responseBytes, &response); err != nil {
return err
}
if response.Error != "" {
return errors.New(response.Error)
}
return nil
}
// KVStoreDeleteByPrefix calls the kvstore_deletebyprefix host function.
// DeleteByPrefix removes all keys matching the given prefix.
//
// Parameters:
// - prefix: Key prefix to match (must not be empty)
//
// Returns the number of keys deleted. Includes expired keys.
func KVStoreDeleteByPrefix(prefix string) (int64, error) {
// Marshal request to JSON
req := kVStoreDeleteByPrefixRequest{
Prefix: prefix,
}
reqBytes, err := json.Marshal(req)
if err != nil {
return 0, err
}
reqMem := pdk.AllocateBytes(reqBytes)
defer reqMem.Free()
// Call the host function
responsePtr := kvstore_deletebyprefix(reqMem.Offset())
// Read the response from memory
responseMem := pdk.FindMemory(responsePtr)
responseBytes := responseMem.ReadBytes()
// Parse the response
var response kVStoreDeleteByPrefixResponse
if err := json.Unmarshal(responseBytes, &response); err != nil {
return 0, err
}
// Convert Error field to Go error
if response.Error != "" {
return 0, errors.New(response.Error)
}
return response.DeletedCount, nil
}
// KVStoreGetStorageUsed calls the kvstore_getstorageused host function.
// GetStorageUsed returns the total storage used by this plugin in bytes.
func KVStoreGetStorageUsed() (int64, error) {

View File

@@ -37,28 +37,6 @@ func KVStoreSet(key string, value []byte) error {
return KVStoreMock.Set(key, value)
}
// SetWithTTL is the mock method for KVStoreSetWithTTL.
func (m *mockKVStoreService) SetWithTTL(key string, value []byte, ttlSeconds int64) error {
args := m.Called(key, value, ttlSeconds)
return args.Error(0)
}
// KVStoreSetWithTTL delegates to the mock instance.
// SetWithTTL stores a byte value with the given key and a time-to-live.
//
// After ttlSeconds, the key is treated as non-existent and will be
// cleaned up lazily. ttlSeconds must be greater than 0.
//
// Parameters:
// - key: The storage key (max 256 bytes, UTF-8)
// - value: The byte slice to store
// - ttlSeconds: Time-to-live in seconds (must be > 0)
//
// Returns an error if the storage limit would be exceeded or the operation fails.
func KVStoreSetWithTTL(key string, value []byte, ttlSeconds int64) error {
return KVStoreMock.SetWithTTL(key, value, ttlSeconds)
}
// Get is the mock method for KVStoreGet.
func (m *mockKVStoreService) Get(key string) ([]byte, bool, error) {
args := m.Called(key)
@@ -76,22 +54,21 @@ func KVStoreGet(key string) ([]byte, bool, error) {
return KVStoreMock.Get(key)
}
// GetMany is the mock method for KVStoreGetMany.
func (m *mockKVStoreService) GetMany(keys []string) (map[string][]byte, error) {
args := m.Called(keys)
return args.Get(0).(map[string][]byte), args.Error(1)
// Delete is the mock method for KVStoreDelete.
func (m *mockKVStoreService) Delete(key string) error {
args := m.Called(key)
return args.Error(0)
}
// KVStoreGetMany delegates to the mock instance.
// GetMany retrieves multiple values in a single call.
// KVStoreDelete delegates to the mock instance.
// Delete removes a value from storage.
//
// Parameters:
// - keys: The storage keys to retrieve
// - key: The storage key
//
// Returns a map of key to value for keys that exist and have not expired.
// Missing or expired keys are omitted from the result.
func KVStoreGetMany(keys []string) (map[string][]byte, error) {
return KVStoreMock.GetMany(keys)
// Returns an error if the operation fails. Does not return an error if the key doesn't exist.
func KVStoreDelete(key string) error {
return KVStoreMock.Delete(key)
}
// Has is the mock method for KVStoreHas.
@@ -128,40 +105,6 @@ func KVStoreList(prefix string) ([]string, error) {
return KVStoreMock.List(prefix)
}
// Delete is the mock method for KVStoreDelete.
func (m *mockKVStoreService) Delete(key string) error {
args := m.Called(key)
return args.Error(0)
}
// KVStoreDelete delegates to the mock instance.
// Delete removes a value from storage.
//
// Parameters:
// - key: The storage key
//
// Returns an error if the operation fails. Does not return an error if the key doesn't exist.
func KVStoreDelete(key string) error {
return KVStoreMock.Delete(key)
}
// DeleteByPrefix is the mock method for KVStoreDeleteByPrefix.
func (m *mockKVStoreService) DeleteByPrefix(prefix string) (int64, error) {
args := m.Called(prefix)
return args.Get(0).(int64), args.Error(1)
}
// KVStoreDeleteByPrefix delegates to the mock instance.
// DeleteByPrefix removes all keys matching the given prefix.
//
// Parameters:
// - prefix: Key prefix to match (must not be empty)
//
// Returns the number of keys deleted. Includes expired keys.
func KVStoreDeleteByPrefix(prefix string) (int64, error) {
return KVStoreMock.DeleteByPrefix(prefix)
}
// GetStorageUsed is the mock method for KVStoreGetStorageUsed.
func (m *mockKVStoreService) GetStorageUsed() (int64, error) {
args := m.Called()

View File

@@ -0,0 +1,118 @@
// Code generated by ndpgen. DO NOT EDIT.
//
// This file contains export wrappers for the Lyrics capability.
// It is intended for use in Navidrome plugins built with TinyGo.
//
//go:build wasip1
package lyrics
import (
"github.com/navidrome/navidrome/plugins/pdk/go/pdk"
)
// ArtistRef is a reference to an artist with name and optional MBID.
type ArtistRef struct {
// ID is the internal Navidrome artist ID (if known).
ID string `json:"id,omitempty"`
// Name is the artist name.
Name string `json:"name"`
// MBID is the MusicBrainz ID for the artist.
MBID string `json:"mbid,omitempty"`
}
// GetLyricsRequest contains the track information for lyrics lookup.
type GetLyricsRequest struct {
Track TrackInfo `json:"track"`
}
// GetLyricsResponse contains the lyrics returned by the plugin.
type GetLyricsResponse struct {
Lyrics []LyricsText `json:"lyrics"`
}
// LyricsText represents a single set of lyrics in raw text format.
// Text can be plain text or LRC format — Navidrome will parse it.
type LyricsText struct {
Lang string `json:"lang,omitempty"`
Text string `json:"text"`
}
// TrackInfo contains track metadata for scrobbling.
type TrackInfo struct {
// ID is the internal Navidrome track ID.
ID string `json:"id"`
// Title is the track title.
Title string `json:"title"`
// Album is the album name.
Album string `json:"album"`
// Artist is the formatted artist name for display (e.g., "Artist1 • Artist2").
Artist string `json:"artist"`
// AlbumArtist is the formatted album artist name for display.
AlbumArtist string `json:"albumArtist"`
// Artists is the list of track artists.
Artists []ArtistRef `json:"artists"`
// AlbumArtists is the list of album artists.
AlbumArtists []ArtistRef `json:"albumArtists"`
// Duration is the track duration in seconds.
Duration float32 `json:"duration"`
// TrackNumber is the track number on the album.
TrackNumber int32 `json:"trackNumber"`
// DiscNumber is the disc number.
DiscNumber int32 `json:"discNumber"`
// MBZRecordingID is the MusicBrainz recording ID.
MBZRecordingID string `json:"mbzRecordingId,omitempty"`
// MBZAlbumID is the MusicBrainz album/release ID.
MBZAlbumID string `json:"mbzAlbumId,omitempty"`
// MBZReleaseGroupID is the MusicBrainz release group ID.
MBZReleaseGroupID string `json:"mbzReleaseGroupId,omitempty"`
// MBZReleaseTrackID is the MusicBrainz release track ID.
MBZReleaseTrackID string `json:"mbzReleaseTrackId,omitempty"`
}
// Lyrics requires all methods to be implemented.
// Lyrics provides lyrics for a given track from external sources.
type Lyrics interface {
// GetLyrics
GetLyrics(GetLyricsRequest) (GetLyricsResponse, error)
} // Internal implementation holders
var (
lyricsImpl func(GetLyricsRequest) (GetLyricsResponse, error)
)
// Register registers a lyrics implementation.
// All methods are required.
func Register(impl Lyrics) {
lyricsImpl = impl.GetLyrics
}
// NotImplementedCode is the standard return code for unimplemented functions.
// The host recognizes this and skips the plugin gracefully.
const NotImplementedCode int32 = -2
//go:wasmexport nd_lyrics_get_lyrics
func _NdLyricsGetLyrics() int32 {
if lyricsImpl == nil {
// Return standard code - host will skip this plugin gracefully
return NotImplementedCode
}
var input GetLyricsRequest
if err := pdk.InputJSON(&input); err != nil {
pdk.SetError(err)
return -1
}
output, err := lyricsImpl(input)
if err != nil {
pdk.SetError(err)
return -1
}
if err := pdk.OutputJSON(output); err != nil {
pdk.SetError(err)
return -1
}
return 0
}

View File

@@ -0,0 +1,82 @@
// Code generated by ndpgen. DO NOT EDIT.
//
// This file provides stub implementations for non-WASM platforms.
// It allows Go plugins to compile and run tests outside of WASM,
// but the actual functionality is only available in WASM builds.
//
//go:build !wasip1
package lyrics
// ArtistRef is a reference to an artist with name and optional MBID.
type ArtistRef struct {
// ID is the internal Navidrome artist ID (if known).
ID string `json:"id,omitempty"`
// Name is the artist name.
Name string `json:"name"`
// MBID is the MusicBrainz ID for the artist.
MBID string `json:"mbid,omitempty"`
}
// GetLyricsRequest contains the track information for lyrics lookup.
type GetLyricsRequest struct {
Track TrackInfo `json:"track"`
}
// GetLyricsResponse contains the lyrics returned by the plugin.
type GetLyricsResponse struct {
Lyrics []LyricsText `json:"lyrics"`
}
// LyricsText represents a single set of lyrics in raw text format.
// Text can be plain text or LRC format — Navidrome will parse it.
type LyricsText struct {
Lang string `json:"lang,omitempty"`
Text string `json:"text"`
}
// TrackInfo contains track metadata for scrobbling.
type TrackInfo struct {
// ID is the internal Navidrome track ID.
ID string `json:"id"`
// Title is the track title.
Title string `json:"title"`
// Album is the album name.
Album string `json:"album"`
// Artist is the formatted artist name for display (e.g., "Artist1 • Artist2").
Artist string `json:"artist"`
// AlbumArtist is the formatted album artist name for display.
AlbumArtist string `json:"albumArtist"`
// Artists is the list of track artists.
Artists []ArtistRef `json:"artists"`
// AlbumArtists is the list of album artists.
AlbumArtists []ArtistRef `json:"albumArtists"`
// Duration is the track duration in seconds.
Duration float32 `json:"duration"`
// TrackNumber is the track number on the album.
TrackNumber int32 `json:"trackNumber"`
// DiscNumber is the disc number.
DiscNumber int32 `json:"discNumber"`
// MBZRecordingID is the MusicBrainz recording ID.
MBZRecordingID string `json:"mbzRecordingId,omitempty"`
// MBZAlbumID is the MusicBrainz album/release ID.
MBZAlbumID string `json:"mbzAlbumId,omitempty"`
// MBZReleaseGroupID is the MusicBrainz release group ID.
MBZReleaseGroupID string `json:"mbzReleaseGroupId,omitempty"`
// MBZReleaseTrackID is the MusicBrainz release track ID.
MBZReleaseTrackID string `json:"mbzReleaseTrackId,omitempty"`
}
// Lyrics requires all methods to be implemented.
// Lyrics provides lyrics for a given track from external sources.
type Lyrics interface {
// GetLyrics
GetLyrics(GetLyricsRequest) (GetLyricsResponse, error)
}
// NotImplementedCode is the standard return code for unimplemented functions.
const NotImplementedCode int32 = -2
// Register is a no-op on non-WASM platforms.
// This stub allows code to compile outside of WASM.
func Register(_ Lyrics) {}

View File

@@ -26,20 +26,14 @@ def _kvstore_set(offset: int) -> int:
...
@extism.import_fn("extism:host/user", "kvstore_setwithttl")
def _kvstore_setwithttl(offset: int) -> int:
"""Raw host function - do not call directly."""
...
@extism.import_fn("extism:host/user", "kvstore_get")
def _kvstore_get(offset: int) -> int:
"""Raw host function - do not call directly."""
...
@extism.import_fn("extism:host/user", "kvstore_getmany")
def _kvstore_getmany(offset: int) -> int:
@extism.import_fn("extism:host/user", "kvstore_delete")
def _kvstore_delete(offset: int) -> int:
"""Raw host function - do not call directly."""
...
@@ -56,18 +50,6 @@ def _kvstore_list(offset: int) -> int:
...
@extism.import_fn("extism:host/user", "kvstore_delete")
def _kvstore_delete(offset: int) -> int:
"""Raw host function - do not call directly."""
...
@extism.import_fn("extism:host/user", "kvstore_deletebyprefix")
def _kvstore_deletebyprefix(offset: int) -> int:
"""Raw host function - do not call directly."""
...
@extism.import_fn("extism:host/user", "kvstore_getstorageused")
def _kvstore_getstorageused(offset: int) -> int:
"""Raw host function - do not call directly."""
@@ -112,43 +94,6 @@ Returns an error if the storage limit would be exceeded or the operation fails.
def kvstore_set_with_ttl(key: str, value: bytes, ttl_seconds: int) -> None:
"""SetWithTTL stores a byte value with the given key and a time-to-live.
After ttlSeconds, the key is treated as non-existent and will be
cleaned up lazily. ttlSeconds must be greater than 0.
Parameters:
- key: The storage key (max 256 bytes, UTF-8)
- value: The byte slice to store
- ttlSeconds: Time-to-live in seconds (must be > 0)
Returns an error if the storage limit would be exceeded or the operation fails.
Args:
key: str parameter.
value: bytes parameter.
ttl_seconds: int parameter.
Raises:
HostFunctionError: If the host function returns an error.
"""
request = {
"key": key,
"value": base64.b64encode(value).decode("ascii"),
"ttlSeconds": ttl_seconds,
}
request_bytes = json.dumps(request).encode("utf-8")
request_mem = extism.memory.alloc(request_bytes)
response_offset = _kvstore_setwithttl(request_mem.offset)
response_mem = extism.memory.find(response_offset)
response = json.loads(extism.memory.string(response_mem))
if response.get("error"):
raise HostFunctionError(response["error"])
def kvstore_get(key: str) -> KVStoreGetResult:
"""Get retrieves a byte value from storage.
@@ -184,37 +129,32 @@ Returns the value and whether the key exists.
)
def kvstore_get_many(keys: Any) -> Any:
"""GetMany retrieves multiple values in a single call.
def kvstore_delete(key: str) -> None:
"""Delete removes a value from storage.
Parameters:
- keys: The storage keys to retrieve
- key: The storage key
Returns a map of key to value for keys that exist and have not expired.
Missing or expired keys are omitted from the result.
Returns an error if the operation fails. Does not return an error if the key doesn't exist.
Args:
keys: Any parameter.
Returns:
Any: The result value.
key: str parameter.
Raises:
HostFunctionError: If the host function returns an error.
"""
request = {
"keys": keys,
"key": key,
}
request_bytes = json.dumps(request).encode("utf-8")
request_mem = extism.memory.alloc(request_bytes)
response_offset = _kvstore_getmany(request_mem.offset)
response_offset = _kvstore_delete(request_mem.offset)
response_mem = extism.memory.find(response_offset)
response = json.loads(extism.memory.string(response_mem))
if response.get("error"):
raise HostFunctionError(response["error"])
return response.get("values", None)
def kvstore_has(key: str) -> bool:
@@ -281,66 +221,6 @@ Returns a slice of matching keys.
return response.get("keys", None)
def kvstore_delete(key: str) -> None:
"""Delete removes a value from storage.
Parameters:
- key: The storage key
Returns an error if the operation fails. Does not return an error if the key doesn't exist.
Args:
key: str parameter.
Raises:
HostFunctionError: If the host function returns an error.
"""
request = {
"key": key,
}
request_bytes = json.dumps(request).encode("utf-8")
request_mem = extism.memory.alloc(request_bytes)
response_offset = _kvstore_delete(request_mem.offset)
response_mem = extism.memory.find(response_offset)
response = json.loads(extism.memory.string(response_mem))
if response.get("error"):
raise HostFunctionError(response["error"])
def kvstore_delete_by_prefix(prefix: str) -> int:
"""DeleteByPrefix removes all keys matching the given prefix.
Parameters:
- prefix: Key prefix to match (must not be empty)
Returns the number of keys deleted. Includes expired keys.
Args:
prefix: str parameter.
Returns:
int: The result value.
Raises:
HostFunctionError: If the host function returns an error.
"""
request = {
"prefix": prefix,
}
request_bytes = json.dumps(request).encode("utf-8")
request_mem = extism.memory.alloc(request_bytes)
response_offset = _kvstore_deletebyprefix(request_mem.offset)
response_mem = extism.memory.find(response_offset)
response = json.loads(extism.memory.string(response_mem))
if response.get("error"):
raise HostFunctionError(response["error"])
return response.get("deletedCount", 0)
def kvstore_get_storage_used() -> int:
"""GetStorageUsed returns the total storage used by this plugin in bytes.

View File

@@ -6,6 +6,7 @@
//! for implementing Navidrome plugin capabilities in Rust.
pub mod lifecycle;
pub mod lyrics;
pub mod metadata;
pub mod scheduler;
pub mod scrobbler;

View File

@@ -0,0 +1,148 @@
// Code generated by ndpgen. DO NOT EDIT.
//
// This file contains export wrappers for the Lyrics capability.
// It is intended for use in Navidrome plugins built with extism-pdk.
use serde::{Deserialize, Serialize};
// Helper functions for skip_serializing_if with numeric types
#[allow(dead_code)]
fn is_zero_i32(value: &i32) -> bool { *value == 0 }
#[allow(dead_code)]
fn is_zero_u32(value: &u32) -> bool { *value == 0 }
#[allow(dead_code)]
fn is_zero_i64(value: &i64) -> bool { *value == 0 }
#[allow(dead_code)]
fn is_zero_u64(value: &u64) -> bool { *value == 0 }
#[allow(dead_code)]
fn is_zero_f32(value: &f32) -> bool { *value == 0.0 }
#[allow(dead_code)]
fn is_zero_f64(value: &f64) -> bool { *value == 0.0 }
/// ArtistRef is a reference to an artist with name and optional MBID.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ArtistRef {
/// ID is the internal Navidrome artist ID (if known).
#[serde(default, skip_serializing_if = "String::is_empty")]
pub id: String,
/// Name is the artist name.
#[serde(default)]
pub name: String,
/// MBID is the MusicBrainz ID for the artist.
#[serde(default, skip_serializing_if = "String::is_empty")]
pub mbid: String,
}
/// GetLyricsRequest contains the track information for lyrics lookup.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct GetLyricsRequest {
#[serde(default)]
pub track: TrackInfo,
}
/// GetLyricsResponse contains the lyrics returned by the plugin.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct GetLyricsResponse {
#[serde(default)]
pub lyrics: Vec<LyricsText>,
}
/// LyricsText represents a single set of lyrics in raw text format.
/// Text can be plain text or LRC format — Navidrome will parse it.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct LyricsText {
#[serde(default, skip_serializing_if = "String::is_empty")]
pub lang: String,
#[serde(default)]
pub text: String,
}
/// TrackInfo contains track metadata for scrobbling.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct TrackInfo {
/// ID is the internal Navidrome track ID.
#[serde(default)]
pub id: String,
/// Title is the track title.
#[serde(default)]
pub title: String,
/// Album is the album name.
#[serde(default)]
pub album: String,
/// Artist is the formatted artist name for display (e.g., "Artist1 • Artist2").
#[serde(default)]
pub artist: String,
/// AlbumArtist is the formatted album artist name for display.
#[serde(default)]
pub album_artist: String,
/// Artists is the list of track artists.
#[serde(default)]
pub artists: Vec<ArtistRef>,
/// AlbumArtists is the list of album artists.
#[serde(default)]
pub album_artists: Vec<ArtistRef>,
/// Duration is the track duration in seconds.
#[serde(default)]
pub duration: f32,
/// TrackNumber is the track number on the album.
#[serde(default)]
pub track_number: i32,
/// DiscNumber is the disc number.
#[serde(default)]
pub disc_number: i32,
/// MBZRecordingID is the MusicBrainz recording ID.
#[serde(default, skip_serializing_if = "String::is_empty")]
pub mbz_recording_id: String,
/// MBZAlbumID is the MusicBrainz album/release ID.
#[serde(default, skip_serializing_if = "String::is_empty")]
pub mbz_album_id: String,
/// MBZReleaseGroupID is the MusicBrainz release group ID.
#[serde(default, skip_serializing_if = "String::is_empty")]
pub mbz_release_group_id: String,
/// MBZReleaseTrackID is the MusicBrainz release track ID.
#[serde(default, skip_serializing_if = "String::is_empty")]
pub mbz_release_track_id: String,
}
/// Error represents an error from a capability method.
#[derive(Debug)]
pub struct Error {
pub message: String,
}
impl std::fmt::Display for Error {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", self.message)
}
}
impl std::error::Error for Error {}
impl Error {
pub fn new(message: impl Into<String>) -> Self {
Self { message: message.into() }
}
}
/// Lyrics requires all methods to be implemented.
/// Lyrics provides lyrics for a given track from external sources.
pub trait Lyrics {
/// GetLyrics
fn get_lyrics(&self, req: GetLyricsRequest) -> Result<GetLyricsResponse, Error>;
}
/// Register all exports for the Lyrics capability.
/// This macro generates the WASM export functions for all trait methods.
#[macro_export]
macro_rules! register_lyrics {
($plugin_type:ty) => {
#[extism_pdk::plugin_fn]
pub fn nd_lyrics_get_lyrics(
req: extism_pdk::Json<$crate::lyrics::GetLyricsRequest>
) -> extism_pdk::FnResult<extism_pdk::Json<$crate::lyrics::GetLyricsResponse>> {
let plugin = <$plugin_type>::default();
let result = $crate::lyrics::Lyrics::get_lyrics(&plugin, req.into_inner())?;
Ok(extism_pdk::Json(result))
}
};
}

View File

@@ -44,22 +44,6 @@ struct KVStoreSetResponse {
error: Option<String>,
}
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
struct KVStoreSetWithTTLRequest {
key: String,
#[serde(with = "base64_bytes")]
value: Vec<u8>,
ttl_seconds: i64,
}
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
struct KVStoreSetWithTTLResponse {
#[serde(default)]
error: Option<String>,
}
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
struct KVStoreGetRequest {
@@ -80,15 +64,13 @@ struct KVStoreGetResponse {
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
struct KVStoreGetManyRequest {
keys: Vec<String>,
struct KVStoreDeleteRequest {
key: String,
}
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
struct KVStoreGetManyResponse {
#[serde(default)]
values: std::collections::HashMap<String, Vec<u8>>,
struct KVStoreDeleteResponse {
#[serde(default)]
error: Option<String>,
}
@@ -123,34 +105,6 @@ struct KVStoreListResponse {
error: Option<String>,
}
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
struct KVStoreDeleteRequest {
key: String,
}
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
struct KVStoreDeleteResponse {
#[serde(default)]
error: Option<String>,
}
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
struct KVStoreDeleteByPrefixRequest {
prefix: String,
}
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
struct KVStoreDeleteByPrefixResponse {
#[serde(default)]
deleted_count: i64,
#[serde(default)]
error: Option<String>,
}
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
struct KVStoreGetStorageUsedResponse {
@@ -163,13 +117,10 @@ struct KVStoreGetStorageUsedResponse {
#[host_fn]
extern "ExtismHost" {
fn kvstore_set(input: Json<KVStoreSetRequest>) -> Json<KVStoreSetResponse>;
fn kvstore_setwithttl(input: Json<KVStoreSetWithTTLRequest>) -> Json<KVStoreSetWithTTLResponse>;
fn kvstore_get(input: Json<KVStoreGetRequest>) -> Json<KVStoreGetResponse>;
fn kvstore_getmany(input: Json<KVStoreGetManyRequest>) -> Json<KVStoreGetManyResponse>;
fn kvstore_delete(input: Json<KVStoreDeleteRequest>) -> Json<KVStoreDeleteResponse>;
fn kvstore_has(input: Json<KVStoreHasRequest>) -> Json<KVStoreHasResponse>;
fn kvstore_list(input: Json<KVStoreListRequest>) -> Json<KVStoreListResponse>;
fn kvstore_delete(input: Json<KVStoreDeleteRequest>) -> Json<KVStoreDeleteResponse>;
fn kvstore_deletebyprefix(input: Json<KVStoreDeleteByPrefixRequest>) -> Json<KVStoreDeleteByPrefixResponse>;
fn kvstore_getstorageused(input: Json<serde_json::Value>) -> Json<KVStoreGetStorageUsedResponse>;
}
@@ -202,41 +153,6 @@ pub fn set(key: &str, value: Vec<u8>) -> Result<(), Error> {
Ok(())
}
/// SetWithTTL stores a byte value with the given key and a time-to-live.
///
/// After ttlSeconds, the key is treated as non-existent and will be
/// cleaned up lazily. ttlSeconds must be greater than 0.
///
/// Parameters:
/// - key: The storage key (max 256 bytes, UTF-8)
/// - value: The byte slice to store
/// - ttlSeconds: Time-to-live in seconds (must be > 0)
///
/// Returns an error if the storage limit would be exceeded or the operation fails.
///
/// # Arguments
/// * `key` - String parameter.
/// * `value` - Vec<u8> parameter.
/// * `ttl_seconds` - i64 parameter.
///
/// # Errors
/// Returns an error if the host function call fails.
pub fn set_with_ttl(key: &str, value: Vec<u8>, ttl_seconds: i64) -> Result<(), Error> {
let response = unsafe {
kvstore_setwithttl(Json(KVStoreSetWithTTLRequest {
key: key.to_owned(),
value: value,
ttl_seconds: ttl_seconds,
}))?
};
if let Some(err) = response.0.error {
return Err(Error::msg(err));
}
Ok(())
}
/// Get retrieves a byte value from storage.
///
/// Parameters:
@@ -270,26 +186,22 @@ pub fn get(key: &str) -> Result<Option<Vec<u8>>, Error> {
}
}
/// GetMany retrieves multiple values in a single call.
/// Delete removes a value from storage.
///
/// Parameters:
/// - keys: The storage keys to retrieve
/// - key: The storage key
///
/// Returns a map of key to value for keys that exist and have not expired.
/// Missing or expired keys are omitted from the result.
/// Returns an error if the operation fails. Does not return an error if the key doesn't exist.
///
/// # Arguments
/// * `keys` - Vec<String> parameter.
///
/// # Returns
/// The values value.
/// * `key` - String parameter.
///
/// # Errors
/// Returns an error if the host function call fails.
pub fn get_many(keys: Vec<String>) -> Result<std::collections::HashMap<String, Vec<u8>>, Error> {
pub fn delete(key: &str) -> Result<(), Error> {
let response = unsafe {
kvstore_getmany(Json(KVStoreGetManyRequest {
keys: keys,
kvstore_delete(Json(KVStoreDeleteRequest {
key: key.to_owned(),
}))?
};
@@ -297,7 +209,7 @@ pub fn get_many(keys: Vec<String>) -> Result<std::collections::HashMap<String, V
return Err(Error::msg(err));
}
Ok(response.0.values)
Ok(())
}
/// Has checks if a key exists in storage.
@@ -358,61 +270,6 @@ pub fn list(prefix: &str) -> Result<Vec<String>, Error> {
Ok(response.0.keys)
}
/// Delete removes a value from storage.
///
/// Parameters:
/// - key: The storage key
///
/// Returns an error if the operation fails. Does not return an error if the key doesn't exist.
///
/// # Arguments
/// * `key` - String parameter.
///
/// # Errors
/// Returns an error if the host function call fails.
pub fn delete(key: &str) -> Result<(), Error> {
let response = unsafe {
kvstore_delete(Json(KVStoreDeleteRequest {
key: key.to_owned(),
}))?
};
if let Some(err) = response.0.error {
return Err(Error::msg(err));
}
Ok(())
}
/// DeleteByPrefix removes all keys matching the given prefix.
///
/// Parameters:
/// - prefix: Key prefix to match (must not be empty)
///
/// Returns the number of keys deleted. Includes expired keys.
///
/// # Arguments
/// * `prefix` - String parameter.
///
/// # Returns
/// The deleted_count value.
///
/// # Errors
/// Returns an error if the host function call fails.
pub fn delete_by_prefix(prefix: &str) -> Result<i64, Error> {
let response = unsafe {
kvstore_deletebyprefix(Json(KVStoreDeleteByPrefixRequest {
prefix: prefix.to_owned(),
}))?
};
if let Some(err) = response.0.error {
return Err(Error::msg(err));
}
Ok(response.0.deleted_count)
}
/// GetStorageUsed returns the total storage used by this plugin in bytes.
///
/// # Returns

View File

@@ -9,23 +9,19 @@ import (
// TestKVStoreInput is the input for nd_test_kvstore callback.
type TestKVStoreInput struct {
Operation string `json:"operation"` // "set", "get", "delete", "has", "list", "get_storage_used", "set_with_ttl", "delete_by_prefix", "get_many"
Key string `json:"key"` // Storage key
Value []byte `json:"value"` // For set operations
Prefix string `json:"prefix"` // For list/delete_by_prefix operations
TTLSeconds int64 `json:"ttl_seconds,omitempty"` // For set_with_ttl
Keys []string `json:"keys,omitempty"` // For get_many
Operation string `json:"operation"` // "set", "get", "delete", "has", "list", "get_storage_used"
Key string `json:"key"` // Storage key
Value []byte `json:"value"` // For set operations
Prefix string `json:"prefix"` // For list operation
}
// TestKVStoreOutput is the output from nd_test_kvstore callback.
type TestKVStoreOutput struct {
Value []byte `json:"value,omitempty"`
Values map[string][]byte `json:"values,omitempty"`
Exists bool `json:"exists,omitempty"`
Keys []string `json:"keys,omitempty"`
StorageUsed int64 `json:"storage_used,omitempty"`
DeletedCount int64 `json:"deleted_count,omitempty"`
Error *string `json:"error,omitempty"`
Value []byte `json:"value,omitempty"`
Exists bool `json:"exists,omitempty"`
Keys []string `json:"keys,omitempty"`
StorageUsed int64 `json:"storage_used,omitempty"`
Error *string `json:"error,omitempty"`
}
// nd_test_kvstore is the test callback that tests the kvstore host functions.
@@ -100,36 +96,6 @@ func ndTestKVStore() int32 {
pdk.OutputJSON(TestKVStoreOutput{StorageUsed: bytesUsed})
return 0
case "set_with_ttl":
err := host.KVStoreSetWithTTL(input.Key, input.Value, input.TTLSeconds)
if err != nil {
errStr := err.Error()
pdk.OutputJSON(TestKVStoreOutput{Error: &errStr})
return 0
}
pdk.OutputJSON(TestKVStoreOutput{})
return 0
case "delete_by_prefix":
deletedCount, err := host.KVStoreDeleteByPrefix(input.Prefix)
if err != nil {
errStr := err.Error()
pdk.OutputJSON(TestKVStoreOutput{Error: &errStr})
return 0
}
pdk.OutputJSON(TestKVStoreOutput{DeletedCount: deletedCount})
return 0
case "get_many":
values, err := host.KVStoreGetMany(input.Keys)
if err != nil {
errStr := err.Error()
pdk.OutputJSON(TestKVStoreOutput{Error: &errStr})
return 0
}
pdk.OutputJSON(TestKVStoreOutput{Values: values})
return 0
default:
errStr := "unknown operation: " + input.Operation
pdk.OutputJSON(TestKVStoreOutput{Error: &errStr})

16
plugins/testdata/test-lyrics/go.mod vendored Normal file
View File

@@ -0,0 +1,16 @@
module test-lyrics
go 1.25
require github.com/navidrome/navidrome/plugins/pdk/go v0.0.0
require (
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/extism/go-pdk v1.1.3 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/stretchr/objx v0.5.2 // indirect
github.com/stretchr/testify v1.11.1 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)
replace github.com/navidrome/navidrome/plugins/pdk/go => ../../pdk/go

14
plugins/testdata/test-lyrics/go.sum vendored Normal file
View File

@@ -0,0 +1,14 @@
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/extism/go-pdk v1.1.3 h1:hfViMPWrqjN6u67cIYRALZTZLk/enSPpNKa+rZ9X2SQ=
github.com/extism/go-pdk v1.1.3/go.mod h1:Gz+LIU/YCKnKXhgge8yo5Yu1F/lbv7KtKFkiCSzW/P4=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY=
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

35
plugins/testdata/test-lyrics/main.go vendored Normal file
View File

@@ -0,0 +1,35 @@
// Test lyrics plugin for Navidrome plugin system integration tests.
package main
import (
"fmt"
"github.com/navidrome/navidrome/plugins/pdk/go/lyrics"
"github.com/navidrome/navidrome/plugins/pdk/go/pdk"
)
func init() {
lyrics.Register(&testLyrics{})
}
type testLyrics struct{}
func (t *testLyrics) GetLyrics(input lyrics.GetLyricsRequest) (lyrics.GetLyricsResponse, error) {
// Check for configured error
errMsg, hasErr := pdk.GetConfig("error")
if hasErr && errMsg != "" {
return lyrics.GetLyricsResponse{}, fmt.Errorf("%s", errMsg)
}
// Return test lyrics based on track info
return lyrics.GetLyricsResponse{
Lyrics: []lyrics.LyricsText{
{
Lang: "eng",
Text: "Test lyrics for " + input.Track.Title + "\nBy " + input.Track.Artist,
},
},
}, nil
}
func main() {}

View File

@@ -0,0 +1,6 @@
{
"name": "Test Lyrics",
"author": "Navidrome Test",
"version": "1.0.0",
"description": "A test lyrics plugin for integration testing"
}

View File

@@ -19,6 +19,7 @@ import (
"github.com/navidrome/navidrome/core/artwork"
"github.com/navidrome/navidrome/core/auth"
"github.com/navidrome/navidrome/core/external"
"github.com/navidrome/navidrome/core/lyrics"
"github.com/navidrome/navidrome/core/metrics"
"github.com/navidrome/navidrome/core/playback"
"github.com/navidrome/navidrome/core/playlists"
@@ -396,6 +397,7 @@ func setupTestDB() {
core.NewShare(ds),
playback.PlaybackServer(nil),
metrics.NewNoopInstance(),
lyrics.NewLyrics(nil),
)
}

View File

@@ -27,7 +27,7 @@ var _ = Describe("Album Lists", func() {
ds = &tests.MockDataStore{}
auth.Init(ds)
mockRepo = ds.Album(ctx).(*tests.MockAlbumRepo)
router = New(ds, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil)
router = New(ds, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil)
w = httptest.NewRecorder()
})

View File

@@ -14,6 +14,7 @@ import (
"github.com/navidrome/navidrome/core"
"github.com/navidrome/navidrome/core/artwork"
"github.com/navidrome/navidrome/core/external"
"github.com/navidrome/navidrome/core/lyrics"
"github.com/navidrome/navidrome/core/metrics"
"github.com/navidrome/navidrome/core/playback"
playlistsvc "github.com/navidrome/navidrome/core/playlists"
@@ -48,12 +49,13 @@ type Router struct {
share core.Share
playback playback.PlaybackServer
metrics metrics.Metrics
lyrics lyrics.Lyrics
}
func New(ds model.DataStore, artwork artwork.Artwork, streamer core.MediaStreamer, archiver core.Archiver,
players core.Players, provider external.Provider, scanner model.Scanner, broker events.Broker,
playlists playlistsvc.Playlists, scrobbler scrobbler.PlayTracker, share core.Share, playback playback.PlaybackServer,
metrics metrics.Metrics,
metrics metrics.Metrics, lyrics lyrics.Lyrics,
) *Router {
r := &Router{
ds: ds,
@@ -69,6 +71,7 @@ func New(ds model.DataStore, artwork artwork.Artwork, streamer core.MediaStreame
share: share,
playback: playback,
metrics: metrics,
lyrics: lyrics,
}
r.Handler = r.routes()
return r

View File

@@ -27,7 +27,7 @@ var _ = Describe("MediaAnnotationController", func() {
ds = &tests.MockDataStore{}
playTracker = &fakePlayTracker{}
eventBroker = &fakeEventBroker{}
router = New(ds, nil, nil, nil, nil, nil, nil, eventBroker, nil, playTracker, nil, nil, nil)
router = New(ds, nil, nil, nil, nil, nil, nil, eventBroker, nil, playTracker, nil, nil, nil, nil)
})
Describe("Scrobble", func() {

View File

@@ -10,7 +10,6 @@ import (
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/consts"
"github.com/navidrome/navidrome/core/lyrics"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/resources"
@@ -109,7 +108,7 @@ func (api *Router) GetLyrics(r *http.Request) (*responses.Subsonic, error) {
return response, nil
}
structuredLyrics, err := lyrics.GetLyrics(r.Context(), &mediaFiles[0])
structuredLyrics, err := api.lyrics.GetLyrics(r.Context(), &mediaFiles[0])
if err != nil {
return nil, err
}
@@ -142,7 +141,7 @@ func (api *Router) GetLyricsBySongId(r *http.Request) (*responses.Subsonic, erro
return nil, err
}
structuredLyrics, err := lyrics.GetLyrics(r.Context(), mediaFile)
structuredLyrics, err := api.lyrics.GetLyrics(r.Context(), mediaFile)
if err != nil {
return nil, err
}

View File

@@ -14,6 +14,7 @@ import (
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/conf/configtest"
"github.com/navidrome/navidrome/core/artwork"
"github.com/navidrome/navidrome/core/lyrics"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/server/subsonic/responses"
"github.com/navidrome/navidrome/tests"
@@ -33,7 +34,7 @@ var _ = Describe("MediaRetrievalController", func() {
MockedMediaFile: mockRepo,
}
artwork = &fakeArtwork{data: "image data"}
router = New(ds, artwork, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil)
router = New(ds, artwork, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, lyrics.NewLyrics(nil))
w = httptest.NewRecorder()
DeferCleanup(configtest.SetupConfig())
conf.Server.LyricsPriority = "embedded,.lrc"

View File

@@ -19,7 +19,7 @@ var _ = Describe("GetOpenSubsonicExtensions", func() {
)
BeforeEach(func() {
router = subsonic.New(nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil)
router = subsonic.New(nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil)
w = httptest.NewRecorder()
r = httptest.NewRequest("GET", "/getOpenSubsonicExtensions?f=json", nil)
})

View File

@@ -24,7 +24,7 @@ var _ = Describe("buildPlaylist", func() {
BeforeEach(func() {
ds = &tests.MockDataStore{}
router = New(ds, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil)
router = New(ds, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil)
ctx = context.Background()
})
@@ -224,7 +224,7 @@ var _ = Describe("UpdatePlaylist", func() {
BeforeEach(func() {
ds = &tests.MockDataStore{}
playlists = &fakePlaylists{}
router = New(ds, nil, nil, nil, nil, nil, nil, nil, playlists, nil, nil, nil, nil)
router = New(ds, nil, nil, nil, nil, nil, nil, nil, playlists, nil, nil, nil, nil, nil)
})
It("clears the comment when parameter is empty", func() {

View File

@@ -21,7 +21,7 @@ var _ = Describe("Search", func() {
ds = &tests.MockDataStore{}
auth.Init(ds)
router = New(ds, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil)
router = New(ds, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil)
// Get references to the mock repositories so we can inspect their Options
mockAlbumRepo = ds.Album(nil).(*tests.MockAlbumRepo)