mirror of
https://github.com/navidrome/navidrome.git
synced 2026-03-01 05:19:19 -05:00
Compare commits
2 Commits
master
...
feat/lyric
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6920ce9f6e | ||
|
|
4a53650981 |
@@ -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()
|
||||
|
||||
@@ -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)),
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
26
plugins/capabilities/lyrics.go
Normal file
26
plugins/capabilities/lyrics.go
Normal 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"`
|
||||
}
|
||||
115
plugins/capabilities/lyrics.yaml
Normal file
115
plugins/capabilities/lyrics.yaml
Normal 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
|
||||
@@ -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)
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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(¤tSize); 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
|
||||
|
||||
@@ -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
55
plugins/lyrics_adapter.go
Normal 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
|
||||
}
|
||||
84
plugins/lyrics_adapter_test.go
Normal file
84
plugins/lyrics_adapter_test.go
Normal 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"))
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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())
|
||||
})
|
||||
})
|
||||
@@ -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) {
|
||||
|
||||
@@ -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()
|
||||
|
||||
118
plugins/pdk/go/lyrics/lyrics.go
Normal file
118
plugins/pdk/go/lyrics/lyrics.go
Normal 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
|
||||
}
|
||||
82
plugins/pdk/go/lyrics/lyrics_stub.go
Normal file
82
plugins/pdk/go/lyrics/lyrics_stub.go
Normal 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) {}
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
148
plugins/pdk/rust/nd-pdk-capabilities/src/lyrics.rs
Normal file
148
plugins/pdk/rust/nd-pdk-capabilities/src/lyrics.rs
Normal 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))
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -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
|
||||
|
||||
52
plugins/testdata/test-kvstore/main.go
vendored
52
plugins/testdata/test-kvstore/main.go
vendored
@@ -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
16
plugins/testdata/test-lyrics/go.mod
vendored
Normal 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
14
plugins/testdata/test-lyrics/go.sum
vendored
Normal 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
35
plugins/testdata/test-lyrics/main.go
vendored
Normal 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() {}
|
||||
6
plugins/testdata/test-lyrics/manifest.json
vendored
Normal file
6
plugins/testdata/test-lyrics/manifest.json
vendored
Normal file
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"name": "Test Lyrics",
|
||||
"author": "Navidrome Test",
|
||||
"version": "1.0.0",
|
||||
"description": "A test lyrics plugin for integration testing"
|
||||
}
|
||||
@@ -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),
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user