mirror of
https://github.com/navidrome/navidrome.git
synced 2026-04-28 03:19:38 +00:00
feat(subsonic): add sonicSimilarity extension as plugin capability (#5419)
* feat(plugins): add sonicSimilarity capability types
Defines the SonicSimilarity plugin capability interface with
GetSonicSimilarTracks and FindSonicPath methods, along with
their request/response types.
* feat(sonic): add core sonic similarity service
Implements the Sonic service with HasProvider, GetSonicSimilarTracks,
and FindSonicPath, delegating to the PluginLoader and using the
Matcher for index-preserving library resolution.
* test(sonic): add sonic service unit tests
Covers HasProvider, GetSonicSimilarTracks, and FindSonicPath with
mock plugin loader and provider, verifying error propagation and
successful match resolution via the library matcher.
* feat(matcher): add MatchSongsToLibraryMap for index-preserving matching
Adds a new method alongside MatchSongsToLibrary that returns a
map[int]MediaFile keyed by input song index rather than a flat slice,
enabling callers to correlate similarity scores back to the original
position in the results.
* fix(sonic): check provider availability before MediaFile lookup
Avoids unnecessary DB call when no plugin is available, and ensures
the correct error path is tested.
* feat(plugins): add sonic similarity adapter
Adds SonicSimilarityAdapter implementing sonic.Provider, bridging the
plugin system to the core sonic service via Extism plugin functions.
Reuses existing songRefsToAgentSongs helper for SongRef conversion.
* feat(plugins): add LoadSonicSimilarity to plugin manager
Adds Manager.LoadSonicSimilarity method following the pattern of
LoadLyricsProvider, enabling the core sonic service to load a
SonicSimilarityAdapter from a named plugin.
* feat(subsonic): add sonicMatch response type
Add SonicMatch struct with Entry and Similarity fields, and a SonicMatches slice to the Subsonic response struct. These types support the OpenSubsonic sonicSimilarity extension for returning similarity-scored track results.
* feat(subsonic): add getSonicSimilarTracks and findSonicPath handlers
Add two new Subsonic API handlers for the sonicSimilarity OpenSubsonic extension: GetSonicSimilarTracks returns similarity-scored tracks similar to a given song, and FindSonicPath returns a path of tracks connecting two songs. Both handlers delegate to the sonic core service and map results to SonicMatch response types.
* feat(subsonic): advertise sonicSimilarity extension when plugin available
Update GetOpenSubsonicExtensions to conditionally include the sonicSimilarity extension only when a sonic similarity plugin provider is available. The nil guard ensures backward compatibility with tests that pass nil for the sonic field. Also update the existing test to pass the new nil parameter.
* feat(subsonic): wire sonic similarity service into router
Add the sonic.Sonic service to the Router struct and New() constructor, register the getSonicSimilarTracks and findSonicPath routes, and wire sonic.New and its PluginLoader binding into the Wire dependency injection graph. Update all existing test call sites to pass the new nil parameter. Regenerate wire_gen.go.
* fix(e2e): add sonic parameter to subsonic.New call in e2e tests
* test(subsonic): add sonicSimilarity extension advertisement tests
Restructures the GetOpenSubsonicExtensions test into two contexts: one verifying the baseline 5 extensions are returned when no sonic similarity plugin is configured, and one verifying that the sonicSimilarity extension is advertised (making 6 total) when a plugin loader reports an available provider. Adds a mockSonicPluginLoader to satisfy the sonic.PluginLoader interface without requiring a real plugin.
* feat(subsonic): add nil guard and e2e tests for sonic similarity endpoints
Handlers return ErrorDataNotFound when no sonic service is configured,
preventing nil panics. E2e tests verify both endpoints return proper
error responses when no plugin is available.
* fix(subsonic): return HTTP 404 when no sonic similarity plugin available
Endpoints are always registered but return 404 when no provider is
available, rather than a subsonic error code 70.
* refactor: clean up sonic similarity code after review
Extract shared helpers to reduce duplication across the sonic similarity
implementation: loadAllMatches in matcher consolidates the 4-phase
matching pipeline, songRefToAgentSong eliminates per-iteration slice
allocation in the adapter, sonicMatchResponse deduplicates response
building in handlers, and a package-level constant replaces raw
capability name strings in core/sonic.
* fix empty response shapes
Signed-off-by: Deluan <deluan@navidrome.org>
* test(plugins): add testdata plugin and e2e tests for sonic similarity
Add a test-sonic-similarity WASM plugin that implements both
GetSonicSimilarTracks and FindSonicPath via the generated sonicsimilarity
PDK. The plugin returns deterministic test data with decreasing similarity
scores and supports error injection via config. Adapter tests verify the
full round-trip through the WASM plugin including error handling. Also
includes regenerated PDK code from make gen.
* docs: update README to include new capabilities and usage examples for plugins
Signed-off-by: Deluan <deluan@navidrome.org>
* test(e2e): enhance sonic similarity tests with additional scenarios and mock provider
Signed-off-by: Deluan <deluan@navidrome.org>
* fix: address PR review feedback for sonic similarity
Fix incorrect field names in README documentation ({from, to} →
{startSong, endSong}) and remove unnecessary XML serialization test
from e2e suite since OpenSubsonic endpoints only use JSON.
* refactor: rename Matcher methods for conciseness
Rename MatchSongsToLibrary to MatchSongs and MatchSongsToLibraryMap to
MatchSongsIndexed. The Matcher receiver already establishes the "to
library" context, making that suffix redundant, and "Indexed" better
describes the intent (preserving input ordering) than "Map" which
describes the data structure.
* refactor: standardize variable naming for media files in sonic path methods
Signed-off-by: Deluan <deluan@navidrome.org>
* refactor: simplify plugin loading by introducing adapter constructors
Signed-off-by: Deluan <deluan@navidrome.org>
---------
Signed-off-by: Deluan <deluan@navidrome.org>
This commit is contained in:
parent
e6680c904b
commit
259c1a9484
51 changed files with 2159 additions and 461 deletions
|
|
@ -22,6 +22,7 @@ import (
|
|||
"github.com/navidrome/navidrome/core/playback"
|
||||
"github.com/navidrome/navidrome/core/playlists"
|
||||
"github.com/navidrome/navidrome/core/scrobbler"
|
||||
"github.com/navidrome/navidrome/core/sonic"
|
||||
"github.com/navidrome/navidrome/core/stream"
|
||||
"github.com/navidrome/navidrome/db"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
|
|
@ -110,7 +111,8 @@ func CreateSubsonicAPIRouter(ctx context.Context) *subsonic.Router {
|
|||
playbackServer := playback.GetInstance(dataStore)
|
||||
lyricsLyrics := lyrics.NewLyrics(manager)
|
||||
transcodeDecider := stream.NewTranscodeDecider(dataStore, fFmpeg)
|
||||
router := subsonic.New(dataStore, artworkArtwork, mediaStreamer, archiver, players, provider, modelScanner, broker, playlistsPlaylists, playTracker, share, playbackServer, metricsMetrics, lyricsLyrics, transcodeDecider)
|
||||
sonicSonic := sonic.New(dataStore, manager, matcherMatcher)
|
||||
router := subsonic.New(dataStore, artworkArtwork, mediaStreamer, archiver, players, provider, modelScanner, broker, playlistsPlaylists, playTracker, share, playbackServer, metricsMetrics, lyricsLyrics, transcodeDecider, sonicSonic)
|
||||
return router
|
||||
}
|
||||
|
||||
|
|
@ -219,7 +221,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(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)))
|
||||
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, sonic.New, 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(sonic.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()
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ import (
|
|||
"github.com/navidrome/navidrome/core/metrics"
|
||||
"github.com/navidrome/navidrome/core/playback"
|
||||
"github.com/navidrome/navidrome/core/scrobbler"
|
||||
"github.com/navidrome/navidrome/core/sonic"
|
||||
"github.com/navidrome/navidrome/db"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/persistence"
|
||||
|
|
@ -43,9 +44,11 @@ var allProviders = wire.NewSet(
|
|||
metrics.GetPrometheusInstance,
|
||||
db.Db,
|
||||
plugins.GetManager,
|
||||
sonic.New,
|
||||
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(sonic.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)),
|
||||
|
|
|
|||
4
core/external/provider.go
vendored
4
core/external/provider.go
vendored
|
|
@ -302,7 +302,7 @@ func (e *provider) SimilarSongs(ctx context.Context, id string, count int) (mode
|
|||
}
|
||||
|
||||
if err == nil && len(songs) > 0 {
|
||||
return e.matcher.MatchSongsToLibrary(ctx, songs, count)
|
||||
return e.matcher.MatchSongs(ctx, songs, count)
|
||||
}
|
||||
|
||||
// Fallback to existing similar artists + top songs algorithm
|
||||
|
|
@ -481,7 +481,7 @@ func (e *provider) getMatchingTopSongs(ctx context.Context, agent agents.ArtistT
|
|||
}
|
||||
}
|
||||
|
||||
mfs, err := e.matcher.MatchSongsToLibrary(ctx, songs, count)
|
||||
mfs, err := e.matcher.MatchSongs(ctx, songs, count)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
|
|
|||
|
|
@ -23,7 +23,7 @@ func New(ds model.DataStore) *Matcher {
|
|||
return &Matcher{ds: ds}
|
||||
}
|
||||
|
||||
// MatchSongsToLibrary matches agent song results to local library tracks using a multi-phase
|
||||
// MatchSongs matches agent song results to local library tracks using a multi-phase
|
||||
// matching algorithm that prioritizes accuracy over recall.
|
||||
//
|
||||
// # Algorithm Overview
|
||||
|
|
@ -107,25 +107,58 @@ func New(ds model.DataStore) *Matcher {
|
|||
//
|
||||
// Returns up to 'count' MediaFiles from the library that best match the input songs,
|
||||
// preserving the original order from the agent. Songs that cannot be matched are skipped.
|
||||
func (m *Matcher) MatchSongsToLibrary(ctx context.Context, songs []agents.Song, count int) (model.MediaFiles, error) {
|
||||
idMatches, err := m.loadTracksByID(ctx, songs)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to load tracks by ID: %w", err)
|
||||
}
|
||||
mbidMatches, err := m.loadTracksByMBID(ctx, songs, idMatches)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to load tracks by MBID: %w", err)
|
||||
}
|
||||
isrcMatches, err := m.loadTracksByISRC(ctx, songs, idMatches, mbidMatches)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to load tracks by ISRC: %w", err)
|
||||
}
|
||||
titleMatches, err := m.loadTracksByTitleAndArtist(ctx, songs, idMatches, mbidMatches, isrcMatches)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to load tracks by title: %w", err)
|
||||
func (m *Matcher) MatchSongs(ctx context.Context, songs []agents.Song, count int) (model.MediaFiles, error) {
|
||||
if len(songs) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
return m.selectBestMatchingSongs(songs, idMatches, mbidMatches, isrcMatches, titleMatches, count), nil
|
||||
byID, byMBID, byISRC, byTitle, err := m.loadAllMatches(ctx, songs)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return m.selectBestMatchingSongs(songs, byID, byMBID, byISRC, byTitle, count), nil
|
||||
}
|
||||
|
||||
// MatchSongsIndexed matches agent song results to local library tracks and returns a map
|
||||
// from input song index to matched MediaFile. Songs that cannot be matched are omitted from the map.
|
||||
// This preserves original indices, allowing callers to correlate results back to the input slice.
|
||||
func (m *Matcher) MatchSongsIndexed(ctx context.Context, songs []agents.Song) (map[int]model.MediaFile, error) {
|
||||
if len(songs) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
byID, byMBID, byISRC, byTitle, err := m.loadAllMatches(ctx, songs)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
result := make(map[int]model.MediaFile, len(songs))
|
||||
for i, t := range songs {
|
||||
if mf, found := findMatchingTrack(t, byID, byMBID, byISRC, byTitle); found {
|
||||
result[i] = mf
|
||||
}
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (m *Matcher) loadAllMatches(ctx context.Context, songs []agents.Song) (byID, byMBID, byISRC, byTitle map[string]model.MediaFile, err error) {
|
||||
byID, err = m.loadTracksByID(ctx, songs)
|
||||
if err != nil {
|
||||
return nil, nil, nil, nil, fmt.Errorf("failed to load tracks by ID: %w", err)
|
||||
}
|
||||
byMBID, err = m.loadTracksByMBID(ctx, songs, byID)
|
||||
if err != nil {
|
||||
return nil, nil, nil, nil, fmt.Errorf("failed to load tracks by MBID: %w", err)
|
||||
}
|
||||
byISRC, err = m.loadTracksByISRC(ctx, songs, byID, byMBID)
|
||||
if err != nil {
|
||||
return nil, nil, nil, nil, fmt.Errorf("failed to load tracks by ISRC: %w", err)
|
||||
}
|
||||
byTitle, err = m.loadTracksByTitleAndArtist(ctx, songs, byID, byMBID, byISRC)
|
||||
if err != nil {
|
||||
return nil, nil, nil, nil, fmt.Errorf("failed to load tracks by title: %w", err)
|
||||
}
|
||||
return byID, byMBID, byISRC, byTitle, nil
|
||||
}
|
||||
|
||||
// songMatchedIn checks if a song has already been matched in any of the provided match maps.
|
||||
|
|
|
|||
|
|
@ -75,7 +75,7 @@ var _ = Describe("Matcher", func() {
|
|||
Return(artistTracks, nil).Maybe()
|
||||
}
|
||||
|
||||
Describe("MatchSongsToLibrary", func() {
|
||||
Describe("MatchSongs", func() {
|
||||
Context("matching by direct ID", func() {
|
||||
It("matches songs with an ID field to MediaFiles by ID", func() {
|
||||
conf.Server.Matcher.FuzzyThreshold = 100
|
||||
|
|
@ -87,7 +87,7 @@ var _ = Describe("Matcher", func() {
|
|||
}
|
||||
expectIDPhase(model.MediaFiles{idMatch})
|
||||
allowOtherPhases()
|
||||
result, err := m.MatchSongsToLibrary(ctx, songs, 5)
|
||||
result, err := m.MatchSongs(ctx, songs, 5)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(result).To(HaveLen(1))
|
||||
Expect(result[0].ID).To(Equal("track-1"))
|
||||
|
|
@ -106,7 +106,7 @@ var _ = Describe("Matcher", func() {
|
|||
}
|
||||
expectMBIDPhase(model.MediaFiles{mbidMatch})
|
||||
allowOtherPhases()
|
||||
result, err := m.MatchSongsToLibrary(ctx, songs, 5)
|
||||
result, err := m.MatchSongs(ctx, songs, 5)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(result).To(HaveLen(1))
|
||||
Expect(result[0].ID).To(Equal("track-mbid"))
|
||||
|
|
@ -125,7 +125,7 @@ var _ = Describe("Matcher", func() {
|
|||
}
|
||||
expectISRCPhase(model.MediaFiles{isrcMatch})
|
||||
allowOtherPhases()
|
||||
result, err := m.MatchSongsToLibrary(ctx, songs, 5)
|
||||
result, err := m.MatchSongs(ctx, songs, 5)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(result).To(HaveLen(1))
|
||||
Expect(result[0].ID).To(Equal("track-isrc"))
|
||||
|
|
@ -142,7 +142,7 @@ var _ = Describe("Matcher", func() {
|
|||
ID: "track-title", Title: "Enjoy the Silence", Artist: "Depeche Mode",
|
||||
}
|
||||
setupTitleOnlyExpectations(model.MediaFiles{titleMatch})
|
||||
result, err := m.MatchSongsToLibrary(ctx, songs, 5)
|
||||
result, err := m.MatchSongs(ctx, songs, 5)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(result).To(HaveLen(1))
|
||||
Expect(result[0].ID).To(Equal("track-title"))
|
||||
|
|
@ -157,7 +157,7 @@ var _ = Describe("Matcher", func() {
|
|||
ID: "track-fuzzy", Title: "Bohemian Rhapsody (Live)", Artist: "Queen",
|
||||
}
|
||||
setupTitleOnlyExpectations(model.MediaFiles{fuzzyMatch})
|
||||
result, err := m.MatchSongsToLibrary(ctx, songs, 5)
|
||||
result, err := m.MatchSongs(ctx, songs, 5)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(result).To(HaveLen(1))
|
||||
Expect(result[0].ID).To(Equal("track-fuzzy"))
|
||||
|
|
@ -172,7 +172,7 @@ var _ = Describe("Matcher", func() {
|
|||
{ID: "different", Title: "Tomorrow Never Knows", Artist: "The Beatles"},
|
||||
}
|
||||
setupTitleOnlyExpectations(differentTracks)
|
||||
result, err := m.MatchSongsToLibrary(ctx, songs, 5)
|
||||
result, err := m.MatchSongs(ctx, songs, 5)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(result).To(BeEmpty())
|
||||
})
|
||||
|
|
@ -189,7 +189,7 @@ var _ = Describe("Matcher", func() {
|
|||
ID: "br-live", Title: "Bohemian Rhapsody (Live)", Artist: "Queen",
|
||||
}
|
||||
setupTitleOnlyExpectations(model.MediaFiles{libraryTrack})
|
||||
result, err := m.MatchSongsToLibrary(ctx, songs, 5)
|
||||
result, err := m.MatchSongs(ctx, songs, 5)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(result).To(HaveLen(1))
|
||||
Expect(result[0].ID).To(Equal("br-live"))
|
||||
|
|
@ -205,7 +205,7 @@ var _ = Describe("Matcher", func() {
|
|||
ID: "br", Title: "Bohemian Rhapsody", Artist: "Queen", Album: "A Night at the Opera",
|
||||
}
|
||||
setupTitleOnlyExpectations(model.MediaFiles{libraryTrack})
|
||||
result, err := m.MatchSongsToLibrary(ctx, songs, 5)
|
||||
result, err := m.MatchSongs(ctx, songs, 5)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(result).To(HaveLen(2))
|
||||
Expect(result[0].ID).To(Equal("br"))
|
||||
|
|
@ -227,7 +227,7 @@ var _ = Describe("Matcher", func() {
|
|||
}
|
||||
expectIDPhase(model.MediaFiles{idMatch})
|
||||
allowOtherPhases()
|
||||
result, err := m.MatchSongsToLibrary(ctx, songs, 5)
|
||||
result, err := m.MatchSongs(ctx, songs, 5)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(result).To(HaveLen(1))
|
||||
Expect(result[0].ID).To(Equal("track-id"))
|
||||
|
|
@ -248,7 +248,7 @@ var _ = Describe("Matcher", func() {
|
|||
{ID: "c", Title: "Song C", Artist: "Artist"},
|
||||
}
|
||||
setupTitleOnlyExpectations(tracks)
|
||||
result, err := m.MatchSongsToLibrary(ctx, songs, 2)
|
||||
result, err := m.MatchSongs(ctx, songs, 2)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(result).To(HaveLen(2))
|
||||
})
|
||||
|
|
@ -256,13 +256,60 @@ var _ = Describe("Matcher", func() {
|
|||
|
||||
Context("empty input", func() {
|
||||
It("returns empty results for no songs", func() {
|
||||
result, err := m.MatchSongsToLibrary(ctx, []agents.Song{}, 5)
|
||||
result, err := m.MatchSongs(ctx, []agents.Song{}, 5)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(result).To(BeEmpty())
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
Describe("MatchSongsIndexed", func() {
|
||||
It("returns index-keyed map of matched songs", func() {
|
||||
songs := []agents.Song{
|
||||
{ID: "track-1", Name: "Song One", Artist: "Artist A"},
|
||||
{ID: "track-2", Name: "Song Two", Artist: "Artist B"},
|
||||
{ID: "track-3", Name: "Song Three", Artist: "Artist C"},
|
||||
}
|
||||
mf1 := model.MediaFile{ID: "track-1", Title: "Song One", Artist: "Artist A"}
|
||||
mf2 := model.MediaFile{ID: "track-2", Title: "Song Two", Artist: "Artist B"}
|
||||
|
||||
expectIDPhase(model.MediaFiles{mf1, mf2})
|
||||
allowOtherPhases()
|
||||
|
||||
result, err := m.MatchSongsIndexed(ctx, songs)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(result).To(HaveLen(2))
|
||||
Expect(result[0].ID).To(Equal("track-1"))
|
||||
Expect(result[1].ID).To(Equal("track-2"))
|
||||
_, exists := result[2]
|
||||
Expect(exists).To(BeFalse())
|
||||
})
|
||||
|
||||
It("preserves original indices when some songs don't match", func() {
|
||||
songs := []agents.Song{
|
||||
{Name: "Unknown Song", Artist: "Unknown Artist"},
|
||||
{ID: "track-1", Name: "Known Song", Artist: "Known Artist"},
|
||||
}
|
||||
mf1 := model.MediaFile{ID: "track-1", Title: "Known Song", Artist: "Known Artist"}
|
||||
|
||||
expectIDPhase(model.MediaFiles{mf1})
|
||||
allowOtherPhases()
|
||||
|
||||
result, err := m.MatchSongsIndexed(ctx, songs)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(result).To(HaveLen(1))
|
||||
_, exists := result[0]
|
||||
Expect(exists).To(BeFalse())
|
||||
Expect(result[1].ID).To(Equal("track-1"))
|
||||
})
|
||||
|
||||
It("returns empty map for empty input", func() {
|
||||
result, err := m.MatchSongsIndexed(ctx, nil)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(result).To(BeEmpty())
|
||||
})
|
||||
})
|
||||
|
||||
Describe("specificity level matching", func() {
|
||||
BeforeEach(func() {
|
||||
conf.Server.Matcher.FuzzyThreshold = 100
|
||||
|
|
@ -283,7 +330,7 @@ var _ = Describe("Matcher", func() {
|
|||
|
||||
setupTitleOnlyExpectations(model.MediaFiles{wrongMatch, correctMatch})
|
||||
|
||||
result, err := m.MatchSongsToLibrary(ctx, songs, 5)
|
||||
result, err := m.MatchSongs(ctx, songs, 5)
|
||||
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(result).To(HaveLen(1))
|
||||
|
|
@ -303,7 +350,7 @@ var _ = Describe("Matcher", func() {
|
|||
|
||||
setupTitleOnlyExpectations(model.MediaFiles{wrongMatch, correctMatch})
|
||||
|
||||
result, err := m.MatchSongsToLibrary(ctx, songs, 5)
|
||||
result, err := m.MatchSongs(ctx, songs, 5)
|
||||
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(result).To(HaveLen(1))
|
||||
|
|
@ -323,7 +370,7 @@ var _ = Describe("Matcher", func() {
|
|||
|
||||
setupTitleOnlyExpectations(model.MediaFiles{wrongMatch, correctMatch})
|
||||
|
||||
result, err := m.MatchSongsToLibrary(ctx, songs, 5)
|
||||
result, err := m.MatchSongs(ctx, songs, 5)
|
||||
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(result).To(HaveLen(1))
|
||||
|
|
@ -337,7 +384,7 @@ var _ = Describe("Matcher", func() {
|
|||
|
||||
setupTitleOnlyExpectations(model.MediaFiles{})
|
||||
|
||||
result, err := m.MatchSongsToLibrary(ctx, songs, 5)
|
||||
result, err := m.MatchSongs(ctx, songs, 5)
|
||||
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(result).To(BeEmpty())
|
||||
|
|
@ -356,7 +403,7 @@ var _ = Describe("Matcher", func() {
|
|||
|
||||
setupTitleOnlyExpectations(model.MediaFiles{cover1, cover2, cover3})
|
||||
|
||||
result, err := m.MatchSongsToLibrary(ctx, songs, 5)
|
||||
result, err := m.MatchSongs(ctx, songs, 5)
|
||||
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(result).To(HaveLen(3))
|
||||
|
|
@ -384,7 +431,7 @@ var _ = Describe("Matcher", func() {
|
|||
|
||||
setupTitleOnlyExpectations(model.MediaFiles{lessAccurateMatch, preciseMatch, artistTwoMatch})
|
||||
|
||||
result, err := m.MatchSongsToLibrary(ctx, songs, 5)
|
||||
result, err := m.MatchSongs(ctx, songs, 5)
|
||||
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(result).To(HaveLen(2))
|
||||
|
|
@ -407,7 +454,7 @@ var _ = Describe("Matcher", func() {
|
|||
|
||||
setupTitleOnlyExpectations(artistTracks)
|
||||
|
||||
result, err := m.MatchSongsToLibrary(ctx, songs, 5)
|
||||
result, err := m.MatchSongs(ctx, songs, 5)
|
||||
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(result).To(HaveLen(1))
|
||||
|
|
@ -426,7 +473,7 @@ var _ = Describe("Matcher", func() {
|
|||
|
||||
setupTitleOnlyExpectations(artistTracks)
|
||||
|
||||
result, err := m.MatchSongsToLibrary(ctx, songs, 5)
|
||||
result, err := m.MatchSongs(ctx, songs, 5)
|
||||
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(result).To(HaveLen(1))
|
||||
|
|
@ -447,7 +494,7 @@ var _ = Describe("Matcher", func() {
|
|||
|
||||
setupTitleOnlyExpectations(artistTracks)
|
||||
|
||||
result, err := m.MatchSongsToLibrary(ctx, songs, 5)
|
||||
result, err := m.MatchSongs(ctx, songs, 5)
|
||||
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(result).To(BeEmpty())
|
||||
|
|
@ -467,7 +514,7 @@ var _ = Describe("Matcher", func() {
|
|||
|
||||
setupTitleOnlyExpectations(artistTracks)
|
||||
|
||||
result, err := m.MatchSongsToLibrary(ctx, songs, 5)
|
||||
result, err := m.MatchSongs(ctx, songs, 5)
|
||||
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(result).To(HaveLen(1))
|
||||
|
|
@ -495,7 +542,7 @@ var _ = Describe("Matcher", func() {
|
|||
|
||||
setupTitleOnlyExpectations(model.MediaFiles{wrongMatch, correctMatch})
|
||||
|
||||
result, err := m.MatchSongsToLibrary(ctx, songs, 5)
|
||||
result, err := m.MatchSongs(ctx, songs, 5)
|
||||
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(result).To(HaveLen(1))
|
||||
|
|
@ -515,7 +562,7 @@ var _ = Describe("Matcher", func() {
|
|||
|
||||
setupTitleOnlyExpectations(model.MediaFiles{wrongMatch, correctMatch})
|
||||
|
||||
result, err := m.MatchSongsToLibrary(ctx, songs, 5)
|
||||
result, err := m.MatchSongs(ctx, songs, 5)
|
||||
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(result).To(HaveLen(1))
|
||||
|
|
@ -535,7 +582,7 @@ var _ = Describe("Matcher", func() {
|
|||
|
||||
setupTitleOnlyExpectations(model.MediaFiles{fuzzyMatch, exactMatch})
|
||||
|
||||
result, err := m.MatchSongsToLibrary(ctx, songs, 5)
|
||||
result, err := m.MatchSongs(ctx, songs, 5)
|
||||
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(result).To(HaveLen(1))
|
||||
|
|
@ -556,7 +603,7 @@ var _ = Describe("Matcher", func() {
|
|||
|
||||
setupTitleOnlyExpectations(model.MediaFiles{albumMatch, starredTrack})
|
||||
|
||||
result, err := m.MatchSongsToLibrary(ctx, songs, 5)
|
||||
result, err := m.MatchSongs(ctx, songs, 5)
|
||||
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(result).To(HaveLen(1))
|
||||
|
|
@ -577,7 +624,7 @@ var _ = Describe("Matcher", func() {
|
|||
|
||||
setupTitleOnlyExpectations(model.MediaFiles{albumMatch, ratedTrack})
|
||||
|
||||
result, err := m.MatchSongsToLibrary(ctx, songs, 5)
|
||||
result, err := m.MatchSongs(ctx, songs, 5)
|
||||
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(result).To(HaveLen(1))
|
||||
|
|
@ -603,7 +650,7 @@ var _ = Describe("Matcher", func() {
|
|||
|
||||
setupTitleOnlyExpectations(model.MediaFiles{wrongDuration, correctMatch})
|
||||
|
||||
result, err := m.MatchSongsToLibrary(ctx, songs, 5)
|
||||
result, err := m.MatchSongs(ctx, songs, 5)
|
||||
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(result).To(HaveLen(1))
|
||||
|
|
@ -620,7 +667,7 @@ var _ = Describe("Matcher", func() {
|
|||
|
||||
setupTitleOnlyExpectations(model.MediaFiles{closeDuration})
|
||||
|
||||
result, err := m.MatchSongsToLibrary(ctx, songs, 5)
|
||||
result, err := m.MatchSongs(ctx, songs, 5)
|
||||
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(result).To(HaveLen(1))
|
||||
|
|
@ -640,7 +687,7 @@ var _ = Describe("Matcher", func() {
|
|||
|
||||
setupTitleOnlyExpectations(model.MediaFiles{farDuration, closeDuration})
|
||||
|
||||
result, err := m.MatchSongsToLibrary(ctx, songs, 5)
|
||||
result, err := m.MatchSongs(ctx, songs, 5)
|
||||
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(result).To(HaveLen(1))
|
||||
|
|
@ -657,7 +704,7 @@ var _ = Describe("Matcher", func() {
|
|||
|
||||
setupTitleOnlyExpectations(model.MediaFiles{differentDuration})
|
||||
|
||||
result, err := m.MatchSongsToLibrary(ctx, songs, 5)
|
||||
result, err := m.MatchSongs(ctx, songs, 5)
|
||||
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(result).To(HaveLen(1))
|
||||
|
|
@ -677,7 +724,7 @@ var _ = Describe("Matcher", func() {
|
|||
|
||||
setupTitleOnlyExpectations(model.MediaFiles{differentTitle, correctTitle})
|
||||
|
||||
result, err := m.MatchSongsToLibrary(ctx, songs, 5)
|
||||
result, err := m.MatchSongs(ctx, songs, 5)
|
||||
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(result).To(HaveLen(1))
|
||||
|
|
@ -694,7 +741,7 @@ var _ = Describe("Matcher", func() {
|
|||
|
||||
setupTitleOnlyExpectations(model.MediaFiles{anyTrack})
|
||||
|
||||
result, err := m.MatchSongsToLibrary(ctx, songs, 5)
|
||||
result, err := m.MatchSongs(ctx, songs, 5)
|
||||
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(result).To(HaveLen(1))
|
||||
|
|
@ -711,7 +758,7 @@ var _ = Describe("Matcher", func() {
|
|||
|
||||
setupTitleOnlyExpectations(model.MediaFiles{shortTrack})
|
||||
|
||||
result, err := m.MatchSongsToLibrary(ctx, songs, 5)
|
||||
result, err := m.MatchSongs(ctx, songs, 5)
|
||||
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(result).To(HaveLen(1))
|
||||
|
|
@ -737,7 +784,7 @@ var _ = Describe("Matcher", func() {
|
|||
|
||||
setupTitleOnlyExpectations(model.MediaFiles{libraryTrack})
|
||||
|
||||
result, err := m.MatchSongsToLibrary(ctx, songs, 5)
|
||||
result, err := m.MatchSongs(ctx, songs, 5)
|
||||
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(result).To(HaveLen(2))
|
||||
|
|
@ -757,7 +804,7 @@ var _ = Describe("Matcher", func() {
|
|||
|
||||
setupTitleOnlyExpectations(model.MediaFiles{trackA, trackB, trackC})
|
||||
|
||||
result, err := m.MatchSongsToLibrary(ctx, songs, 5)
|
||||
result, err := m.MatchSongs(ctx, songs, 5)
|
||||
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(result).To(HaveLen(3))
|
||||
|
|
@ -778,7 +825,7 @@ var _ = Describe("Matcher", func() {
|
|||
|
||||
setupTitleOnlyExpectations(model.MediaFiles{trackA, trackB})
|
||||
|
||||
result, err := m.MatchSongsToLibrary(ctx, songs, 2)
|
||||
result, err := m.MatchSongs(ctx, songs, 2)
|
||||
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(result).To(HaveLen(2))
|
||||
|
|
|
|||
130
core/sonic/sonic.go
Normal file
130
core/sonic/sonic.go
Normal file
|
|
@ -0,0 +1,130 @@
|
|||
package sonic
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"github.com/navidrome/navidrome/core/agents"
|
||||
"github.com/navidrome/navidrome/core/matcher"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
)
|
||||
|
||||
const capabilitySonicSimilarity = "SonicSimilarity"
|
||||
|
||||
type SimilarResult struct {
|
||||
Song agents.Song
|
||||
Similarity float64
|
||||
}
|
||||
|
||||
type SimilarMatch struct {
|
||||
MediaFile model.MediaFile
|
||||
Similarity float64
|
||||
}
|
||||
|
||||
type Provider interface {
|
||||
GetSonicSimilarTracks(ctx context.Context, mf *model.MediaFile, count int) ([]SimilarResult, error)
|
||||
FindSonicPath(ctx context.Context, startMF, endMF *model.MediaFile, count int) ([]SimilarResult, error)
|
||||
}
|
||||
|
||||
type PluginLoader interface {
|
||||
PluginNames(capability string) []string
|
||||
LoadSonicSimilarity(name string) (Provider, bool)
|
||||
}
|
||||
|
||||
type Sonic struct {
|
||||
ds model.DataStore
|
||||
pluginLoader PluginLoader
|
||||
matcher *matcher.Matcher
|
||||
}
|
||||
|
||||
func New(ds model.DataStore, pluginLoader PluginLoader, matcher *matcher.Matcher) *Sonic {
|
||||
return &Sonic{
|
||||
ds: ds,
|
||||
pluginLoader: pluginLoader,
|
||||
matcher: matcher,
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Sonic) HasProvider() bool {
|
||||
return len(s.pluginLoader.PluginNames(capabilitySonicSimilarity)) > 0
|
||||
}
|
||||
|
||||
func (s *Sonic) loadProvider() (Provider, error) {
|
||||
names := s.pluginLoader.PluginNames(capabilitySonicSimilarity)
|
||||
if len(names) == 0 {
|
||||
return nil, model.ErrNotFound
|
||||
}
|
||||
provider, ok := s.pluginLoader.LoadSonicSimilarity(names[0])
|
||||
if !ok {
|
||||
return nil, model.ErrNotFound
|
||||
}
|
||||
return provider, nil
|
||||
}
|
||||
|
||||
func (s *Sonic) resolveMatches(ctx context.Context, results []SimilarResult) ([]SimilarMatch, error) {
|
||||
songs := make([]agents.Song, len(results))
|
||||
for i, r := range results {
|
||||
songs[i] = r.Song
|
||||
}
|
||||
|
||||
matchMap, err := s.matcher.MatchSongsIndexed(ctx, songs)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("matching songs to library: %w", err)
|
||||
}
|
||||
|
||||
var matches []SimilarMatch
|
||||
for i, r := range results {
|
||||
if mf, ok := matchMap[i]; ok {
|
||||
matches = append(matches, SimilarMatch{
|
||||
MediaFile: mf,
|
||||
Similarity: r.Similarity,
|
||||
})
|
||||
}
|
||||
}
|
||||
return matches, nil
|
||||
}
|
||||
|
||||
func (s *Sonic) GetSonicSimilarTracks(ctx context.Context, id string, count int) ([]SimilarMatch, error) {
|
||||
provider, err := s.loadProvider()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
mf, err := s.ds.MediaFile(ctx).Get(id)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("getting media file %s: %w", id, err)
|
||||
}
|
||||
|
||||
results, err := provider.GetSonicSimilarTracks(ctx, mf, count)
|
||||
if err != nil {
|
||||
log.Error(ctx, "Plugin GetSonicSimilarTracks failed", "id", id, err)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return s.resolveMatches(ctx, results)
|
||||
}
|
||||
|
||||
func (s *Sonic) FindSonicPath(ctx context.Context, startID, endID string, count int) ([]SimilarMatch, error) {
|
||||
provider, err := s.loadProvider()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
startMF, err := s.ds.MediaFile(ctx).Get(startID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("getting start media file %s: %w", startID, err)
|
||||
}
|
||||
endMF, err := s.ds.MediaFile(ctx).Get(endID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("getting end media file %s: %w", endID, err)
|
||||
}
|
||||
|
||||
results, err := provider.FindSonicPath(ctx, startMF, endMF, count)
|
||||
if err != nil {
|
||||
log.Error(ctx, "Plugin FindSonicPath failed", "startId", startID, "endId", endID, err)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return s.resolveMatches(ctx, results)
|
||||
}
|
||||
17
core/sonic/sonic_suite_test.go
Normal file
17
core/sonic/sonic_suite_test.go
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
package sonic_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/tests"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
func TestSonic(t *testing.T) {
|
||||
tests.Init(t, false)
|
||||
log.SetLevel(log.LevelFatal)
|
||||
RegisterFailHandler(Fail)
|
||||
RunSpecs(t, "Sonic Suite")
|
||||
}
|
||||
146
core/sonic/sonic_test.go
Normal file
146
core/sonic/sonic_test.go
Normal file
|
|
@ -0,0 +1,146 @@
|
|||
package sonic_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
|
||||
"github.com/navidrome/navidrome/core/agents"
|
||||
"github.com/navidrome/navidrome/core/matcher"
|
||||
"github.com/navidrome/navidrome/core/sonic"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/tests"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
type mockPluginLoader struct {
|
||||
names []string
|
||||
provider sonic.Provider
|
||||
loadOk bool
|
||||
}
|
||||
|
||||
func (m *mockPluginLoader) PluginNames(capability string) []string {
|
||||
if capability == "SonicSimilarity" {
|
||||
return m.names
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *mockPluginLoader) LoadSonicSimilarity(name string) (sonic.Provider, bool) {
|
||||
return m.provider, m.loadOk
|
||||
}
|
||||
|
||||
type mockProvider struct {
|
||||
similarResults []sonic.SimilarResult
|
||||
similarErr error
|
||||
pathResults []sonic.SimilarResult
|
||||
pathErr error
|
||||
}
|
||||
|
||||
func (m *mockProvider) GetSonicSimilarTracks(_ context.Context, _ *model.MediaFile, _ int) ([]sonic.SimilarResult, error) {
|
||||
return m.similarResults, m.similarErr
|
||||
}
|
||||
|
||||
func (m *mockProvider) FindSonicPath(_ context.Context, _, _ *model.MediaFile, _ int) ([]sonic.SimilarResult, error) {
|
||||
return m.pathResults, m.pathErr
|
||||
}
|
||||
|
||||
var _ = Describe("Sonic", func() {
|
||||
var (
|
||||
ctx context.Context
|
||||
ds *tests.MockDataStore
|
||||
loader *mockPluginLoader
|
||||
service *sonic.Sonic
|
||||
)
|
||||
|
||||
BeforeEach(func() {
|
||||
ctx = GinkgoT().Context()
|
||||
ds = &tests.MockDataStore{}
|
||||
loader = &mockPluginLoader{}
|
||||
})
|
||||
|
||||
Describe("HasProvider", func() {
|
||||
It("returns false when no plugins available", func() {
|
||||
loader.names = nil
|
||||
service = sonic.New(ds, loader, nil)
|
||||
Expect(service.HasProvider()).To(BeFalse())
|
||||
})
|
||||
|
||||
It("returns true when a plugin is available", func() {
|
||||
loader.names = []string{"test-plugin"}
|
||||
service = sonic.New(ds, loader, nil)
|
||||
Expect(service.HasProvider()).To(BeTrue())
|
||||
})
|
||||
})
|
||||
|
||||
Describe("GetSonicSimilarTracks", func() {
|
||||
It("returns error when no plugin available", func() {
|
||||
loader.names = nil
|
||||
service = sonic.New(ds, loader, nil)
|
||||
_, err := service.GetSonicSimilarTracks(ctx, "song-1", 10)
|
||||
Expect(err).To(MatchError(model.ErrNotFound))
|
||||
})
|
||||
|
||||
It("returns error when media file not found", func() {
|
||||
loader.names = []string{"test-plugin"}
|
||||
loader.provider = &mockProvider{}
|
||||
loader.loadOk = true
|
||||
ds.MockedMediaFile = &tests.MockMediaFileRepo{}
|
||||
service = sonic.New(ds, loader, matcher.New(ds))
|
||||
_, err := service.GetSonicSimilarTracks(ctx, "nonexistent", 10)
|
||||
Expect(err).To(HaveOccurred())
|
||||
})
|
||||
|
||||
It("returns matched results from plugin", func() {
|
||||
mf1 := model.MediaFile{ID: "song-1", Title: "Test Song", Artist: "Test Artist"}
|
||||
mf2 := model.MediaFile{ID: "song-2", Title: "Similar Song", Artist: "Test Artist"}
|
||||
|
||||
mockRepo := tests.CreateMockMediaFileRepo()
|
||||
mockRepo.SetData(model.MediaFiles{mf1, mf2})
|
||||
ds.MockedMediaFile = mockRepo
|
||||
|
||||
provider := &mockProvider{
|
||||
similarResults: []sonic.SimilarResult{
|
||||
{Song: agents.Song{ID: "song-2", Name: "Similar Song", Artist: "Test Artist"}, Similarity: 0.85},
|
||||
},
|
||||
}
|
||||
loader.names = []string{"test-plugin"}
|
||||
loader.provider = provider
|
||||
loader.loadOk = true
|
||||
|
||||
service = sonic.New(ds, loader, matcher.New(ds))
|
||||
matches, err := service.GetSonicSimilarTracks(ctx, "song-1", 10)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(matches).To(HaveLen(1))
|
||||
Expect(matches[0].MediaFile.ID).To(Equal("song-2"))
|
||||
Expect(matches[0].Similarity).To(Equal(0.85))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("FindSonicPath", func() {
|
||||
It("returns error when no plugin available", func() {
|
||||
loader.names = nil
|
||||
service = sonic.New(ds, loader, nil)
|
||||
_, err := service.FindSonicPath(ctx, "song-1", "song-2", 25)
|
||||
Expect(err).To(MatchError(model.ErrNotFound))
|
||||
})
|
||||
|
||||
It("returns error when plugin call fails", func() {
|
||||
mf1 := model.MediaFile{ID: "song-1", Title: "Start", Artist: "Artist"}
|
||||
mf2 := model.MediaFile{ID: "song-2", Title: "End", Artist: "Artist"}
|
||||
|
||||
mockRepo := tests.CreateMockMediaFileRepo()
|
||||
mockRepo.SetData(model.MediaFiles{mf1, mf2})
|
||||
ds.MockedMediaFile = mockRepo
|
||||
|
||||
provider := &mockProvider{pathErr: errors.New("plugin error")}
|
||||
loader.names = []string{"test-plugin"}
|
||||
loader.provider = provider
|
||||
loader.loadOk = true
|
||||
|
||||
service = sonic.New(ds, loader, matcher.New(ds))
|
||||
_, err := service.FindSonicPath(ctx, "song-1", "song-2", 25)
|
||||
Expect(err).To(HaveOccurred())
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -1,8 +1,8 @@
|
|||
# Navidrome Plugin System
|
||||
|
||||
Navidrome supports WebAssembly (Wasm) plugins for extending functionality. Plugins run in a secure sandbox and can provide metadata agents, scrobblers, and other integrations through host services like scheduling, caching, WebSockets, and Subsonic API access.
|
||||
Navidrome supports WebAssembly (Wasm) plugins for extending functionality. Plugins run in a secure sandbox and can provide metadata agents, scrobblers, lyrics providers, audio similarity, and other integrations through host services like scheduling, caching, task queues, WebSockets, and Subsonic API access.
|
||||
|
||||
The plugin system is built on **[Extism](https://extism.org/)**, a cross-language framework for building WebAssembly plugins. This means you can write plugins in any language that Extism supports (Go, Rust, Python, TypeScript, and more) using their Plugin Development Kits (PDKs).
|
||||
The plugin system is built on **[Extism](https://extism.org/)**, a cross-language framework for building WebAssembly plugins. You can write plugins in any language that Extism supports (Go, Rust, Python, TypeScript, and more) using their Plugin Development Kits (PDKs).
|
||||
|
||||
**Essential Extism Resources:**
|
||||
- [Extism Documentation](https://extism.org/docs/overview) – Core concepts and architecture
|
||||
|
|
@ -19,12 +19,18 @@ The plugin system is built on **[Extism](https://extism.org/)**, a cross-languag
|
|||
- [Capabilities](#capabilities)
|
||||
- [MetadataAgent](#metadataagent)
|
||||
- [Scrobbler](#scrobbler)
|
||||
- [Lyrics](#lyrics)
|
||||
- [SonicSimilarity](#sonicsimilarity)
|
||||
- [TaskWorker](#taskworker)
|
||||
- [Lifecycle](#lifecycle)
|
||||
- [SchedulerCallback](#schedulercallback)
|
||||
- [WebSocketCallback](#websocketcallback)
|
||||
- [Host Services](#host-services)
|
||||
- [HTTP Requests](#http-requests)
|
||||
- [HTTP](#http)
|
||||
- [Scheduler](#scheduler)
|
||||
- [Cache](#cache)
|
||||
- [KVStore](#kvstore)
|
||||
- [Task](#task)
|
||||
- [WebSocket](#websocket)
|
||||
- [Library](#library)
|
||||
- [Artwork](#artwork)
|
||||
|
|
@ -95,14 +101,6 @@ A Navidrome plugin is an `.ndp` package file (zip archive) containing:
|
|||
1. **`manifest.json`** – Plugin metadata (name, author, version, permissions)
|
||||
2. **`plugin.wasm`** – Compiled WebAssembly module with capability functions
|
||||
|
||||
### Plugin Package Structure
|
||||
|
||||
```
|
||||
my-plugin.ndp (zip archive)
|
||||
├── manifest.json # Required: Plugin metadata
|
||||
└── plugin.wasm # Required: Compiled WebAssembly module
|
||||
```
|
||||
|
||||
### Plugin Naming
|
||||
|
||||
Plugins are identified by their **filename** (without `.ndp` extension), not the manifest `name` field:
|
||||
|
|
@ -123,6 +121,10 @@ Every plugin must include a `manifest.json` file. Example:
|
|||
"version": "1.0.0",
|
||||
"description": "What this plugin does",
|
||||
"website": "https://example.com",
|
||||
"config": {
|
||||
"schema": { ... },
|
||||
"uiSchema": { ... }
|
||||
},
|
||||
"permissions": {
|
||||
"http": {
|
||||
"reason": "Fetch metadata from external API",
|
||||
|
|
@ -134,6 +136,30 @@ Every plugin must include a `manifest.json` file. Example:
|
|||
|
||||
**Required fields:** `name`, `author`, `version`
|
||||
|
||||
**Optional fields:** `description`, `website`, `config`, `permissions`, `experimental`
|
||||
|
||||
#### Config Definition
|
||||
|
||||
The `config` field defines the plugin's configuration schema using [JSON Schema (draft-07)](https://json-schema.org/) and an optional [JSONForms](https://jsonforms.io/) UI schema for rendering in the Navidrome web UI:
|
||||
|
||||
```json
|
||||
{
|
||||
"config": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"api_key": { "type": "string", "title": "API Key" },
|
||||
"max_retries": { "type": "integer", "default": 3 }
|
||||
},
|
||||
"required": ["api_key"]
|
||||
},
|
||||
"uiSchema": {
|
||||
"api_key": { "ui:widget": "password" }
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### Experimental Features
|
||||
|
||||
Plugins can opt-in to experimental WebAssembly features that may change or be removed in future versions. Currently supported:
|
||||
|
|
@ -142,9 +168,6 @@ Plugins can opt-in to experimental WebAssembly features that may change or be re
|
|||
|
||||
```json
|
||||
{
|
||||
"name": "Threaded Plugin",
|
||||
"author": "Author Name",
|
||||
"version": "1.0.0",
|
||||
"experimental": {
|
||||
"threads": {
|
||||
"reason": "Required for concurrent audio processing"
|
||||
|
|
@ -159,50 +182,25 @@ Plugins can opt-in to experimental WebAssembly features that may change or be re
|
|||
|
||||
## Capabilities
|
||||
|
||||
Capabilities define what your plugin can do. They're automatically detected based on which functions you export.
|
||||
Capabilities define what your plugin can do. They're automatically detected based on which functions you export. A plugin can implement multiple capabilities.
|
||||
|
||||
### MetadataAgent
|
||||
|
||||
Provides artist and album metadata. Export one or more of these functions:
|
||||
Provides artist and album metadata. All methods are **optional** — implement only the ones your data source supports.
|
||||
|
||||
| Function | Input | Output | Description |
|
||||
|---------------------------|----------------------------|----------------------------------|----------------------|
|
||||
| `nd_get_artist_mbid` | `{id, name}` | `{mbid}` | Get MusicBrainz ID |
|
||||
| `nd_get_artist_url` | `{id, name, mbid?}` | `{url}` | Get artist URL |
|
||||
| `nd_get_artist_biography` | `{id, name, mbid?}` | `{biography}` | Get artist biography |
|
||||
| `nd_get_similar_artists` | `{id, name, mbid?, limit}` | `{artists: [{name, mbid?}]}` | Get similar artists |
|
||||
| `nd_get_artist_images` | `{id, name, mbid?}` | `{images: [{url, size}]}` | Get artist images |
|
||||
| `nd_get_artist_top_songs` | `{id, name, mbid?, count}` | `{songs: [{name, mbid?}]}` | Get top songs |
|
||||
| `nd_get_album_info` | `{name, artist, mbid?}` | `{name, mbid, description, url}` | Get album info |
|
||||
| `nd_get_album_images` | `{name, artist, mbid?}` | `{images: [{url, size}]}` | Get album images |
|
||||
|
||||
**Example:**
|
||||
|
||||
```go
|
||||
type ArtistInput struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
MBID string `json:"mbid,omitempty"`
|
||||
}
|
||||
|
||||
type BiographyOutput struct {
|
||||
Biography string `json:"biography"`
|
||||
}
|
||||
|
||||
//go:wasmexport nd_get_artist_biography
|
||||
func ndGetArtistBiography() int32 {
|
||||
var input ArtistInput
|
||||
if err := pdk.InputJSON(&input); err != nil {
|
||||
pdk.SetError(err)
|
||||
return 1
|
||||
}
|
||||
|
||||
// Fetch biography from your data source...
|
||||
output := BiographyOutput{Biography: "Artist biography..."}
|
||||
pdk.OutputJSON(output)
|
||||
return 0
|
||||
}
|
||||
```
|
||||
| Function | Input | Output | Description |
|
||||
|-----------------------------------|----------------------------|----------------------------------|--------------------------|
|
||||
| `nd_get_artist_mbid` | `{id, name}` | `{mbid}` | Get MusicBrainz ID |
|
||||
| `nd_get_artist_url` | `{id, name, mbid?}` | `{url}` | Get artist URL |
|
||||
| `nd_get_artist_biography` | `{id, name, mbid?}` | `{biography}` | Get artist biography |
|
||||
| `nd_get_similar_artists` | `{id, name, mbid?, limit}` | `{artists: [{name, mbid?}]}` | Get similar artists |
|
||||
| `nd_get_artist_images` | `{id, name, mbid?}` | `{images: [{url, size}]}` | Get artist images |
|
||||
| `nd_get_artist_top_songs` | `{id, name, mbid?, count}` | `{songs: [{name, mbid?}]}` | Get top songs |
|
||||
| `nd_get_album_info` | `{name, artist, mbid?}` | `{name, mbid, description, url}` | Get album info |
|
||||
| `nd_get_album_images` | `{name, artist, mbid?}` | `{images: [{url, size}]}` | Get album images |
|
||||
| `nd_get_similar_songs_by_track` | `{id, name, artist, ...}` | `{songs: [{name, artist}]}` | Similar songs by track |
|
||||
| `nd_get_similar_songs_by_album` | `{id, name, artist, ...}` | `{songs: [{name, artist}]}` | Similar songs by album |
|
||||
| `nd_get_similar_songs_by_artist` | `{id, name, mbid?, count}` | `{songs: [{name, artist}]}` | Similar songs by artist |
|
||||
|
||||
To use the plugin as a metadata agent, add it to your config:
|
||||
|
||||
|
|
@ -210,17 +208,49 @@ To use the plugin as a metadata agent, add it to your config:
|
|||
Agents = "lastfm,spotify,my-plugin"
|
||||
```
|
||||
|
||||
**Example (using Go PDK package):**
|
||||
|
||||
```go
|
||||
package main
|
||||
|
||||
import "github.com/navidrome/navidrome/plugins/pdk/go/metadata"
|
||||
|
||||
type myPlugin struct{}
|
||||
|
||||
func (p *myPlugin) GetArtistBiography(input metadata.ArtistRequest) (*metadata.ArtistBiographyResponse, error) {
|
||||
return &metadata.ArtistBiographyResponse{Biography: "Biography text..."}, nil
|
||||
}
|
||||
|
||||
func init() { metadata.Register(&myPlugin{}) }
|
||||
func main() {}
|
||||
```
|
||||
|
||||
**Example (raw wasmexport):**
|
||||
|
||||
```go
|
||||
//go:wasmexport nd_get_artist_biography
|
||||
func ndGetArtistBiography() int32 {
|
||||
var input ArtistInput
|
||||
if err := pdk.InputJSON(&input); err != nil {
|
||||
pdk.SetError(err)
|
||||
return 1
|
||||
}
|
||||
pdk.OutputJSON(BiographyOutput{Biography: "Artist biography..."})
|
||||
return 0
|
||||
}
|
||||
```
|
||||
|
||||
### Scrobbler
|
||||
|
||||
Integrates with external scrobbling services. Export one or more of these functions:
|
||||
Integrates with external scrobbling services. All three methods are **required**.
|
||||
|
||||
| Function | Input | Output | Description |
|
||||
|------------------------------|-----------------------|----------------|-----------------------------|
|
||||
| `nd_scrobbler_is_authorized` | `{username}` | `bool` | Check if user is authorized |
|
||||
| `nd_scrobbler_now_playing` | See below | (none) | Send now playing |
|
||||
| `nd_scrobbler_scrobble` | See below | (none) | Submit a scrobble |
|
||||
| Function | Input | Output | Description |
|
||||
|------------------------------|-----------------------|--------|-----------------------------|
|
||||
| `nd_scrobbler_is_authorized` | `{username}` | `bool` | Check if user is authorized |
|
||||
| `nd_scrobbler_now_playing` | See below | (none) | Send now playing |
|
||||
| `nd_scrobbler_scrobble` | See below | (none) | Submit a scrobble |
|
||||
|
||||
> **Important:** Scrobbler plugins require the `users` permission in their manifest. Scrobble events are only sent for users assigned to the plugin through Navidrome's configuration. The `nd_scrobbler_is_authorized` function is called after the server-side user check passes.
|
||||
> **Important:** Scrobbler plugins require the `users` permission in their manifest. Scrobble events are only sent for users assigned to the plugin through Navidrome's configuration.
|
||||
|
||||
**Manifest permission:**
|
||||
|
||||
|
|
@ -267,31 +297,95 @@ On success, return `0`. On failure, use `pdk.SetError()` with one of these error
|
|||
```go
|
||||
import "github.com/navidrome/navidrome/plugins/pdk/go/scrobbler"
|
||||
|
||||
// Return error using predefined constants
|
||||
return scrobbler.ScrobblerErrorNotAuthorized
|
||||
return scrobbler.ScrobblerErrorRetryLater
|
||||
return scrobbler.ScrobblerErrorUnrecoverable
|
||||
```
|
||||
|
||||
### Lyrics
|
||||
|
||||
Provides lyrics for tracks. The single method is **required**.
|
||||
|
||||
| Function | Input | Output | Description |
|
||||
|-------------------------|-------------------------------|------------------------------------|-----------------|
|
||||
| `nd_lyrics_get_lyrics` | `{artistName, title, ...}` | `{lyrics: [{lang, text}]}` | Get lyrics |
|
||||
|
||||
Each returned lyric entry has a `lang` (language code) and `text` field. Multiple entries can be returned for different languages.
|
||||
|
||||
### SonicSimilarity
|
||||
|
||||
Audio-similarity discovery based on acoustic features (e.g., embeddings). Both methods are **required**.
|
||||
|
||||
| Function | Input | Output | Description |
|
||||
|---------------------------------|----------------------------------|--------------------------------------------|---------------------------------------|
|
||||
| `nd_get_sonic_similar_tracks` | `{song, count}` | `{matches: [{song, similarity}]}` | Find acoustically similar tracks |
|
||||
| `nd_find_sonic_path` | `{startSong, endSong, count}` | `{matches: [{song, similarity}]}` | Find a path between two songs |
|
||||
|
||||
Each match contains a `song` reference and a `similarity` score (float64, 0.0–1.0).
|
||||
|
||||
### TaskWorker
|
||||
|
||||
Processes tasks from a queue. The method is **optional** — export it if your plugin uses the [Task](#task) host service for background work.
|
||||
|
||||
| Function | Input | Output | Description |
|
||||
|---------------------|---------------------------------------------|---------|----------------------|
|
||||
| `nd_task_execute` | `{queueName, taskID, payload, attempt}` | `string`| Execute a queued task|
|
||||
|
||||
The `payload` is raw bytes (the same bytes passed to `TaskEnqueue`). The `attempt` counter starts at 1 and increments on retries. Return a string result on success.
|
||||
|
||||
### Lifecycle
|
||||
|
||||
Optional initialization callback. Export this function to run code when your plugin loads:
|
||||
Optional initialization callback. Called once after the plugin fully loads.
|
||||
|
||||
| Function | Input | Output | Description |
|
||||
|--------------|-------|------------|--------------------------------|
|
||||
| `nd_on_init` | `{}` | `{error?}` | Called once after plugin loads |
|
||||
|
||||
Useful for initializing connections, scheduling recurring tasks, etc.
|
||||
Useful for initializing connections, scheduling recurring tasks, etc. Errors are logged but don't prevent the plugin from loading.
|
||||
|
||||
### SchedulerCallback
|
||||
|
||||
Receives scheduled task events. **Required** if your plugin uses the [Scheduler](#scheduler) host service.
|
||||
|
||||
| Function | Input | Output | Description |
|
||||
|---------------------------|----------------------------------------------|--------|-----------------------------|
|
||||
| `nd_scheduler_callback` | `{scheduleId, payload, isRecurring}` | (none) | Handle scheduled task event |
|
||||
|
||||
### WebSocketCallback
|
||||
|
||||
Receives WebSocket events. Export any subset of these to handle events from the [WebSocket](#websocket) host service.
|
||||
|
||||
| Function | Input | Description |
|
||||
|----------------------------------|---------------------------------|----------------------------------|
|
||||
| `nd_websocket_on_text_message` | `{connectionId, message}` | Text message received |
|
||||
| `nd_websocket_on_binary_message` | `{connectionId, data}` | Binary message received (base64) |
|
||||
| `nd_websocket_on_error` | `{connectionId, error}` | Connection error |
|
||||
| `nd_websocket_on_close` | `{connectionId, code, reason}` | Connection closed |
|
||||
|
||||
---
|
||||
|
||||
## Host Services
|
||||
|
||||
Host services let your plugin call back into Navidrome for advanced functionality. Each service requires declaring the permission in your manifest.
|
||||
Host services let your plugin call back into Navidrome for advanced functionality. Each service (except [Config](#config)) requires declaring the corresponding permission in your manifest.
|
||||
|
||||
### HTTP Requests
|
||||
### Go PDK Setup
|
||||
|
||||
Make HTTP requests using the Extism PDK's built-in HTTP support. See your [Extism PDK documentation](https://extism.org/docs/concepts/pdk) for more details on making requests.
|
||||
All host service examples below use the generated Go SDK. Add this to your `go.mod`:
|
||||
|
||||
```
|
||||
require github.com/navidrome/navidrome/plugins/pdk/go v0.0.0
|
||||
replace github.com/navidrome/navidrome/plugins/pdk/go => ../../pdk/go
|
||||
```
|
||||
|
||||
Then import:
|
||||
|
||||
```go
|
||||
import "github.com/navidrome/navidrome/plugins/pdk/go/host"
|
||||
```
|
||||
|
||||
### HTTP
|
||||
|
||||
Make HTTP requests to external services. This is a dedicated host service (separate from Extism's built-in HTTP support) with additional features like timeouts and redirect control.
|
||||
|
||||
**Manifest permission:**
|
||||
|
||||
|
|
@ -306,22 +400,28 @@ Make HTTP requests using the Extism PDK's built-in HTTP support. See your [Extis
|
|||
}
|
||||
```
|
||||
|
||||
**Host functions:**
|
||||
|
||||
| Function | Parameters | Returns |
|
||||
|-------------|----------------------------------------------------------|----------------------------------|
|
||||
| `http_send` | `method, url, headers, body, timeoutMs, noFollowRedirects` | `statusCode, headers, body` |
|
||||
|
||||
**Usage:**
|
||||
|
||||
```go
|
||||
req := pdk.NewHTTPRequest(pdk.MethodGet, "https://api.example.com/data")
|
||||
req.SetHeader("Authorization", "Bearer " + apiKey)
|
||||
resp := req.Send()
|
||||
|
||||
if resp.Status() == 200 {
|
||||
data := resp.Body()
|
||||
// Process response...
|
||||
resp, err := host.HTTPSend(host.HTTPRequest{
|
||||
Method: "GET",
|
||||
URL: "https://api.example.com/data",
|
||||
Headers: map[string]string{"Authorization": "Bearer " + apiKey},
|
||||
})
|
||||
if resp.StatusCode == 200 {
|
||||
// Process resp.Body
|
||||
}
|
||||
```
|
||||
|
||||
### Scheduler
|
||||
|
||||
Schedule one-time or recurring tasks. Your plugin must export `nd_scheduler_callback` to receive events.
|
||||
Schedule one-time or recurring tasks. Your plugin must export the [`nd_scheduler_callback`](#schedulercallback) function to receive events.
|
||||
|
||||
**Manifest permission:**
|
||||
|
||||
|
|
@ -343,40 +443,9 @@ Schedule one-time or recurring tasks. Your plugin must export `nd_scheduler_call
|
|||
| `scheduler_schedulerecurring` | `cronExpression, payload, scheduleId?` | Schedule recurring callback |
|
||||
| `scheduler_cancelschedule` | `scheduleId` | Cancel a scheduled task |
|
||||
|
||||
**Callback function:**
|
||||
**Usage:**
|
||||
|
||||
```go
|
||||
type SchedulerCallbackInput struct {
|
||||
ScheduleID string `json:"scheduleId"`
|
||||
Payload string `json:"payload"`
|
||||
IsRecurring bool `json:"isRecurring"`
|
||||
}
|
||||
|
||||
//go:wasmexport nd_scheduler_callback
|
||||
func ndSchedulerCallback() int32 {
|
||||
var input SchedulerCallbackInput
|
||||
pdk.InputJSON(&input)
|
||||
|
||||
// Handle the scheduled task based on payload
|
||||
pdk.Log(pdk.LogInfo, "Task fired: " + input.ScheduleID)
|
||||
return 0
|
||||
}
|
||||
```
|
||||
|
||||
**Scheduling tasks (using generated SDK):**
|
||||
|
||||
Add the generated SDK to your `go.mod`:
|
||||
|
||||
```
|
||||
require github.com/navidrome/navidrome/plugins/pdk/go v0.0.0
|
||||
replace github.com/navidrome/navidrome/plugins/pdk/go => ../../pdk/go
|
||||
```
|
||||
|
||||
Then import and use:
|
||||
|
||||
```go
|
||||
import "github.com/navidrome/navidrome/plugins/pdk/go/host"
|
||||
|
||||
// Schedule one-time task in 60 seconds
|
||||
scheduleID, err := host.SchedulerScheduleOneTime(60, "my-payload", "")
|
||||
|
||||
|
|
@ -389,7 +458,7 @@ err := host.SchedulerCancelSchedule(scheduleID)
|
|||
|
||||
### Cache
|
||||
|
||||
Store and retrieve data in an in-memory TTL-based cache. Each plugin has its own isolated namespace.
|
||||
In-memory TTL-based cache. Each plugin has its own isolated namespace. Cleared on server restart.
|
||||
|
||||
**Manifest permission:**
|
||||
|
||||
|
|
@ -420,28 +489,22 @@ Store and retrieve data in an in-memory TTL-based cache. Each plugin has its own
|
|||
|
||||
**TTL:** Pass `0` for the default (24 hours), or specify seconds.
|
||||
|
||||
**Usage (with generated SDK):**
|
||||
|
||||
Import the Go SDK (see [Scheduler](#scheduler) for `go.mod` setup):
|
||||
**Usage:**
|
||||
|
||||
```go
|
||||
import "github.com/navidrome/navidrome/plugins/pdk/go/host"
|
||||
|
||||
// Cache a value for 1 hour
|
||||
host.CacheSetString("api-response", responseData, 3600)
|
||||
|
||||
// Retrieve (check Exists before using Value)
|
||||
result, err := host.CacheGetString("api-response")
|
||||
if result.Exists {
|
||||
data := result.Value
|
||||
// Retrieve (returns value, exists, error)
|
||||
value, exists, err := host.CacheGetString("api-response")
|
||||
if exists {
|
||||
// Use value
|
||||
}
|
||||
```
|
||||
|
||||
> **Note:** Cache is in-memory only and cleared on server restart.
|
||||
|
||||
### KVStore
|
||||
|
||||
Persistent key-value storage that survives server restarts. Each plugin has its own isolated SQLite database.
|
||||
Persistent key-value storage backed by SQLite. Survives server restarts. Each plugin has its own isolated database at `${DataFolder}/plugins/${pluginID}/kvstore.db`.
|
||||
|
||||
**Manifest permission:**
|
||||
|
||||
|
|
@ -456,61 +519,101 @@ Persistent key-value storage that survives server restarts. Each plugin has its
|
|||
}
|
||||
```
|
||||
|
||||
**Permission options:**
|
||||
- `maxSize`: Maximum storage size (e.g., `"1MB"`, `"500KB"`). Default: 1MB
|
||||
|
||||
**Key constraints:** Maximum 256 bytes, must be valid UTF-8.
|
||||
|
||||
**Host functions:**
|
||||
|
||||
| Function | Parameters | Description |
|
||||
|--------------------------|--------------|-----------------------------------|
|
||||
| `kvstore_set` | `key, value` | Store a byte value |
|
||||
| `kvstore_get` | `key` | Retrieve a byte value |
|
||||
| `kvstore_delete` | `key` | Delete a value |
|
||||
| `kvstore_has` | `key` | Check if key exists |
|
||||
| `kvstore_list` | `prefix` | List keys matching prefix |
|
||||
| `kvstore_getstorageused` | - | Get current storage usage (bytes) |
|
||||
| Function | Parameters | Description |
|
||||
|-----------------------------|--------------------------|-----------------------------------|
|
||||
| `kvstore_set` | `key, value` | Store a byte value |
|
||||
| `kvstore_setwithttl` | `key, value, ttlSeconds` | Store with auto-expiration |
|
||||
| `kvstore_get` | `key` | Retrieve a byte value |
|
||||
| `kvstore_getmany` | `keys` | Retrieve multiple values at once |
|
||||
| `kvstore_has` | `key` | Check if key exists |
|
||||
| `kvstore_list` | `prefix` | List keys matching prefix |
|
||||
| `kvstore_delete` | `key` | Delete a value |
|
||||
| `kvstore_deletebyprefix` | `prefix` | Delete all keys matching prefix |
|
||||
| `kvstore_getstorageused` | – | Get current storage usage (bytes) |
|
||||
|
||||
**Key constraints:**
|
||||
- Maximum key length: 256 bytes
|
||||
- Keys must be valid UTF-8 strings
|
||||
|
||||
**Usage (with generated SDK):**
|
||||
|
||||
Import the Go SDK (see [Scheduler](#scheduler) for `go.mod` setup):
|
||||
**Usage:**
|
||||
|
||||
```go
|
||||
import "github.com/navidrome/navidrome/plugins/pdk/go/host"
|
||||
|
||||
// Store a value (as raw bytes)
|
||||
token := []byte(`{"access_token": "xyz", "refresh_token": "abc"}`)
|
||||
_, err := host.KVStoreSet("oauth:spotify", token)
|
||||
host.KVStoreSet("oauth:spotify", token)
|
||||
|
||||
// Store with TTL (auto-expires after 1 hour)
|
||||
host.KVStoreSetWithTTL("session:abc", sessionData, 3600)
|
||||
|
||||
// Retrieve a value
|
||||
result, err := host.KVStoreGet("oauth:spotify")
|
||||
if result.Exists {
|
||||
value, exists, err := host.KVStoreGet("oauth:spotify")
|
||||
if exists {
|
||||
var tokenData map[string]string
|
||||
json.Unmarshal(result.Value, &tokenData)
|
||||
json.Unmarshal(value, &tokenData)
|
||||
}
|
||||
|
||||
// List all keys with prefix
|
||||
keysResult, err := host.KVStoreList("user:")
|
||||
for _, key := range keysResult.Keys {
|
||||
// Process each key
|
||||
}
|
||||
// Batch retrieve
|
||||
results, err := host.KVStoreGetMany([]string{"key1", "key2", "key3"})
|
||||
|
||||
// List and delete by prefix
|
||||
keys, err := host.KVStoreList("user:")
|
||||
host.KVStoreDeleteByPrefix("user:")
|
||||
|
||||
// Check storage usage
|
||||
usageResult, err := host.KVStoreGetStorageUsed()
|
||||
fmt.Printf("Using %d bytes\n", usageResult.Bytes)
|
||||
|
||||
// Delete a value
|
||||
host.KVStoreDelete("oauth:spotify")
|
||||
usage, err := host.KVStoreGetStorageUsed()
|
||||
fmt.Printf("Using %d bytes\n", usage)
|
||||
```
|
||||
|
||||
> **Note:** Unlike Cache, KVStore data persists across server restarts. Storage is located at `${DataFolder}/plugins/${pluginID}/kvstore.db`.
|
||||
### Task
|
||||
|
||||
Background task queue with retry support. Plugins enqueue tasks and process them by exporting the [`nd_task_execute`](#taskworker) capability function.
|
||||
|
||||
**Manifest permission:**
|
||||
|
||||
```json
|
||||
{
|
||||
"permissions": {
|
||||
"taskqueue": {
|
||||
"reason": "Process audio analysis in the background",
|
||||
"maxConcurrency": 2
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Host functions:**
|
||||
|
||||
| Function | Parameters | Description |
|
||||
|---------------------|---------------------------------------------------|----------------------------|
|
||||
| `task_createqueue` | `name, concurrency, maxRetries, backoffMs, ...` | Create a named task queue |
|
||||
| `task_enqueue` | `queueName, payload` | Add a task to the queue |
|
||||
| `task_get` | `taskID` | Get task status and result |
|
||||
| `task_cancel` | `taskID` | Cancel a pending task |
|
||||
| `task_clearqueue` | `queueName` | Remove all tasks from queue|
|
||||
|
||||
**Usage:**
|
||||
|
||||
```go
|
||||
// Create a queue with retry configuration
|
||||
host.TaskCreateQueue("analysis", host.QueueConfig{
|
||||
Concurrency: 2,
|
||||
MaxRetries: 3,
|
||||
BackoffMs: 1000,
|
||||
})
|
||||
|
||||
// Enqueue a task
|
||||
taskID, err := host.TaskEnqueue("analysis", []byte(`{"trackId": "abc"}`))
|
||||
|
||||
// Check task status
|
||||
info, err := host.TaskGet(taskID)
|
||||
fmt.Printf("Status: %s, Attempt: %d\n", info.Status, info.Attempt)
|
||||
```
|
||||
|
||||
### WebSocket
|
||||
|
||||
Establish persistent WebSocket connections to external services.
|
||||
Establish persistent WebSocket connections to external services. Your plugin must export [WebSocketCallback](#websocketcallback) functions to receive events.
|
||||
|
||||
**Manifest permission:**
|
||||
|
||||
|
|
@ -527,21 +630,20 @@ Establish persistent WebSocket connections to external services.
|
|||
|
||||
**Host functions:**
|
||||
|
||||
| Function | Parameters | Description |
|
||||
|------------------------|---------------------------------|-------------------|
|
||||
| `websocket_connect` | `url, headers?, connectionId?` | Open a connection |
|
||||
| `websocket_sendtext` | `connectionId, message` | Send text message |
|
||||
| `websocket_sendbinary` | `connectionId, data` | Send binary data |
|
||||
| `websocket_close` | `connectionId, code?, reason?` | Close connection |
|
||||
| Function | Parameters | Description |
|
||||
|----------------------------|---------------------------------|-------------------|
|
||||
| `websocket_connect` | `url, headers?, connectionId?` | Open a connection |
|
||||
| `websocket_sendtext` | `connectionId, message` | Send text message |
|
||||
| `websocket_sendbinary` | `connectionId, data` | Send binary data |
|
||||
| `websocket_closeconnection`| `connectionId, code?, reason?` | Close connection |
|
||||
|
||||
**Callback functions (export these to receive events):**
|
||||
**Usage:**
|
||||
|
||||
| Function | Input | Description |
|
||||
|----------------------------------|---------------------------------|----------------------------------|
|
||||
| `nd_websocket_on_text_message` | `{connectionId, message}` | Text message received |
|
||||
| `nd_websocket_on_binary_message` | `{connectionId, data}` | Binary message received (base64) |
|
||||
| `nd_websocket_on_error` | `{connectionId, error}` | Connection error |
|
||||
| `nd_websocket_on_close` | `{connectionId, code, reason}` | Connection closed |
|
||||
```go
|
||||
connID, err := host.WebSocketConnect("wss://gateway.example.com", nil, "")
|
||||
host.WebSocketSendText(connID, `{"op": 1, "d": null}`)
|
||||
host.WebSocketCloseConnection(connID, 1000, "done")
|
||||
```
|
||||
|
||||
### Library
|
||||
|
||||
|
|
@ -595,33 +697,22 @@ When `filesystem: true`, your plugin can read files from library directories via
|
|||
```go
|
||||
import "os"
|
||||
|
||||
// Read a file from library 1
|
||||
content, err := os.ReadFile("/libraries/1/Artist/Album/track.mp3")
|
||||
|
||||
// List directory contents
|
||||
entries, err := os.ReadDir("/libraries/1/Artist")
|
||||
```
|
||||
|
||||
> **Security:** Filesystem access is read-only and restricted to configured library paths only. Plugins cannot access other parts of the host filesystem.
|
||||
> **Security:** Filesystem access is read-only and restricted to configured library paths only.
|
||||
|
||||
**Usage (with generated SDK):**
|
||||
|
||||
Import the Go SDK (see [Scheduler](#scheduler) for `go.mod` setup). The `Library` struct is provided by the SDK:
|
||||
**Usage:**
|
||||
|
||||
```go
|
||||
import "github.com/navidrome/navidrome/plugins/pdk/go/host"
|
||||
|
||||
// Get a specific library
|
||||
resp, err := host.LibraryGetLibrary(1)
|
||||
if err != nil {
|
||||
// Handle error
|
||||
}
|
||||
library := resp.Result
|
||||
library, err := host.LibraryGetLibrary(1)
|
||||
fmt.Printf("Library: %s (%d songs)\n", library.Name, library.TotalSongs)
|
||||
|
||||
// Get all libraries
|
||||
resp, err := host.LibraryGetAllLibraries()
|
||||
for _, lib := range resp.Result {
|
||||
// lib is of type host.Library
|
||||
libraries, err := host.LibraryGetAllLibraries()
|
||||
for _, lib := range libraries {
|
||||
fmt.Printf("Library: %s (%d songs)\n", lib.Name, lib.TotalSongs)
|
||||
}
|
||||
```
|
||||
|
|
@ -651,6 +742,12 @@ Generate public URLs for Navidrome artwork (albums, artists, tracks, playlists).
|
|||
| `artwork_gettrackurl` | `id, size` | Artwork URL |
|
||||
| `artwork_getplaylisturl` | `id, size` | Artwork URL |
|
||||
|
||||
**Usage:**
|
||||
|
||||
```go
|
||||
url, err := host.ArtworkGetAlbumUrl("album-id", 300)
|
||||
```
|
||||
|
||||
### SubsonicAPI
|
||||
|
||||
Call Navidrome's Subsonic API internally (no network round-trip).
|
||||
|
|
@ -670,24 +767,28 @@ Call Navidrome's Subsonic API internally (no network round-trip).
|
|||
}
|
||||
```
|
||||
|
||||
> **Important:** The `subsonicapi` permission requires the `users` permission. User access is controlled through the plugin's database configuration, not the manifest. Configure which users can use the plugin through the Navidrome UI or API.
|
||||
> **Important:** The `subsonicapi` permission requires the `users` permission. Which users the plugin can act as is controlled through the Navidrome UI.
|
||||
|
||||
**Host function:**
|
||||
**Host functions:**
|
||||
|
||||
| Function | Parameters | Returns |
|
||||
|--------------------|------------|---------------|
|
||||
| `subsonicapi_call` | `uri` | JSON response |
|
||||
| Function | Parameters | Returns |
|
||||
|-----------------------|------------|--------------------------------|
|
||||
| `subsonicapi_call` | `uri` | JSON response string |
|
||||
| `subsonicapi_callraw` | `uri` | Content type + binary response |
|
||||
|
||||
**Usage:**
|
||||
|
||||
```go
|
||||
// The URI must include the 'u' parameter with the username
|
||||
response, err := SubsonicAPICall("getAlbumList2?type=random&size=10&u=username")
|
||||
// JSON response
|
||||
response, err := host.SubsonicAPICall("getAlbumList2?type=random&size=10&u=username")
|
||||
|
||||
// Binary response (e.g., cover art, streams)
|
||||
contentType, data, err := host.SubsonicAPICallRaw("getCoverArt?id=al-123&u=username")
|
||||
```
|
||||
|
||||
### Config
|
||||
|
||||
Access plugin configuration values programmatically. Unlike `pdk.GetConfig()` which only retrieves individual values, this service can list all available configuration keys—useful for discovering dynamic configuration (e.g., user-to-token mappings).
|
||||
Access plugin configuration values. Unlike `pdk.GetConfig()` which only retrieves individual values, this service can list all available configuration keys — useful for discovering dynamic configuration.
|
||||
|
||||
> **Note:** This service is always available and does not require a manifest permission.
|
||||
|
||||
|
|
@ -699,25 +800,17 @@ Access plugin configuration values programmatically. Unlike `pdk.GetConfig()` wh
|
|||
| `config_getint` | `key` | `value, exists` |
|
||||
| `config_keys` | `prefix` | Array of matching key names |
|
||||
|
||||
**Usage (with generated SDK):**
|
||||
**Usage:**
|
||||
|
||||
```go
|
||||
import "github.com/navidrome/navidrome/plugins/pdk/go/host"
|
||||
|
||||
// Get a string configuration value
|
||||
// Get a configuration value
|
||||
value, exists := host.ConfigGet("api_key")
|
||||
if exists {
|
||||
// Use the value
|
||||
}
|
||||
|
||||
// Get an integer configuration value
|
||||
count, exists := host.ConfigGetInt("max_retries")
|
||||
|
||||
// List all keys with a prefix (useful for user-specific config)
|
||||
keys := host.ConfigKeys("user:")
|
||||
for _, key := range keys {
|
||||
// key might be "user:john", "user:jane", etc.
|
||||
}
|
||||
|
||||
// List all configuration keys
|
||||
allKeys := host.ConfigKeys("")
|
||||
|
|
@ -725,7 +818,7 @@ allKeys := host.ConfigKeys("")
|
|||
|
||||
### Users
|
||||
|
||||
Access user information for the users that the plugin has been granted access to. This is useful for plugins that need to associate data with specific users or display user information.
|
||||
Access user information for the users that the plugin has been granted access to.
|
||||
|
||||
**Manifest permission:**
|
||||
|
||||
|
|
@ -739,7 +832,7 @@ Access user information for the users that the plugin has been granted access to
|
|||
}
|
||||
```
|
||||
|
||||
**Important:** Before enabling a plugin that requires the `users` permission, an administrator must configure which users the plugin can access. This can be done in two ways:
|
||||
**Important:** Before enabling a plugin that requires the `users` permission, an administrator must configure which users the plugin can access:
|
||||
|
||||
1. **Allow all users** – Enable the "Allow all users" toggle in the plugin settings
|
||||
2. **Select specific users** – Choose individual users from the user list
|
||||
|
|
@ -751,6 +844,7 @@ If neither option is configured, the plugin cannot be enabled.
|
|||
| Function | Parameters | Returns |
|
||||
|------------------|------------|-----------------------|
|
||||
| `users_getusers` | – | Array of User objects |
|
||||
| `users_getadmins`| – | Array of admin Users |
|
||||
|
||||
**User object fields:**
|
||||
|
||||
|
|
@ -762,45 +856,15 @@ If neither option is configured, the plugin cannot be enabled.
|
|||
|
||||
> **Security:** Sensitive fields like passwords, email addresses, and internal IDs are never exposed to plugins.
|
||||
|
||||
**Usage (with generated SDK):**
|
||||
**Usage:**
|
||||
|
||||
```go
|
||||
import "github.com/navidrome/navidrome/plugins/pdk/go/host"
|
||||
|
||||
// Get all users the plugin has access to
|
||||
users, err := host.UsersGetUsers()
|
||||
if err != nil {
|
||||
pdk.Log(pdk.LogError, "Failed to get users: " + err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
for _, user := range users {
|
||||
pdk.Log(pdk.LogInfo, "User: " + user.UserName + " (" + user.Name + ")")
|
||||
if user.IsAdmin {
|
||||
pdk.Log(pdk.LogInfo, " - Administrator")
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Rust example:**
|
||||
|
||||
```rust
|
||||
use nd_pdk_host::users::get_users;
|
||||
|
||||
let users = get_users()?;
|
||||
for user in users {
|
||||
println!("User: {} ({})", user.user_name, user.name);
|
||||
}
|
||||
```
|
||||
|
||||
**Python example:**
|
||||
|
||||
```python
|
||||
from host.nd_host_users import users_get_users
|
||||
|
||||
users = users_get_users()
|
||||
for user in users:
|
||||
print(f"User: {user['userName']} ({user['name']})")
|
||||
admins, err := host.UsersGetAdmins()
|
||||
```
|
||||
|
||||
---
|
||||
|
|
@ -834,20 +898,20 @@ if !ok {
|
|||
}
|
||||
```
|
||||
|
||||
For more advanced access (listing keys, integer values), use the [Config](#config) host service.
|
||||
|
||||
---
|
||||
|
||||
## Building Plugins
|
||||
|
||||
### Supported Languages
|
||||
|
||||
Plugins can be written in any language that Extism supports. Each language has its own PDK (Plugin Development Kit) that provides the APIs for I/O, logging, configuration, and HTTP requests. See the [Extism PDK documentation](https://extism.org/docs/concepts/pdk) for details.
|
||||
Plugins can be written in any language that Extism supports. We recommend:
|
||||
|
||||
We recommend:
|
||||
|
||||
- **Go** – Best experience with [TinyGo](https://tinygo.org/) and the [Go PDK](https://github.com/extism/go-pdk)
|
||||
- **Rust** – Excellent performance with the [Rust PDK](https://github.com/extism/rust-pdk)
|
||||
- **Python** – Experimental support via [extism-py](https://github.com/extism/python-pdk)
|
||||
- **TypeScript** – Experimental support via [extism-js](https://github.com/extism/js-pdk)
|
||||
- **Go** – Best overall experience with [TinyGo](https://tinygo.org/) and the [Go PDK](https://github.com/extism/go-pdk). Familiar syntax, excellent stdlib support.
|
||||
- **Rust** – Best for performance-critical plugins. Smallest binaries, excellent type safety. Uses the [Rust PDK](https://github.com/extism/rust-pdk).
|
||||
- **Python** – Best for rapid prototyping. Experimental support via [extism-py](https://github.com/extism/python-pdk). Note some limitations compared to compiled languages.
|
||||
- **TypeScript** – Experimental support via [extism-js](https://github.com/extism/js-pdk).
|
||||
|
||||
### Go with TinyGo (Recommended)
|
||||
|
||||
|
|
@ -863,14 +927,12 @@ zip -j my-plugin.ndp manifest.json plugin.wasm
|
|||
|
||||
#### Using Go PDK Packages
|
||||
|
||||
Navidrome provides type-safe Go packages for each capability in `plugins/pdk/go/`. Instead of manually exporting functions with `//go:wasmexport`, use the `Register()` pattern:
|
||||
Navidrome provides type-safe Go packages for each capability and host service in `plugins/pdk/go/`. Instead of manually exporting functions with `//go:wasmexport`, use the `Register()` pattern:
|
||||
|
||||
```go
|
||||
package main
|
||||
|
||||
import (
|
||||
"github.com/navidrome/navidrome/plugins/pdk/go/metadata"
|
||||
)
|
||||
import "github.com/navidrome/navidrome/plugins/pdk/go/metadata"
|
||||
|
||||
type myPlugin struct{}
|
||||
|
||||
|
|
@ -878,10 +940,7 @@ func (p *myPlugin) GetArtistBiography(input metadata.ArtistRequest) (*metadata.A
|
|||
return &metadata.ArtistBiographyResponse{Biography: "Biography text..."}, nil
|
||||
}
|
||||
|
||||
func init() {
|
||||
metadata.Register(&myPlugin{})
|
||||
}
|
||||
|
||||
func init() { metadata.Register(&myPlugin{}) }
|
||||
func main() {}
|
||||
```
|
||||
|
||||
|
|
@ -892,16 +951,19 @@ require github.com/navidrome/navidrome v0.0.0
|
|||
replace github.com/navidrome/navidrome => ../../..
|
||||
```
|
||||
|
||||
Available capability packages:
|
||||
**Available capability packages:**
|
||||
|
||||
| Package | Import Path | Description |
|
||||
|-------------|----------------------------|--------------------------------------|
|
||||
| `metadata` | `plugins/pdk/go/metadata` | Artist/album metadata providers |
|
||||
| `scrobbler` | `plugins/pdk/go/scrobbler` | Scrobbling services |
|
||||
| `lifecycle` | `plugins/pdk/go/lifecycle` | Plugin initialization |
|
||||
| `scheduler` | `plugins/pdk/go/scheduler` | Scheduled task callbacks |
|
||||
| `websocket` | `plugins/pdk/go/websocket` | WebSocket event handlers |
|
||||
| `host` | `plugins/pdk/go/host` | Host service SDK (HTTP, cache, etc.) |
|
||||
| Package | Import Path | Description |
|
||||
|-------------------|--------------------------------------|--------------------------------------|
|
||||
| `metadata` | `plugins/pdk/go/metadata` | Artist/album metadata providers |
|
||||
| `scrobbler` | `plugins/pdk/go/scrobbler` | Scrobbling services |
|
||||
| `lyrics` | `plugins/pdk/go/lyrics` | Lyrics providers |
|
||||
| `sonicsimilarity` | `plugins/pdk/go/sonicsimilarity` | Audio similarity discovery |
|
||||
| `taskworker` | `plugins/pdk/go/taskworker` | Background task processing |
|
||||
| `lifecycle` | `plugins/pdk/go/lifecycle` | Plugin initialization |
|
||||
| `scheduler` | `plugins/pdk/go/scheduler` | Scheduled task callbacks |
|
||||
| `websocket` | `plugins/pdk/go/websocket` | WebSocket event handlers |
|
||||
| `host` | `plugins/pdk/go/host` | Host service SDK (all services) |
|
||||
|
||||
See the example plugins in [examples/](examples/) for complete usage patterns.
|
||||
|
||||
|
|
@ -917,8 +979,6 @@ zip -j my-plugin.ndp manifest.json target/wasm32-wasip1/release/plugin.wasm
|
|||
|
||||
#### Using Rust PDK
|
||||
|
||||
The Rust PDK provides generated type-safe wrappers for both capabilities and host services:
|
||||
|
||||
```toml
|
||||
# Cargo.toml
|
||||
[dependencies]
|
||||
|
|
@ -953,17 +1013,12 @@ register_scrobbler!(MyPlugin); // Generates all WASM exports
|
|||
```rust
|
||||
use nd_pdk::host::{cache, scheduler, library};
|
||||
|
||||
// Cache a value for 1 hour
|
||||
cache::set_string("my_key", "my_value", 3600)?;
|
||||
|
||||
// Schedule a recurring task
|
||||
scheduler::schedule_recurring("@every 5m", "payload", "task_id")?;
|
||||
|
||||
// Access library metadata
|
||||
let libs = library::get_all_libraries()?;
|
||||
```
|
||||
|
||||
See [pdk/rust/README.md](pdk/rust/README.md) for detailed documentation and examples.
|
||||
See [pdk/rust/README.md](pdk/rust/README.md) for detailed documentation.
|
||||
|
||||
### Python (with extism-py)
|
||||
|
||||
|
|
@ -975,6 +1030,8 @@ extism-py plugin.wasm -o plugin.wasm *.py
|
|||
zip -j my-plugin.ndp manifest.json plugin.wasm
|
||||
```
|
||||
|
||||
**For Python host services:** Copy functions from the `nd_host_*.py` files in `plugins/pdk/python/host/` into your `__init__.py` (see comments in those files for extism-py limitations).
|
||||
|
||||
### Using XTP CLI (Scaffolding)
|
||||
|
||||
Bootstrap a new plugin from a schema:
|
||||
|
|
@ -996,66 +1053,38 @@ zip -j my-agent.ndp manifest.json dist/plugin.wasm
|
|||
|
||||
See [capabilities/README.md](capabilities/README.md) for available schemas and scaffolding examples.
|
||||
|
||||
### Using Host Service SDKs
|
||||
|
||||
Generated SDKs for calling host services are in `plugins/pdk/go/`, `plugins/pdk/python/` and `plugins/pdk/rust`.
|
||||
|
||||
**For Go plugins:** Import the SDK as a Go module:
|
||||
|
||||
```go
|
||||
import "github.com/navidrome/navidrome/plugins/pdk/go/host"
|
||||
```
|
||||
|
||||
Add to your `go.mod`:
|
||||
|
||||
```
|
||||
require github.com/navidrome/navidrome/plugins/pdk/go v0.0.0
|
||||
replace github.com/navidrome/navidrome/plugins/pdk/go => ../../pdk/go
|
||||
```
|
||||
|
||||
See [pdk/go/README.md](pdk/go/README.md) for detailed documentation.
|
||||
|
||||
**For Python plugins:** Copy functions from `nd_host_*.py` into your `__init__.py` (see comments in those files for extism-py limitations).
|
||||
|
||||
**Recommendations:**
|
||||
|
||||
- **Go:** Best overall experience with excellent stdlib support and familiar syntax for most developers. Recommended if you're already in the Go ecosystem.
|
||||
- **Rust:** Best for performance-critical plugins or when leveraging Rust's ecosystem. Produces smallest binaries with excellent type safety.
|
||||
- **Python:** Best for rapid prototyping or simple plugins. Note that extism-py has limitations compared to compiled languages.
|
||||
|
||||
---
|
||||
|
||||
## Examples
|
||||
|
||||
See [examples/](examples/) for complete working plugins:
|
||||
|
||||
| Plugin | Language | Capabilities | Host Services | Description |
|
||||
|----------------------------------------------------------------|----------|---------------|--------------------------------------------|--------------------------------|
|
||||
| [minimal](examples/minimal/) | Go | MetadataAgent | – | Basic structure example |
|
||||
| [wikimedia](examples/wikimedia/) | Go | MetadataAgent | HTTP | Wikidata/Wikipedia integration |
|
||||
| [coverartarchive-py](examples/coverartarchive-py/) | Python | MetadataAgent | HTTP | Cover Art Archive |
|
||||
| [webhook-rs](examples/webhook-rs/) | Rust | Scrobbler | HTTP | HTTP webhooks |
|
||||
| [nowplaying-py](examples/nowplaying-py/) | Python | Lifecycle | Scheduler, SubsonicAPI | Periodic now-playing logger |
|
||||
| [library-inspector](examples/library-inspector-rs/) | Rust | Lifecycle | Library, Scheduler | Periodic library stats logging |
|
||||
| [crypto-ticker](examples/crypto-ticker/) | Go | Lifecycle | WebSocket, Scheduler | Real-time crypto prices demo |
|
||||
| [discord-rich-presence-rs](examples/discord-rich-presence-rs/) | Rust | Scrobbler | HTTP, WebSocket, Cache, Scheduler, Artwork | Discord integration (Rust) |
|
||||
| Plugin | Language | Capabilities | Host Services | Description |
|
||||
|----------------------------------------------------------------|----------------|---------------|--------------------------------------------|--------------------------------|
|
||||
| [minimal](examples/minimal/) | Go | MetadataAgent | – | Basic structure example |
|
||||
| [wikimedia](examples/wikimedia/) | Go | MetadataAgent | HTTP | Wikidata/Wikipedia integration |
|
||||
| [coverartarchive-py](examples/coverartarchive-py/) | Python | MetadataAgent | HTTP | Cover Art Archive |
|
||||
| [coverartarchive-as](examples/coverartarchive-as/) | AssemblyScript | MetadataAgent | HTTP | Cover Art Archive |
|
||||
| [webhook-rs](examples/webhook-rs/) | Rust | Scrobbler | HTTP | HTTP webhooks |
|
||||
| [nowplaying-py](examples/nowplaying-py/) | Python | Lifecycle | Scheduler, SubsonicAPI | Periodic now-playing logger |
|
||||
| [library-inspector-rs](examples/library-inspector-rs/) | Rust | Lifecycle | Library, Scheduler | Periodic library stats logging |
|
||||
| [crypto-ticker](examples/crypto-ticker/) | Go | Lifecycle | WebSocket, Scheduler | Real-time crypto prices demo |
|
||||
| [discord-rich-presence-rs](examples/discord-rich-presence-rs/) | Rust | Scrobbler | HTTP, WebSocket, Cache, Scheduler, Artwork | Discord integration |
|
||||
|
||||
---
|
||||
|
||||
|
||||
## Security
|
||||
|
||||
Plugins run in a secure WebAssembly sandbox provided by [Extism](https://extism.org/) and the [Wazero](https://wazero.io/) runtime:
|
||||
|
||||
1. **Host Allowlisting** – Only explicitly allowed hosts are accessible via HTTP/WebSocket
|
||||
2. **Limited File System** – Plugins can only access library directories when explicitly granted the `library.filesystem` permission, and access is read-only
|
||||
2. **Limited File System** – Read-only access to library directories, only when explicitly granted the `library.filesystem` permission
|
||||
3. **No Network Listeners** – Plugins cannot bind ports
|
||||
4. **Config Isolation** – Plugins only receive their own config section
|
||||
5. **Memory Limits** – Controlled by the WebAssembly runtime
|
||||
6. **User-Scoped Authorization** – Plugins with `subsonicapi` or `scrobbler` capabilities can only access/receive events for users assigned to them through Navidrome's configuration. The `users` permission is required for these features.
|
||||
6. **User-Scoped Authorization** – Plugins with `subsonicapi` or `scrobbler` capabilities can only access/receive events for users assigned to them through Navidrome's configuration
|
||||
7. **Users Permission** – Plugins requesting user access must be explicitly configured with allowed users; sensitive data (passwords, emails) is never exposed
|
||||
|
||||
|
||||
---
|
||||
|
||||
## Runtime Management
|
||||
|
|
@ -1064,7 +1093,7 @@ Plugins run in a secure WebAssembly sandbox provided by [Extism](https://extism.
|
|||
|
||||
With `AutoReload = true`, Navidrome watches the plugins folder and automatically detects when `.ndp` files are added, modified, or removed. When a plugin file changes, the plugin is disabled and its metadata is re-read from the archive.
|
||||
|
||||
If the `AutoReload` setting is disabled, Navidrome needs to be restarted to pick up plugin changes.
|
||||
If `AutoReload` is disabled, Navidrome needs to be restarted to pick up plugin changes.
|
||||
|
||||
### Enabling/Disabling Plugins
|
||||
|
||||
|
|
@ -1074,4 +1103,4 @@ Plugins can be enabled/disabled via the Navidrome UI. The plugin state is persis
|
|||
|
||||
- **In-flight requests** – When reloading, existing requests complete before the new version takes over
|
||||
- **Config changes** – Changes to the plugin configuration in the UI are applied immediately
|
||||
- **Cache persistence** – The in-memory cache is cleared when a plugin is unloaded
|
||||
- **Cache persistence** – The in-memory cache is cleared when a plugin is unloaded
|
||||
|
|
@ -102,6 +102,12 @@ components:
|
|||
mbzReleaseTrackId:
|
||||
type: string
|
||||
description: MBZReleaseTrackID is the MusicBrainz release track ID.
|
||||
libraryId:
|
||||
type: integer
|
||||
format: int32
|
||||
description: |-
|
||||
LibraryID is the ID of the library the track belongs to.
|
||||
Only included if the plugin has library permission with filesystem access for the track's library.
|
||||
path:
|
||||
type: string
|
||||
description: |-
|
||||
|
|
|
|||
|
|
@ -128,6 +128,12 @@ components:
|
|||
mbzReleaseTrackId:
|
||||
type: string
|
||||
description: MBZReleaseTrackID is the MusicBrainz release track ID.
|
||||
libraryId:
|
||||
type: integer
|
||||
format: int32
|
||||
description: |-
|
||||
LibraryID is the ID of the library the track belongs to.
|
||||
Only included if the plugin has library permission with filesystem access for the track's library.
|
||||
path:
|
||||
type: string
|
||||
description: |-
|
||||
|
|
|
|||
32
plugins/capabilities/sonic_similarity.go
Normal file
32
plugins/capabilities/sonic_similarity.go
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
package capabilities
|
||||
|
||||
// SonicSimilarity provides audio-similarity based track discovery.
|
||||
//
|
||||
//nd:capability name=sonicsimilarity required=true
|
||||
type SonicSimilarity interface {
|
||||
//nd:export name=nd_get_sonic_similar_tracks
|
||||
GetSonicSimilarTracks(GetSonicSimilarTracksRequest) (SonicSimilarityResponse, error)
|
||||
|
||||
//nd:export name=nd_find_sonic_path
|
||||
FindSonicPath(FindSonicPathRequest) (SonicSimilarityResponse, error)
|
||||
}
|
||||
|
||||
type GetSonicSimilarTracksRequest struct {
|
||||
Song SongRef `json:"song"`
|
||||
Count int32 `json:"count"`
|
||||
}
|
||||
|
||||
type FindSonicPathRequest struct {
|
||||
StartSong SongRef `json:"startSong"`
|
||||
EndSong SongRef `json:"endSong"`
|
||||
Count int32 `json:"count"`
|
||||
}
|
||||
|
||||
type SonicSimilarityResponse struct {
|
||||
Matches []SonicMatch `json:"matches"`
|
||||
}
|
||||
|
||||
type SonicMatch struct {
|
||||
Song SongRef `json:"song"`
|
||||
Similarity float64 `json:"similarity"`
|
||||
}
|
||||
92
plugins/capabilities/sonic_similarity.yaml
Normal file
92
plugins/capabilities/sonic_similarity.yaml
Normal file
|
|
@ -0,0 +1,92 @@
|
|||
version: v1-draft
|
||||
exports:
|
||||
nd_get_sonic_similar_tracks:
|
||||
input:
|
||||
$ref: '#/components/schemas/GetSonicSimilarTracksRequest'
|
||||
contentType: application/json
|
||||
output:
|
||||
$ref: '#/components/schemas/SonicSimilarityResponse'
|
||||
contentType: application/json
|
||||
nd_find_sonic_path:
|
||||
input:
|
||||
$ref: '#/components/schemas/FindSonicPathRequest'
|
||||
contentType: application/json
|
||||
output:
|
||||
$ref: '#/components/schemas/SonicSimilarityResponse'
|
||||
contentType: application/json
|
||||
components:
|
||||
schemas:
|
||||
FindSonicPathRequest:
|
||||
properties:
|
||||
startSong:
|
||||
$ref: '#/components/schemas/SongRef'
|
||||
endSong:
|
||||
$ref: '#/components/schemas/SongRef'
|
||||
count:
|
||||
type: integer
|
||||
format: int32
|
||||
required:
|
||||
- startSong
|
||||
- endSong
|
||||
- count
|
||||
GetSonicSimilarTracksRequest:
|
||||
properties:
|
||||
song:
|
||||
$ref: '#/components/schemas/SongRef'
|
||||
count:
|
||||
type: integer
|
||||
format: int32
|
||||
required:
|
||||
- song
|
||||
- count
|
||||
SongRef:
|
||||
description: SongRef is a reference to a song with metadata for matching.
|
||||
properties:
|
||||
id:
|
||||
type: string
|
||||
description: ID is the internal Navidrome mediafile ID (if known).
|
||||
name:
|
||||
type: string
|
||||
description: Name is the song name.
|
||||
mbid:
|
||||
type: string
|
||||
description: MBID is the MusicBrainz ID for the song.
|
||||
isrc:
|
||||
type: string
|
||||
description: ISRC is the International Standard Recording Code for the song.
|
||||
artist:
|
||||
type: string
|
||||
description: Artist is the artist name.
|
||||
artistMbid:
|
||||
type: string
|
||||
description: ArtistMBID is the MusicBrainz artist ID.
|
||||
album:
|
||||
type: string
|
||||
description: Album is the album name.
|
||||
albumMbid:
|
||||
type: string
|
||||
description: AlbumMBID is the MusicBrainz release ID.
|
||||
duration:
|
||||
type: number
|
||||
format: float
|
||||
description: Duration is the song duration in seconds.
|
||||
required:
|
||||
- name
|
||||
SonicMatch:
|
||||
properties:
|
||||
song:
|
||||
$ref: '#/components/schemas/SongRef'
|
||||
similarity:
|
||||
type: number
|
||||
format: float
|
||||
required:
|
||||
- song
|
||||
- similarity
|
||||
SonicSimilarityResponse:
|
||||
properties:
|
||||
matches:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/SonicMatch'
|
||||
required:
|
||||
- matches
|
||||
|
|
@ -21,6 +21,10 @@ func init() {
|
|||
)
|
||||
}
|
||||
|
||||
func newLyricsPlugin(p *plugin) *LyricsPlugin {
|
||||
return &LyricsPlugin{name: p.name, plugin: p}
|
||||
}
|
||||
|
||||
// LyricsPlugin adapts a WASM plugin with the Lyrics capability.
|
||||
type LyricsPlugin struct {
|
||||
name string
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ import (
|
|||
"github.com/navidrome/navidrome/core/agents"
|
||||
"github.com/navidrome/navidrome/core/lyrics"
|
||||
"github.com/navidrome/navidrome/core/scrobbler"
|
||||
"github.com/navidrome/navidrome/core/sonic"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/server/events"
|
||||
|
|
@ -238,65 +239,32 @@ func (m *Manager) PluginNames(capability string) []string {
|
|||
return names
|
||||
}
|
||||
|
||||
// LoadMediaAgent loads and returns a media agent plugin by name.
|
||||
// Returns false if the plugin is not found or doesn't have the MetadataAgent capability.
|
||||
func (m *Manager) LoadMediaAgent(name string) (agents.Interface, bool) {
|
||||
m.mu.RLock()
|
||||
plugin, ok := m.plugins[name]
|
||||
m.mu.RUnlock()
|
||||
|
||||
if !ok || !hasCapability(plugin.capabilities, CapabilityMetadataAgent) {
|
||||
return nil, false
|
||||
}
|
||||
|
||||
// Create a new metadata agent adapter for this plugin
|
||||
return &MetadataAgent{
|
||||
name: plugin.name,
|
||||
plugin: plugin,
|
||||
}, true
|
||||
return loadPlugin(m, name, CapabilityMetadataAgent, newMetadataAgent)
|
||||
}
|
||||
|
||||
// LoadScrobbler loads and returns a scrobbler plugin by name.
|
||||
// Returns false if the plugin is not found or doesn't have the Scrobbler capability.
|
||||
func (m *Manager) LoadScrobbler(name string) (scrobbler.Scrobbler, bool) {
|
||||
m.mu.RLock()
|
||||
plugin, ok := m.plugins[name]
|
||||
m.mu.RUnlock()
|
||||
|
||||
if !ok || !hasCapability(plugin.capabilities, CapabilityScrobbler) {
|
||||
return nil, false
|
||||
}
|
||||
|
||||
// Build user ID map for fast lookups
|
||||
userIDMap := make(map[string]struct{})
|
||||
for _, id := range plugin.allowedUserIDs {
|
||||
userIDMap[id] = struct{}{}
|
||||
}
|
||||
|
||||
// Create a new scrobbler adapter for this plugin with user authorization config
|
||||
return &ScrobblerPlugin{
|
||||
name: plugin.name,
|
||||
plugin: plugin,
|
||||
allowedUserIDs: plugin.allowedUserIDs,
|
||||
allUsers: plugin.allUsers,
|
||||
userIDMap: userIDMap,
|
||||
}, true
|
||||
return loadPlugin(m, name, CapabilityScrobbler, newScrobblerPlugin)
|
||||
}
|
||||
|
||||
// LoadLyricsProvider loads and returns a lyrics provider plugin by name.
|
||||
func (m *Manager) LoadLyricsProvider(name string) (lyrics.Lyrics, bool) {
|
||||
return loadPlugin(m, name, CapabilityLyrics, newLyricsPlugin)
|
||||
}
|
||||
|
||||
func (m *Manager) LoadSonicSimilarity(name string) (sonic.Provider, bool) {
|
||||
return loadPlugin(m, name, CapabilitySonicSimilarity, newSonicSimilarityPlugin)
|
||||
}
|
||||
|
||||
func loadPlugin[T any](m *Manager, name string, cap Capability, newAdapter func(*plugin) T) (T, bool) {
|
||||
m.mu.RLock()
|
||||
plugin, ok := m.plugins[name]
|
||||
p, ok := m.plugins[name]
|
||||
m.mu.RUnlock()
|
||||
|
||||
if !ok || !hasCapability(plugin.capabilities, CapabilityLyrics) {
|
||||
return nil, false
|
||||
var zero T
|
||||
if !ok || !hasCapability(p.capabilities, cap) {
|
||||
return zero, false
|
||||
}
|
||||
|
||||
return &LyricsPlugin{
|
||||
name: plugin.name,
|
||||
plugin: plugin,
|
||||
}, true
|
||||
return newAdapter(p), true
|
||||
}
|
||||
|
||||
// PluginInfo contains basic information about a plugin for metrics/insights.
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import (
|
|||
|
||||
"github.com/navidrome/navidrome/core/agents"
|
||||
"github.com/navidrome/navidrome/plugins/capabilities"
|
||||
"github.com/navidrome/navidrome/utils/slice"
|
||||
)
|
||||
|
||||
// CapabilityMetadataAgent indicates the plugin can provide artist/album metadata.
|
||||
|
|
@ -44,6 +45,10 @@ func init() {
|
|||
)
|
||||
}
|
||||
|
||||
func newMetadataAgent(p *plugin) *MetadataAgent {
|
||||
return &MetadataAgent{name: p.name, plugin: p}
|
||||
}
|
||||
|
||||
// MetadataAgent is an adapter that wraps an Extism plugin and implements
|
||||
// the agents interfaces for metadata retrieval.
|
||||
type MetadataAgent struct {
|
||||
|
|
@ -222,23 +227,24 @@ func (a *MetadataAgent) GetSimilarSongsByArtist(ctx context.Context, id, name, m
|
|||
return callSimilarSongsPluginFunction[capabilities.SimilarSongsByArtistRequest](ctx, a.plugin, FuncGetSimilarSongsByArtist, capabilities.SimilarSongsByArtistRequest{ID: id, Name: name, MBID: mbid, Count: int32(count)})
|
||||
}
|
||||
|
||||
// songRefToAgentSong converts a single SongRef to agents.Song
|
||||
func songRefToAgentSong(s capabilities.SongRef) agents.Song {
|
||||
return agents.Song{
|
||||
ID: s.ID,
|
||||
Name: s.Name,
|
||||
MBID: s.MBID,
|
||||
ISRC: s.ISRC,
|
||||
Artist: s.Artist,
|
||||
ArtistMBID: s.ArtistMBID,
|
||||
Album: s.Album,
|
||||
AlbumMBID: s.AlbumMBID,
|
||||
Duration: uint32(s.Duration * 1000),
|
||||
}
|
||||
}
|
||||
|
||||
// songRefsToAgentSongs converts a slice of SongRef to agents.Song
|
||||
func songRefsToAgentSongs(refs []capabilities.SongRef) []agents.Song {
|
||||
songs := make([]agents.Song, len(refs))
|
||||
for i, s := range refs {
|
||||
songs[i] = agents.Song{
|
||||
ID: s.ID,
|
||||
Name: s.Name,
|
||||
MBID: s.MBID,
|
||||
ISRC: s.ISRC,
|
||||
Artist: s.Artist,
|
||||
ArtistMBID: s.ArtistMBID,
|
||||
Album: s.Album,
|
||||
AlbumMBID: s.AlbumMBID,
|
||||
Duration: uint32(s.Duration * 1000),
|
||||
}
|
||||
}
|
||||
return songs
|
||||
return slice.Map(refs, songRefToAgentSong)
|
||||
}
|
||||
|
||||
// Verify interface implementations at compile time
|
||||
|
|
|
|||
|
|
@ -68,6 +68,9 @@ type TrackInfo struct {
|
|||
MBZReleaseGroupID string `json:"mbzReleaseGroupId,omitempty"`
|
||||
// MBZReleaseTrackID is the MusicBrainz release track ID.
|
||||
MBZReleaseTrackID string `json:"mbzReleaseTrackId,omitempty"`
|
||||
// LibraryID is the ID of the library the track belongs to.
|
||||
// Only included if the plugin has library permission with filesystem access for the track's library.
|
||||
LibraryID int32 `json:"libraryId,omitempty"`
|
||||
// Path is the full path to the track file, relative to the library root.
|
||||
// Only included if the plugin has library permission with filesystem access for the track's library.
|
||||
Path string `json:"path,omitempty"`
|
||||
|
|
|
|||
|
|
@ -65,6 +65,9 @@ type TrackInfo struct {
|
|||
MBZReleaseGroupID string `json:"mbzReleaseGroupId,omitempty"`
|
||||
// MBZReleaseTrackID is the MusicBrainz release track ID.
|
||||
MBZReleaseTrackID string `json:"mbzReleaseTrackId,omitempty"`
|
||||
// LibraryID is the ID of the library the track belongs to.
|
||||
// Only included if the plugin has library permission with filesystem access for the track's library.
|
||||
LibraryID int32 `json:"libraryId,omitempty"`
|
||||
// Path is the full path to the track file, relative to the library root.
|
||||
// Only included if the plugin has library permission with filesystem access for the track's library.
|
||||
Path string `json:"path,omitempty"`
|
||||
|
|
|
|||
|
|
@ -92,6 +92,9 @@ type TrackInfo struct {
|
|||
MBZReleaseGroupID string `json:"mbzReleaseGroupId,omitempty"`
|
||||
// MBZReleaseTrackID is the MusicBrainz release track ID.
|
||||
MBZReleaseTrackID string `json:"mbzReleaseTrackId,omitempty"`
|
||||
// LibraryID is the ID of the library the track belongs to.
|
||||
// Only included if the plugin has library permission with filesystem access for the track's library.
|
||||
LibraryID int32 `json:"libraryId,omitempty"`
|
||||
// Path is the full path to the track file, relative to the library root.
|
||||
// Only included if the plugin has library permission with filesystem access for the track's library.
|
||||
Path string `json:"path,omitempty"`
|
||||
|
|
|
|||
|
|
@ -89,6 +89,9 @@ type TrackInfo struct {
|
|||
MBZReleaseGroupID string `json:"mbzReleaseGroupId,omitempty"`
|
||||
// MBZReleaseTrackID is the MusicBrainz release track ID.
|
||||
MBZReleaseTrackID string `json:"mbzReleaseTrackId,omitempty"`
|
||||
// LibraryID is the ID of the library the track belongs to.
|
||||
// Only included if the plugin has library permission with filesystem access for the track's library.
|
||||
LibraryID int32 `json:"libraryId,omitempty"`
|
||||
// Path is the full path to the track file, relative to the library root.
|
||||
// Only included if the plugin has library permission with filesystem access for the track's library.
|
||||
Path string `json:"path,omitempty"`
|
||||
|
|
|
|||
136
plugins/pdk/go/sonicsimilarity/sonicsimilarity.go
Normal file
136
plugins/pdk/go/sonicsimilarity/sonicsimilarity.go
Normal file
|
|
@ -0,0 +1,136 @@
|
|||
// Code generated by ndpgen. DO NOT EDIT.
|
||||
//
|
||||
// This file contains export wrappers for the SonicSimilarity capability.
|
||||
// It is intended for use in Navidrome plugins built with TinyGo.
|
||||
//
|
||||
//go:build wasip1
|
||||
|
||||
package sonicsimilarity
|
||||
|
||||
import (
|
||||
"github.com/navidrome/navidrome/plugins/pdk/go/pdk"
|
||||
)
|
||||
|
||||
// FindSonicPathRequest represents the FindSonicPathRequest data structure.
|
||||
type FindSonicPathRequest struct {
|
||||
StartSong SongRef `json:"startSong"`
|
||||
EndSong SongRef `json:"endSong"`
|
||||
Count int32 `json:"count"`
|
||||
}
|
||||
|
||||
// GetSonicSimilarTracksRequest represents the GetSonicSimilarTracksRequest data structure.
|
||||
type GetSonicSimilarTracksRequest struct {
|
||||
Song SongRef `json:"song"`
|
||||
Count int32 `json:"count"`
|
||||
}
|
||||
|
||||
// SongRef is a reference to a song with metadata for matching.
|
||||
type SongRef struct {
|
||||
// ID is the internal Navidrome mediafile ID (if known).
|
||||
ID string `json:"id,omitempty"`
|
||||
// Name is the song name.
|
||||
Name string `json:"name"`
|
||||
// MBID is the MusicBrainz ID for the song.
|
||||
MBID string `json:"mbid,omitempty"`
|
||||
// ISRC is the International Standard Recording Code for the song.
|
||||
ISRC string `json:"isrc,omitempty"`
|
||||
// Artist is the artist name.
|
||||
Artist string `json:"artist,omitempty"`
|
||||
// ArtistMBID is the MusicBrainz artist ID.
|
||||
ArtistMBID string `json:"artistMbid,omitempty"`
|
||||
// Album is the album name.
|
||||
Album string `json:"album,omitempty"`
|
||||
// AlbumMBID is the MusicBrainz release ID.
|
||||
AlbumMBID string `json:"albumMbid,omitempty"`
|
||||
// Duration is the song duration in seconds.
|
||||
Duration float32 `json:"duration,omitempty"`
|
||||
}
|
||||
|
||||
// SonicMatch represents the SonicMatch data structure.
|
||||
type SonicMatch struct {
|
||||
Song SongRef `json:"song"`
|
||||
Similarity float64 `json:"similarity"`
|
||||
}
|
||||
|
||||
// SonicSimilarityResponse represents the SonicSimilarityResponse data structure.
|
||||
type SonicSimilarityResponse struct {
|
||||
Matches []SonicMatch `json:"matches"`
|
||||
}
|
||||
|
||||
// SonicSimilarity requires all methods to be implemented.
|
||||
// SonicSimilarity provides audio-similarity based track discovery.
|
||||
type SonicSimilarity interface {
|
||||
// GetSonicSimilarTracks
|
||||
GetSonicSimilarTracks(GetSonicSimilarTracksRequest) (SonicSimilarityResponse, error)
|
||||
// FindSonicPath
|
||||
FindSonicPath(FindSonicPathRequest) (SonicSimilarityResponse, error)
|
||||
} // Internal implementation holders
|
||||
var (
|
||||
sonicSimilarTracksImpl func(GetSonicSimilarTracksRequest) (SonicSimilarityResponse, error)
|
||||
findSonicPathImpl func(FindSonicPathRequest) (SonicSimilarityResponse, error)
|
||||
)
|
||||
|
||||
// Register registers a sonicsimilarity implementation.
|
||||
// All methods are required.
|
||||
func Register(impl SonicSimilarity) {
|
||||
sonicSimilarTracksImpl = impl.GetSonicSimilarTracks
|
||||
findSonicPathImpl = impl.FindSonicPath
|
||||
}
|
||||
|
||||
// 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_get_sonic_similar_tracks
|
||||
func _NdGetSonicSimilarTracks() int32 {
|
||||
if sonicSimilarTracksImpl == nil {
|
||||
// Return standard code - host will skip this plugin gracefully
|
||||
return NotImplementedCode
|
||||
}
|
||||
|
||||
var input GetSonicSimilarTracksRequest
|
||||
if err := pdk.InputJSON(&input); err != nil {
|
||||
pdk.SetError(err)
|
||||
return -1
|
||||
}
|
||||
|
||||
output, err := sonicSimilarTracksImpl(input)
|
||||
if err != nil {
|
||||
pdk.SetError(err)
|
||||
return -1
|
||||
}
|
||||
|
||||
if err := pdk.OutputJSON(output); err != nil {
|
||||
pdk.SetError(err)
|
||||
return -1
|
||||
}
|
||||
|
||||
return 0
|
||||
}
|
||||
|
||||
//go:wasmexport nd_find_sonic_path
|
||||
func _NdFindSonicPath() int32 {
|
||||
if findSonicPathImpl == nil {
|
||||
// Return standard code - host will skip this plugin gracefully
|
||||
return NotImplementedCode
|
||||
}
|
||||
|
||||
var input FindSonicPathRequest
|
||||
if err := pdk.InputJSON(&input); err != nil {
|
||||
pdk.SetError(err)
|
||||
return -1
|
||||
}
|
||||
|
||||
output, err := findSonicPathImpl(input)
|
||||
if err != nil {
|
||||
pdk.SetError(err)
|
||||
return -1
|
||||
}
|
||||
|
||||
if err := pdk.OutputJSON(output); err != nil {
|
||||
pdk.SetError(err)
|
||||
return -1
|
||||
}
|
||||
|
||||
return 0
|
||||
}
|
||||
71
plugins/pdk/go/sonicsimilarity/sonicsimilarity_stub.go
Normal file
71
plugins/pdk/go/sonicsimilarity/sonicsimilarity_stub.go
Normal file
|
|
@ -0,0 +1,71 @@
|
|||
// 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 sonicsimilarity
|
||||
|
||||
// FindSonicPathRequest represents the FindSonicPathRequest data structure.
|
||||
type FindSonicPathRequest struct {
|
||||
StartSong SongRef `json:"startSong"`
|
||||
EndSong SongRef `json:"endSong"`
|
||||
Count int32 `json:"count"`
|
||||
}
|
||||
|
||||
// GetSonicSimilarTracksRequest represents the GetSonicSimilarTracksRequest data structure.
|
||||
type GetSonicSimilarTracksRequest struct {
|
||||
Song SongRef `json:"song"`
|
||||
Count int32 `json:"count"`
|
||||
}
|
||||
|
||||
// SongRef is a reference to a song with metadata for matching.
|
||||
type SongRef struct {
|
||||
// ID is the internal Navidrome mediafile ID (if known).
|
||||
ID string `json:"id,omitempty"`
|
||||
// Name is the song name.
|
||||
Name string `json:"name"`
|
||||
// MBID is the MusicBrainz ID for the song.
|
||||
MBID string `json:"mbid,omitempty"`
|
||||
// ISRC is the International Standard Recording Code for the song.
|
||||
ISRC string `json:"isrc,omitempty"`
|
||||
// Artist is the artist name.
|
||||
Artist string `json:"artist,omitempty"`
|
||||
// ArtistMBID is the MusicBrainz artist ID.
|
||||
ArtistMBID string `json:"artistMbid,omitempty"`
|
||||
// Album is the album name.
|
||||
Album string `json:"album,omitempty"`
|
||||
// AlbumMBID is the MusicBrainz release ID.
|
||||
AlbumMBID string `json:"albumMbid,omitempty"`
|
||||
// Duration is the song duration in seconds.
|
||||
Duration float32 `json:"duration,omitempty"`
|
||||
}
|
||||
|
||||
// SonicMatch represents the SonicMatch data structure.
|
||||
type SonicMatch struct {
|
||||
Song SongRef `json:"song"`
|
||||
Similarity float64 `json:"similarity"`
|
||||
}
|
||||
|
||||
// SonicSimilarityResponse represents the SonicSimilarityResponse data structure.
|
||||
type SonicSimilarityResponse struct {
|
||||
Matches []SonicMatch `json:"matches"`
|
||||
}
|
||||
|
||||
// SonicSimilarity requires all methods to be implemented.
|
||||
// SonicSimilarity provides audio-similarity based track discovery.
|
||||
type SonicSimilarity interface {
|
||||
// GetSonicSimilarTracks
|
||||
GetSonicSimilarTracks(GetSonicSimilarTracksRequest) (SonicSimilarityResponse, error)
|
||||
// FindSonicPath
|
||||
FindSonicPath(FindSonicPathRequest) (SonicSimilarityResponse, 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(_ SonicSimilarity) {}
|
||||
|
|
@ -10,5 +10,6 @@ pub mod lyrics;
|
|||
pub mod metadata;
|
||||
pub mod scheduler;
|
||||
pub mod scrobbler;
|
||||
pub mod sonicsimilarity;
|
||||
pub mod taskworker;
|
||||
pub mod websocket;
|
||||
|
|
|
|||
|
|
@ -102,6 +102,10 @@ pub struct TrackInfo {
|
|||
/// MBZReleaseTrackID is the MusicBrainz release track ID.
|
||||
#[serde(default, skip_serializing_if = "String::is_empty")]
|
||||
pub mbz_release_track_id: String,
|
||||
/// LibraryID is the ID of the library the track belongs to.
|
||||
/// Only included if the plugin has library permission with filesystem access for the track's library.
|
||||
#[serde(default, skip_serializing_if = "is_zero_i32")]
|
||||
pub library_id: i32,
|
||||
/// Path is the full path to the track file, relative to the library root.
|
||||
/// Only included if the plugin has library permission with filesystem access for the track's library.
|
||||
#[serde(default, skip_serializing_if = "String::is_empty")]
|
||||
|
|
|
|||
|
|
@ -122,6 +122,10 @@ pub struct TrackInfo {
|
|||
/// MBZReleaseTrackID is the MusicBrainz release track ID.
|
||||
#[serde(default, skip_serializing_if = "String::is_empty")]
|
||||
pub mbz_release_track_id: String,
|
||||
/// LibraryID is the ID of the library the track belongs to.
|
||||
/// Only included if the plugin has library permission with filesystem access for the track's library.
|
||||
#[serde(default, skip_serializing_if = "is_zero_i32")]
|
||||
pub library_id: i32,
|
||||
/// Path is the full path to the track file, relative to the library root.
|
||||
/// Only included if the plugin has library permission with filesystem access for the track's library.
|
||||
#[serde(default, skip_serializing_if = "String::is_empty")]
|
||||
|
|
|
|||
141
plugins/pdk/rust/nd-pdk-capabilities/src/sonicsimilarity.rs
Normal file
141
plugins/pdk/rust/nd-pdk-capabilities/src/sonicsimilarity.rs
Normal file
|
|
@ -0,0 +1,141 @@
|
|||
// Code generated by ndpgen. DO NOT EDIT.
|
||||
//
|
||||
// This file contains export wrappers for the SonicSimilarity 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 }
|
||||
/// FindSonicPathRequest represents the FindSonicPathRequest data structure.
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct FindSonicPathRequest {
|
||||
#[serde(default)]
|
||||
pub start_song: SongRef,
|
||||
#[serde(default)]
|
||||
pub end_song: SongRef,
|
||||
#[serde(default)]
|
||||
pub count: i32,
|
||||
}
|
||||
/// GetSonicSimilarTracksRequest represents the GetSonicSimilarTracksRequest data structure.
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct GetSonicSimilarTracksRequest {
|
||||
#[serde(default)]
|
||||
pub song: SongRef,
|
||||
#[serde(default)]
|
||||
pub count: i32,
|
||||
}
|
||||
/// SongRef is a reference to a song with metadata for matching.
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct SongRef {
|
||||
/// ID is the internal Navidrome mediafile ID (if known).
|
||||
#[serde(default, skip_serializing_if = "String::is_empty")]
|
||||
pub id: String,
|
||||
/// Name is the song name.
|
||||
#[serde(default)]
|
||||
pub name: String,
|
||||
/// MBID is the MusicBrainz ID for the song.
|
||||
#[serde(default, skip_serializing_if = "String::is_empty")]
|
||||
pub mbid: String,
|
||||
/// ISRC is the International Standard Recording Code for the song.
|
||||
#[serde(default, skip_serializing_if = "String::is_empty")]
|
||||
pub isrc: String,
|
||||
/// Artist is the artist name.
|
||||
#[serde(default, skip_serializing_if = "String::is_empty")]
|
||||
pub artist: String,
|
||||
/// ArtistMBID is the MusicBrainz artist ID.
|
||||
#[serde(default, skip_serializing_if = "String::is_empty")]
|
||||
pub artist_mbid: String,
|
||||
/// Album is the album name.
|
||||
#[serde(default, skip_serializing_if = "String::is_empty")]
|
||||
pub album: String,
|
||||
/// AlbumMBID is the MusicBrainz release ID.
|
||||
#[serde(default, skip_serializing_if = "String::is_empty")]
|
||||
pub album_mbid: String,
|
||||
/// Duration is the song duration in seconds.
|
||||
#[serde(default, skip_serializing_if = "is_zero_f32")]
|
||||
pub duration: f32,
|
||||
}
|
||||
/// SonicMatch represents the SonicMatch data structure.
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct SonicMatch {
|
||||
#[serde(default)]
|
||||
pub song: SongRef,
|
||||
#[serde(default)]
|
||||
pub similarity: f64,
|
||||
}
|
||||
/// SonicSimilarityResponse represents the SonicSimilarityResponse data structure.
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct SonicSimilarityResponse {
|
||||
#[serde(default)]
|
||||
pub matches: Vec<SonicMatch>,
|
||||
}
|
||||
|
||||
/// 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() }
|
||||
}
|
||||
}
|
||||
|
||||
/// SonicSimilarity requires all methods to be implemented.
|
||||
/// SonicSimilarity provides audio-similarity based track discovery.
|
||||
pub trait SonicSimilarity {
|
||||
/// GetSonicSimilarTracks
|
||||
fn get_sonic_similar_tracks(&self, req: GetSonicSimilarTracksRequest) -> Result<SonicSimilarityResponse, Error>;
|
||||
/// FindSonicPath
|
||||
fn find_sonic_path(&self, req: FindSonicPathRequest) -> Result<SonicSimilarityResponse, Error>;
|
||||
}
|
||||
|
||||
/// Register all exports for the SonicSimilarity capability.
|
||||
/// This macro generates the WASM export functions for all trait methods.
|
||||
#[macro_export]
|
||||
macro_rules! register_sonicsimilarity {
|
||||
($plugin_type:ty) => {
|
||||
#[extism_pdk::plugin_fn]
|
||||
pub fn nd_get_sonic_similar_tracks(
|
||||
req: extism_pdk::Json<$crate::sonicsimilarity::GetSonicSimilarTracksRequest>
|
||||
) -> extism_pdk::FnResult<extism_pdk::Json<$crate::sonicsimilarity::SonicSimilarityResponse>> {
|
||||
let plugin = <$plugin_type>::default();
|
||||
let result = $crate::sonicsimilarity::SonicSimilarity::get_sonic_similar_tracks(&plugin, req.into_inner())?;
|
||||
Ok(extism_pdk::Json(result))
|
||||
}
|
||||
#[extism_pdk::plugin_fn]
|
||||
pub fn nd_find_sonic_path(
|
||||
req: extism_pdk::Json<$crate::sonicsimilarity::FindSonicPathRequest>
|
||||
) -> extism_pdk::FnResult<extism_pdk::Json<$crate::sonicsimilarity::SonicSimilarityResponse>> {
|
||||
let plugin = <$plugin_type>::default();
|
||||
let result = $crate::sonicsimilarity::SonicSimilarity::find_sonic_path(&plugin, req.into_inner())?;
|
||||
Ok(extism_pdk::Json(result))
|
||||
}
|
||||
};
|
||||
}
|
||||
|
|
@ -30,6 +30,20 @@ func init() {
|
|||
)
|
||||
}
|
||||
|
||||
func newScrobblerPlugin(p *plugin) *ScrobblerPlugin {
|
||||
userIDMap := make(map[string]struct{})
|
||||
for _, id := range p.allowedUserIDs {
|
||||
userIDMap[id] = struct{}{}
|
||||
}
|
||||
return &ScrobblerPlugin{
|
||||
name: p.name,
|
||||
plugin: p,
|
||||
allowedUserIDs: p.allowedUserIDs,
|
||||
allUsers: p.allUsers,
|
||||
userIDMap: userIDMap,
|
||||
}
|
||||
}
|
||||
|
||||
// ScrobblerPlugin is an adapter that wraps an Extism plugin and implements
|
||||
// the scrobbler.Scrobbler interface for scrobbling to external services.
|
||||
type ScrobblerPlugin struct {
|
||||
|
|
|
|||
92
plugins/sonic_similarity_adapter.go
Normal file
92
plugins/sonic_similarity_adapter.go
Normal file
|
|
@ -0,0 +1,92 @@
|
|||
package plugins
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/navidrome/navidrome/core/sonic"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/plugins/capabilities"
|
||||
)
|
||||
|
||||
const CapabilitySonicSimilarity Capability = "SonicSimilarity"
|
||||
|
||||
const (
|
||||
FuncGetSonicSimilarTracks = "nd_get_sonic_similar_tracks"
|
||||
FuncFindSonicPath = "nd_find_sonic_path"
|
||||
)
|
||||
|
||||
func init() {
|
||||
registerCapability(
|
||||
CapabilitySonicSimilarity,
|
||||
FuncGetSonicSimilarTracks,
|
||||
FuncFindSonicPath,
|
||||
)
|
||||
}
|
||||
|
||||
func newSonicSimilarityPlugin(p *plugin) *SonicSimilarityPlugin {
|
||||
return &SonicSimilarityPlugin{name: p.name, plugin: p}
|
||||
}
|
||||
|
||||
type SonicSimilarityPlugin struct {
|
||||
name string
|
||||
plugin *plugin
|
||||
}
|
||||
|
||||
func (a *SonicSimilarityPlugin) GetSonicSimilarTracks(ctx context.Context, mf *model.MediaFile, count int) ([]sonic.SimilarResult, error) {
|
||||
req := capabilities.GetSonicSimilarTracksRequest{
|
||||
Song: mediaFileToSongRef(mf),
|
||||
Count: int32(count),
|
||||
}
|
||||
resp, err := callPluginFunction[capabilities.GetSonicSimilarTracksRequest, capabilities.SonicSimilarityResponse](
|
||||
ctx, a.plugin, FuncGetSonicSimilarTracks, req,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return sonicMatchesToSimilarResults(resp.Matches), nil
|
||||
}
|
||||
|
||||
func (a *SonicSimilarityPlugin) FindSonicPath(ctx context.Context, startMf, endMf *model.MediaFile, count int) ([]sonic.SimilarResult, error) {
|
||||
req := capabilities.FindSonicPathRequest{
|
||||
StartSong: mediaFileToSongRef(startMf),
|
||||
EndSong: mediaFileToSongRef(endMf),
|
||||
Count: int32(count),
|
||||
}
|
||||
resp, err := callPluginFunction[capabilities.FindSonicPathRequest, capabilities.SonicSimilarityResponse](
|
||||
ctx, a.plugin, FuncFindSonicPath, req,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return sonicMatchesToSimilarResults(resp.Matches), nil
|
||||
}
|
||||
|
||||
func mediaFileToSongRef(mf *model.MediaFile) capabilities.SongRef {
|
||||
ref := capabilities.SongRef{
|
||||
ID: mf.ID,
|
||||
Name: mf.Title,
|
||||
MBID: mf.MbzRecordingID,
|
||||
Artist: mf.Artist,
|
||||
ArtistMBID: mf.MbzArtistID,
|
||||
Album: mf.Album,
|
||||
AlbumMBID: mf.MbzAlbumID,
|
||||
Duration: mf.Duration,
|
||||
}
|
||||
if isrcs := mf.Tags.Values(model.TagISRC); len(isrcs) > 0 {
|
||||
ref.ISRC = isrcs[0]
|
||||
}
|
||||
return ref
|
||||
}
|
||||
|
||||
func sonicMatchesToSimilarResults(matches []capabilities.SonicMatch) []sonic.SimilarResult {
|
||||
results := make([]sonic.SimilarResult, len(matches))
|
||||
for i, m := range matches {
|
||||
results[i] = sonic.SimilarResult{
|
||||
Song: songRefToAgentSong(m.Song),
|
||||
Similarity: m.Similarity,
|
||||
}
|
||||
}
|
||||
return results
|
||||
}
|
||||
|
||||
var _ sonic.Provider = (*SonicSimilarityPlugin)(nil)
|
||||
110
plugins/sonic_similarity_adapter_test.go
Normal file
110
plugins/sonic_similarity_adapter_test.go
Normal file
|
|
@ -0,0 +1,110 @@
|
|||
//go:build !windows
|
||||
|
||||
package plugins
|
||||
|
||||
import (
|
||||
"github.com/navidrome/navidrome/core/sonic"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
var _ = Describe("SonicSimilarityPlugin", Ordered, func() {
|
||||
var (
|
||||
manager *Manager
|
||||
provider sonic.Provider
|
||||
)
|
||||
|
||||
BeforeAll(func() {
|
||||
manager, _ = createTestManagerWithPlugins(nil, "test-sonic-similarity"+PackageExtension)
|
||||
|
||||
var ok bool
|
||||
provider, ok = manager.LoadSonicSimilarity("test-sonic-similarity")
|
||||
Expect(ok).To(BeTrue())
|
||||
})
|
||||
|
||||
Describe("PluginNames", func() {
|
||||
It("reports the sonic similarity capability", func() {
|
||||
names := manager.PluginNames(string(CapabilitySonicSimilarity))
|
||||
Expect(names).To(ContainElement("test-sonic-similarity"))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("GetSonicSimilarTracks", func() {
|
||||
It("returns similar tracks from the plugin", func() {
|
||||
mf := &model.MediaFile{
|
||||
ID: "track-1",
|
||||
Title: "Yesterday",
|
||||
Artist: "The Beatles",
|
||||
}
|
||||
|
||||
results, err := provider.GetSonicSimilarTracks(GinkgoT().Context(), mf, 3)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(results).To(HaveLen(3))
|
||||
Expect(results[0].Song.Name).To(Equal("Similar to Yesterday #1"))
|
||||
Expect(results[0].Song.Artist).To(Equal("The Beatles"))
|
||||
Expect(results[0].Similarity).To(Equal(1.0))
|
||||
Expect(results[1].Similarity).To(Equal(0.9))
|
||||
Expect(results[2].Similarity).To(Equal(0.8))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("FindSonicPath", func() {
|
||||
It("returns a path between two tracks from the plugin", func() {
|
||||
startMf := &model.MediaFile{
|
||||
ID: "track-1",
|
||||
Title: "Yesterday",
|
||||
Artist: "The Beatles",
|
||||
}
|
||||
|
||||
endMf := &model.MediaFile{
|
||||
ID: "track-2",
|
||||
Title: "Tomorrow Never Knows",
|
||||
Artist: "The Beatles",
|
||||
}
|
||||
|
||||
results, err := provider.FindSonicPath(GinkgoT().Context(), startMf, endMf, 3)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(results).To(HaveLen(3))
|
||||
Expect(results[0].Song.Name).To(Equal("Path Yesterday to Tomorrow Never Knows #1"))
|
||||
Expect(results[0].Song.Artist).To(Equal("The Beatles"))
|
||||
Expect(results[0].Similarity).To(Equal(1.0))
|
||||
Expect(results[1].Similarity).To(Equal(0.95))
|
||||
Expect(results[2].Similarity).To(Equal(0.9))
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
var _ = Describe("SonicSimilarityPlugin error handling", Ordered, func() {
|
||||
var (
|
||||
errorManager *Manager
|
||||
errorProvider sonic.Provider
|
||||
)
|
||||
|
||||
BeforeAll(func() {
|
||||
errorManager, _ = createTestManagerWithPlugins(map[string]map[string]string{
|
||||
"test-sonic-similarity": {
|
||||
"error": "simulated plugin error",
|
||||
},
|
||||
}, "test-sonic-similarity"+PackageExtension)
|
||||
|
||||
var ok bool
|
||||
errorProvider, ok = errorManager.LoadSonicSimilarity("test-sonic-similarity")
|
||||
Expect(ok).To(BeTrue())
|
||||
})
|
||||
|
||||
It("returns error from GetSonicSimilarTracks", func() {
|
||||
mf := &model.MediaFile{ID: "track-1", Title: "Test"}
|
||||
_, err := errorProvider.GetSonicSimilarTracks(GinkgoT().Context(), mf, 3)
|
||||
Expect(err).To(HaveOccurred())
|
||||
Expect(err.Error()).To(ContainSubstring("simulated plugin error"))
|
||||
})
|
||||
|
||||
It("returns error from FindSonicPath", func() {
|
||||
startMf := &model.MediaFile{ID: "track-1", Title: "Start"}
|
||||
endMf := &model.MediaFile{ID: "track-2", Title: "End"}
|
||||
_, err := errorProvider.FindSonicPath(GinkgoT().Context(), startMf, endMf, 3)
|
||||
Expect(err).To(HaveOccurred())
|
||||
Expect(err.Error()).To(ContainSubstring("simulated plugin error"))
|
||||
})
|
||||
})
|
||||
16
plugins/testdata/test-sonic-similarity/go.mod
vendored
Normal file
16
plugins/testdata/test-sonic-similarity/go.mod
vendored
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
module test-sonic-similarity
|
||||
|
||||
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-sonic-similarity/go.sum
vendored
Normal file
14
plugins/testdata/test-sonic-similarity/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=
|
||||
71
plugins/testdata/test-sonic-similarity/main.go
vendored
Normal file
71
plugins/testdata/test-sonic-similarity/main.go
vendored
Normal file
|
|
@ -0,0 +1,71 @@
|
|||
// Test plugin for Navidrome sonic similarity integration tests.
|
||||
// Build with: tinygo build -o ../test-sonic-similarity.wasm -target wasip1 -buildmode=c-shared .
|
||||
package main
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strconv"
|
||||
|
||||
"github.com/navidrome/navidrome/plugins/pdk/go/pdk"
|
||||
"github.com/navidrome/navidrome/plugins/pdk/go/sonicsimilarity"
|
||||
)
|
||||
|
||||
func init() {
|
||||
sonicsimilarity.Register(&testSonicSimilarity{})
|
||||
}
|
||||
|
||||
type testSonicSimilarity struct{}
|
||||
|
||||
func checkConfigError() error {
|
||||
errMsg, hasErr := pdk.GetConfig("error")
|
||||
if !hasErr || errMsg == "" {
|
||||
return nil
|
||||
}
|
||||
return errors.New(errMsg)
|
||||
}
|
||||
|
||||
func (t *testSonicSimilarity) GetSonicSimilarTracks(input sonicsimilarity.GetSonicSimilarTracksRequest) (sonicsimilarity.SonicSimilarityResponse, error) {
|
||||
if err := checkConfigError(); err != nil {
|
||||
return sonicsimilarity.SonicSimilarityResponse{}, err
|
||||
}
|
||||
count := int(input.Count)
|
||||
if count == 0 {
|
||||
count = 5
|
||||
}
|
||||
matches := make([]sonicsimilarity.SonicMatch, 0, count)
|
||||
for i := range count {
|
||||
matches = append(matches, sonicsimilarity.SonicMatch{
|
||||
Song: sonicsimilarity.SongRef{
|
||||
ID: "similar-track-" + strconv.Itoa(i+1),
|
||||
Name: "Similar to " + input.Song.Name + " #" + strconv.Itoa(i+1),
|
||||
Artist: input.Song.Artist,
|
||||
},
|
||||
Similarity: 1.0 - float64(i)*0.1,
|
||||
})
|
||||
}
|
||||
return sonicsimilarity.SonicSimilarityResponse{Matches: matches}, nil
|
||||
}
|
||||
|
||||
func (t *testSonicSimilarity) FindSonicPath(input sonicsimilarity.FindSonicPathRequest) (sonicsimilarity.SonicSimilarityResponse, error) {
|
||||
if err := checkConfigError(); err != nil {
|
||||
return sonicsimilarity.SonicSimilarityResponse{}, err
|
||||
}
|
||||
count := int(input.Count)
|
||||
if count == 0 {
|
||||
count = 5
|
||||
}
|
||||
matches := make([]sonicsimilarity.SonicMatch, 0, count)
|
||||
for i := range count {
|
||||
matches = append(matches, sonicsimilarity.SonicMatch{
|
||||
Song: sonicsimilarity.SongRef{
|
||||
ID: "path-track-" + strconv.Itoa(i+1),
|
||||
Name: "Path " + input.StartSong.Name + " to " + input.EndSong.Name + " #" + strconv.Itoa(i+1),
|
||||
Artist: input.StartSong.Artist,
|
||||
},
|
||||
Similarity: 1.0 - float64(i)*0.05,
|
||||
})
|
||||
}
|
||||
return sonicsimilarity.SonicSimilarityResponse{Matches: matches}, nil
|
||||
}
|
||||
|
||||
func main() {}
|
||||
7
plugins/testdata/test-sonic-similarity/manifest.json
vendored
Normal file
7
plugins/testdata/test-sonic-similarity/manifest.json
vendored
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
{
|
||||
"name": "Test Sonic Similarity",
|
||||
"author": "Navidrome Test",
|
||||
"version": "1.0.0",
|
||||
"description": "A test plugin for sonic similarity integration testing",
|
||||
"capabilities": ["SonicSimilarity"]
|
||||
}
|
||||
|
|
@ -519,6 +519,7 @@ func setupTestDB() {
|
|||
metrics.NewNoopInstance(),
|
||||
lyrics.NewLyrics(nil),
|
||||
decider,
|
||||
nil,
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
272
server/e2e/subsonic_sonic_similarity_test.go
Normal file
272
server/e2e/subsonic_sonic_similarity_test.go
Normal file
|
|
@ -0,0 +1,272 @@
|
|||
package e2e
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
|
||||
"github.com/Masterminds/squirrel"
|
||||
"github.com/navidrome/navidrome/core"
|
||||
"github.com/navidrome/navidrome/core/agents"
|
||||
"github.com/navidrome/navidrome/core/lyrics"
|
||||
"github.com/navidrome/navidrome/core/matcher"
|
||||
"github.com/navidrome/navidrome/core/metrics"
|
||||
"github.com/navidrome/navidrome/core/playback"
|
||||
"github.com/navidrome/navidrome/core/playlists"
|
||||
"github.com/navidrome/navidrome/core/sonic"
|
||||
"github.com/navidrome/navidrome/core/stream"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/server/events"
|
||||
"github.com/navidrome/navidrome/server/subsonic"
|
||||
"github.com/navidrome/navidrome/server/subsonic/responses"
|
||||
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
// buildSonicRouter creates a subsonic.Router with a real sonic.Sonic service
|
||||
// backed by the given provider and the shared e2e DataStore.
|
||||
func buildSonicRouter(provider sonic.Provider) *subsonic.Router {
|
||||
loader := &mockSonicPluginLoader{provider: provider}
|
||||
m := matcher.New(ds)
|
||||
sonicSvc := sonic.New(ds, loader, m)
|
||||
decider := stream.NewTranscodeDecider(ds, noopFFmpeg{})
|
||||
return subsonic.New(
|
||||
ds,
|
||||
noopArtwork{},
|
||||
&spyStreamer{},
|
||||
noopArchiver{},
|
||||
core.NewPlayers(ds),
|
||||
noopProvider{},
|
||||
nil, // scanner
|
||||
events.NoopBroker(),
|
||||
playlists.NewPlaylists(ds, core.NewImageUploadService()),
|
||||
noopPlayTracker{},
|
||||
core.NewShare(ds),
|
||||
playback.PlaybackServer(nil),
|
||||
metrics.NewNoopInstance(),
|
||||
lyrics.NewLyrics(nil),
|
||||
decider,
|
||||
sonicSvc,
|
||||
)
|
||||
}
|
||||
|
||||
// doSonicReq makes a request through a sonic-enabled router and returns the parsed response.
|
||||
func doSonicReq(sonicRouter *subsonic.Router, endpoint string, params ...string) *responses.Subsonic {
|
||||
w := httptest.NewRecorder()
|
||||
r := buildReq(adminUser, endpoint, params...)
|
||||
sonicRouter.ServeHTTP(w, r)
|
||||
return parseJSONResponse(w)
|
||||
}
|
||||
|
||||
// doSonicRawReq makes a request through a sonic-enabled router and returns the raw recorder.
|
||||
func doSonicRawReq(sonicRouter *subsonic.Router, endpoint string, params ...string) *httptest.ResponseRecorder {
|
||||
w := httptest.NewRecorder()
|
||||
r := buildReq(adminUser, endpoint, params...)
|
||||
sonicRouter.ServeHTTP(w, r)
|
||||
return w
|
||||
}
|
||||
|
||||
var _ = Describe("Sonic Similarity Endpoints", func() {
|
||||
BeforeEach(func() {
|
||||
setupTestDB()
|
||||
})
|
||||
|
||||
Context("without sonic similarity plugin", func() {
|
||||
Describe("getSonicSimilarTracks", func() {
|
||||
It("returns 404 when no sonic similarity plugin is available", func() {
|
||||
w := doRawReq("getSonicSimilarTracks", "id", "any-song-id")
|
||||
Expect(w.Code).To(Equal(http.StatusNotFound))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("findSonicPath", func() {
|
||||
It("returns 404 when no sonic similarity plugin is available", func() {
|
||||
w := doRawReq("findSonicPath", "startSongId", "any-song-id", "endSongId", "another-song-id")
|
||||
Expect(w.Code).To(Equal(http.StatusNotFound))
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
Context("with sonic similarity plugin", func() {
|
||||
var (
|
||||
sonicRouter *subsonic.Router
|
||||
comeTogether model.MediaFile
|
||||
something model.MediaFile
|
||||
)
|
||||
|
||||
BeforeEach(func() {
|
||||
songs, err := ds.MediaFile(ctx).GetAll(model.QueryOptions{
|
||||
Filters: squirrel.Eq{"title": "Come Together"},
|
||||
})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(songs).ToNot(BeEmpty())
|
||||
comeTogether = songs[0]
|
||||
|
||||
songs, err = ds.MediaFile(ctx).GetAll(model.QueryOptions{
|
||||
Filters: squirrel.Eq{"title": "Something"},
|
||||
})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(songs).ToNot(BeEmpty())
|
||||
something = songs[0]
|
||||
|
||||
provider := &mockSonicProvider{
|
||||
similarIDs: []string{something.ID, comeTogether.ID},
|
||||
pathIDs: []string{comeTogether.ID, something.ID},
|
||||
}
|
||||
sonicRouter = buildSonicRouter(provider)
|
||||
})
|
||||
|
||||
Describe("getSonicSimilarTracks", func() {
|
||||
It("returns similar tracks with similarity scores", func() {
|
||||
resp := doSonicReq(sonicRouter, "getSonicSimilarTracks", "id", comeTogether.ID)
|
||||
|
||||
Expect(resp.Status).To(Equal(responses.StatusOK))
|
||||
matches := *resp.SonicMatches
|
||||
Expect(matches).To(HaveLen(2))
|
||||
Expect(matches[0].Entry.Title).To(Equal("Something"))
|
||||
Expect(matches[0].Similarity).To(Equal(1.0))
|
||||
Expect(matches[1].Entry.Title).To(Equal("Come Together"))
|
||||
Expect(matches[1].Similarity).To(Equal(0.9))
|
||||
})
|
||||
|
||||
It("respects the count parameter", func() {
|
||||
resp := doSonicReq(sonicRouter, "getSonicSimilarTracks", "id", comeTogether.ID, "count", "1")
|
||||
|
||||
Expect(resp.Status).To(Equal(responses.StatusOK))
|
||||
Expect(*resp.SonicMatches).To(HaveLen(1))
|
||||
})
|
||||
|
||||
It("returns an error for a missing id parameter", func() {
|
||||
resp := doSonicReq(sonicRouter, "getSonicSimilarTracks")
|
||||
|
||||
Expect(resp.Status).To(Equal(responses.StatusFailed))
|
||||
Expect(resp.Error).ToNot(BeNil())
|
||||
})
|
||||
|
||||
It("returns an error for a non-existent song ID", func() {
|
||||
resp := doSonicReq(sonicRouter, "getSonicSimilarTracks", "id", "non-existent-id")
|
||||
|
||||
Expect(resp.Status).To(Equal(responses.StatusFailed))
|
||||
Expect(resp.Error).ToNot(BeNil())
|
||||
})
|
||||
|
||||
It("returns correct JSON structure", func() {
|
||||
w := doSonicRawReq(sonicRouter, "getSonicSimilarTracks", "id", comeTogether.ID)
|
||||
Expect(w.Code).To(Equal(http.StatusOK))
|
||||
|
||||
var wrapper responses.JsonWrapper
|
||||
Expect(json.Unmarshal(w.Body.Bytes(), &wrapper)).To(Succeed())
|
||||
matches := *wrapper.Subsonic.SonicMatches
|
||||
Expect(matches).To(HaveLen(2))
|
||||
Expect(matches[0].Similarity).To(BeNumerically(">", 0))
|
||||
Expect(matches[0].Entry.Id).ToNot(BeEmpty())
|
||||
})
|
||||
})
|
||||
|
||||
Describe("findSonicPath", func() {
|
||||
It("returns a path between two tracks with similarity scores", func() {
|
||||
resp := doSonicReq(sonicRouter, "findSonicPath",
|
||||
"startSongId", comeTogether.ID,
|
||||
"endSongId", something.ID,
|
||||
)
|
||||
|
||||
Expect(resp.Status).To(Equal(responses.StatusOK))
|
||||
matches := *resp.SonicMatches
|
||||
Expect(matches).To(HaveLen(2))
|
||||
Expect(matches[0].Entry.Title).To(Equal("Come Together"))
|
||||
Expect(matches[0].Similarity).To(Equal(1.0))
|
||||
Expect(matches[1].Entry.Title).To(Equal("Something"))
|
||||
Expect(matches[1].Similarity).To(Equal(0.95))
|
||||
})
|
||||
|
||||
It("returns an error for a missing startSongId parameter", func() {
|
||||
resp := doSonicReq(sonicRouter, "findSonicPath", "endSongId", something.ID)
|
||||
|
||||
Expect(resp.Status).To(Equal(responses.StatusFailed))
|
||||
Expect(resp.Error).ToNot(BeNil())
|
||||
})
|
||||
|
||||
It("returns an error for a missing endSongId parameter", func() {
|
||||
resp := doSonicReq(sonicRouter, "findSonicPath", "startSongId", comeTogether.ID)
|
||||
|
||||
Expect(resp.Status).To(Equal(responses.StatusFailed))
|
||||
Expect(resp.Error).ToNot(BeNil())
|
||||
})
|
||||
|
||||
It("returns an error for a non-existent start song ID", func() {
|
||||
resp := doSonicReq(sonicRouter, "findSonicPath",
|
||||
"startSongId", "non-existent-id",
|
||||
"endSongId", something.ID,
|
||||
)
|
||||
|
||||
Expect(resp.Status).To(Equal(responses.StatusFailed))
|
||||
Expect(resp.Error).ToNot(BeNil())
|
||||
})
|
||||
|
||||
It("returns an error for a non-existent end song ID", func() {
|
||||
resp := doSonicReq(sonicRouter, "findSonicPath",
|
||||
"startSongId", comeTogether.ID,
|
||||
"endSongId", "non-existent-id",
|
||||
)
|
||||
|
||||
Expect(resp.Status).To(Equal(responses.StatusFailed))
|
||||
Expect(resp.Error).ToNot(BeNil())
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
// mockSonicProvider returns results using IDs from the real test library,
|
||||
// so that the matcher can resolve them back to actual MediaFiles.
|
||||
type mockSonicProvider struct {
|
||||
similarIDs []string
|
||||
pathIDs []string
|
||||
}
|
||||
|
||||
func (m *mockSonicProvider) GetSonicSimilarTracks(_ context.Context, mf *model.MediaFile, count int) ([]sonic.SimilarResult, error) {
|
||||
var results []sonic.SimilarResult
|
||||
for i, id := range m.similarIDs {
|
||||
if i >= count {
|
||||
break
|
||||
}
|
||||
results = append(results, sonic.SimilarResult{
|
||||
Song: agents.Song{ID: id},
|
||||
Similarity: 1.0 - float64(i)*0.1,
|
||||
})
|
||||
}
|
||||
return results, nil
|
||||
}
|
||||
|
||||
func (m *mockSonicProvider) FindSonicPath(_ context.Context, startMf, endMf *model.MediaFile, count int) ([]sonic.SimilarResult, error) {
|
||||
var results []sonic.SimilarResult
|
||||
for i, id := range m.pathIDs {
|
||||
if i >= count {
|
||||
break
|
||||
}
|
||||
results = append(results, sonic.SimilarResult{
|
||||
Song: agents.Song{ID: id},
|
||||
Similarity: 1.0 - float64(i)*0.05,
|
||||
})
|
||||
}
|
||||
return results, nil
|
||||
}
|
||||
|
||||
type mockSonicPluginLoader struct {
|
||||
provider sonic.Provider
|
||||
}
|
||||
|
||||
func (m *mockSonicPluginLoader) PluginNames(capability string) []string {
|
||||
if capability == "SonicSimilarity" && m.provider != nil {
|
||||
return []string{"mock-sonic"}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *mockSonicPluginLoader) LoadSonicSimilarity(_ string) (sonic.Provider, bool) {
|
||||
if m.provider != nil {
|
||||
return m.provider, true
|
||||
}
|
||||
return nil, false
|
||||
}
|
||||
|
|
@ -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, nil, nil)
|
||||
router = New(ds, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil)
|
||||
w = httptest.NewRecorder()
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@ import (
|
|||
"github.com/navidrome/navidrome/core/playback"
|
||||
playlistsvc "github.com/navidrome/navidrome/core/playlists"
|
||||
"github.com/navidrome/navidrome/core/scrobbler"
|
||||
sonicsvc "github.com/navidrome/navidrome/core/sonic"
|
||||
"github.com/navidrome/navidrome/core/stream"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
|
|
@ -52,12 +53,14 @@ type Router struct {
|
|||
metrics metrics.Metrics
|
||||
lyrics lyricssvc.Lyrics
|
||||
transcodeDecision stream.TranscodeDecider
|
||||
sonic *sonicsvc.Sonic
|
||||
}
|
||||
|
||||
func New(ds model.DataStore, artwork artwork.Artwork, streamer stream.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, lyrics lyricssvc.Lyrics, transcodeDecision stream.TranscodeDecider,
|
||||
sonic *sonicsvc.Sonic,
|
||||
) *Router {
|
||||
r := &Router{
|
||||
ds: ds,
|
||||
|
|
@ -75,6 +78,7 @@ func New(ds model.DataStore, artwork artwork.Artwork, streamer stream.MediaStrea
|
|||
metrics: metrics,
|
||||
lyrics: lyrics,
|
||||
transcodeDecision: transcodeDecision,
|
||||
sonic: sonic,
|
||||
}
|
||||
r.Handler = r.routes()
|
||||
return r
|
||||
|
|
@ -121,6 +125,8 @@ func (api *Router) routes() http.Handler {
|
|||
h(r, "getTopSongs", api.GetTopSongs)
|
||||
h(r, "getSimilarSongs", api.GetSimilarSongs)
|
||||
h(r, "getSimilarSongs2", api.GetSimilarSongs2)
|
||||
hr(r, "getSonicSimilarTracks", api.GetSonicSimilarTracks)
|
||||
hr(r, "findSonicPath", api.FindSonicPath)
|
||||
})
|
||||
r.Group(func(r chi.Router) {
|
||||
r.Use(getPlayer(api.players))
|
||||
|
|
|
|||
|
|
@ -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, nil, nil)
|
||||
router = New(ds, nil, nil, nil, nil, nil, nil, eventBroker, nil, playTracker, nil, nil, nil, nil, nil, nil)
|
||||
})
|
||||
|
||||
Describe("Scrobble", func() {
|
||||
|
|
|
|||
|
|
@ -34,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, lyrics.NewLyrics(nil), nil)
|
||||
router = New(ds, artwork, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, lyrics.NewLyrics(nil), nil, nil)
|
||||
w = httptest.NewRecorder()
|
||||
DeferCleanup(configtest.SetupConfig())
|
||||
conf.Server.LyricsPriority = "embedded,.lrc"
|
||||
|
|
|
|||
|
|
@ -8,12 +8,18 @@ import (
|
|||
|
||||
func (api *Router) GetOpenSubsonicExtensions(_ *http.Request) (*responses.Subsonic, error) {
|
||||
response := newResponse()
|
||||
response.OpenSubsonicExtensions = &responses.OpenSubsonicExtensions{
|
||||
extensions := responses.OpenSubsonicExtensions{
|
||||
{Name: "transcodeOffset", Versions: []int32{1}},
|
||||
{Name: "formPost", Versions: []int32{1}},
|
||||
{Name: "songLyrics", Versions: []int32{1}},
|
||||
{Name: "indexBasedQueue", Versions: []int32{1}},
|
||||
{Name: "transcoding", Versions: []int32{1}},
|
||||
}
|
||||
if api.sonic != nil && api.sonic.HasProvider() {
|
||||
extensions = append(extensions, responses.OpenSubsonicExtension{
|
||||
Name: "sonicSimilarity", Versions: []int32{1},
|
||||
})
|
||||
}
|
||||
response.OpenSubsonicExtensions = &extensions
|
||||
return response, nil
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,12 +5,28 @@ import (
|
|||
"net/http"
|
||||
"net/http/httptest"
|
||||
|
||||
sonicsvc "github.com/navidrome/navidrome/core/sonic"
|
||||
"github.com/navidrome/navidrome/server/subsonic"
|
||||
"github.com/navidrome/navidrome/server/subsonic/responses"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
type mockSonicPluginLoader struct {
|
||||
names []string
|
||||
}
|
||||
|
||||
func (m *mockSonicPluginLoader) PluginNames(capability string) []string {
|
||||
if capability == "SonicSimilarity" {
|
||||
return m.names
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *mockSonicPluginLoader) LoadSonicSimilarity(_ string) (sonicsvc.Provider, bool) {
|
||||
return nil, false
|
||||
}
|
||||
|
||||
var _ = Describe("GetOpenSubsonicExtensions", func() {
|
||||
var (
|
||||
router *subsonic.Router
|
||||
|
|
@ -18,29 +34,64 @@ var _ = Describe("GetOpenSubsonicExtensions", func() {
|
|||
r *http.Request
|
||||
)
|
||||
|
||||
BeforeEach(func() {
|
||||
router = subsonic.New(nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil)
|
||||
JustBeforeEach(func() {
|
||||
w = httptest.NewRecorder()
|
||||
r = httptest.NewRequest("GET", "/getOpenSubsonicExtensions?f=json", nil)
|
||||
})
|
||||
|
||||
It("should return the correct OpenSubsonicExtensions", func() {
|
||||
router.ServeHTTP(w, r)
|
||||
Context("without sonic similarity plugin", func() {
|
||||
BeforeEach(func() {
|
||||
router = subsonic.New(nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil)
|
||||
})
|
||||
|
||||
// Make sure the endpoint is public, by not passing any authentication
|
||||
Expect(w.Code).To(Equal(http.StatusOK))
|
||||
Expect(w.Header().Get("Content-Type")).To(Equal("application/json"))
|
||||
It("should return the base 5 OpenSubsonicExtensions without sonicSimilarity", func() {
|
||||
router.ServeHTTP(w, r)
|
||||
|
||||
var response responses.JsonWrapper
|
||||
err := json.Unmarshal(w.Body.Bytes(), &response)
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
Expect(*response.Subsonic.OpenSubsonicExtensions).To(SatisfyAll(
|
||||
HaveLen(5),
|
||||
ContainElement(responses.OpenSubsonicExtension{Name: "transcodeOffset", Versions: []int32{1}}),
|
||||
ContainElement(responses.OpenSubsonicExtension{Name: "formPost", Versions: []int32{1}}),
|
||||
ContainElement(responses.OpenSubsonicExtension{Name: "songLyrics", Versions: []int32{1}}),
|
||||
ContainElement(responses.OpenSubsonicExtension{Name: "indexBasedQueue", Versions: []int32{1}}),
|
||||
ContainElement(responses.OpenSubsonicExtension{Name: "transcoding", Versions: []int32{1}}),
|
||||
))
|
||||
// Make sure the endpoint is public, by not passing any authentication
|
||||
Expect(w.Code).To(Equal(http.StatusOK))
|
||||
Expect(w.Header().Get("Content-Type")).To(Equal("application/json"))
|
||||
|
||||
var response responses.JsonWrapper
|
||||
err := json.Unmarshal(w.Body.Bytes(), &response)
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
Expect(*response.Subsonic.OpenSubsonicExtensions).To(SatisfyAll(
|
||||
HaveLen(5),
|
||||
ContainElement(responses.OpenSubsonicExtension{Name: "transcodeOffset", Versions: []int32{1}}),
|
||||
ContainElement(responses.OpenSubsonicExtension{Name: "formPost", Versions: []int32{1}}),
|
||||
ContainElement(responses.OpenSubsonicExtension{Name: "songLyrics", Versions: []int32{1}}),
|
||||
ContainElement(responses.OpenSubsonicExtension{Name: "indexBasedQueue", Versions: []int32{1}}),
|
||||
ContainElement(responses.OpenSubsonicExtension{Name: "transcoding", Versions: []int32{1}}),
|
||||
))
|
||||
Expect(*response.Subsonic.OpenSubsonicExtensions).NotTo(
|
||||
ContainElement(responses.OpenSubsonicExtension{Name: "sonicSimilarity", Versions: []int32{1}}),
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
Context("with sonic similarity plugin", func() {
|
||||
BeforeEach(func() {
|
||||
sonicService := sonicsvc.New(nil, &mockSonicPluginLoader{names: []string{"test-plugin"}}, nil)
|
||||
router = subsonic.New(nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, sonicService)
|
||||
})
|
||||
|
||||
It("should return 6 extensions including sonicSimilarity", func() {
|
||||
router.ServeHTTP(w, r)
|
||||
|
||||
Expect(w.Code).To(Equal(http.StatusOK))
|
||||
Expect(w.Header().Get("Content-Type")).To(Equal("application/json"))
|
||||
|
||||
var response responses.JsonWrapper
|
||||
err := json.Unmarshal(w.Body.Bytes(), &response)
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
Expect(*response.Subsonic.OpenSubsonicExtensions).To(SatisfyAll(
|
||||
HaveLen(6),
|
||||
ContainElement(responses.OpenSubsonicExtension{Name: "transcodeOffset", Versions: []int32{1}}),
|
||||
ContainElement(responses.OpenSubsonicExtension{Name: "formPost", Versions: []int32{1}}),
|
||||
ContainElement(responses.OpenSubsonicExtension{Name: "songLyrics", Versions: []int32{1}}),
|
||||
ContainElement(responses.OpenSubsonicExtension{Name: "indexBasedQueue", Versions: []int32{1}}),
|
||||
ContainElement(responses.OpenSubsonicExtension{Name: "transcoding", Versions: []int32{1}}),
|
||||
ContainElement(responses.OpenSubsonicExtension{Name: "sonicSimilarity", Versions: []int32{1}}),
|
||||
))
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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, nil, nil)
|
||||
router = New(ds, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil)
|
||||
ctx = context.Background()
|
||||
})
|
||||
|
||||
|
|
@ -258,7 +258,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, nil, nil)
|
||||
router = New(ds, nil, nil, nil, nil, nil, nil, nil, playlists, nil, nil, nil, nil, nil, nil, nil)
|
||||
})
|
||||
|
||||
It("clears the comment when parameter is empty", func() {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,25 @@
|
|||
{
|
||||
"status": "ok",
|
||||
"version": "1.16.1",
|
||||
"type": "navidrome",
|
||||
"serverVersion": "v0.55.0",
|
||||
"openSubsonic": true,
|
||||
"sonicMatch": [
|
||||
{
|
||||
"entry": {
|
||||
"id": "1",
|
||||
"isDir": false,
|
||||
"title": "Bohemian Rhapsody"
|
||||
},
|
||||
"similarity": 0.95
|
||||
},
|
||||
{
|
||||
"entry": {
|
||||
"id": "2",
|
||||
"isDir": false,
|
||||
"title": "We Will Rock You"
|
||||
},
|
||||
"similarity": 0.78
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -0,0 +1,10 @@
|
|||
<subsonic-response xmlns="http://subsonic.org/restapi" status="ok" version="1.16.1" type="navidrome" serverVersion="v0.55.0" openSubsonic="true">
|
||||
<sonicMatch>
|
||||
<entry id="1" isDir="false" title="Bohemian Rhapsody"></entry>
|
||||
<similarity>0.95</similarity>
|
||||
</sonicMatch>
|
||||
<sonicMatch>
|
||||
<entry id="2" isDir="false" title="We Will Rock You"></entry>
|
||||
<similarity>0.78</similarity>
|
||||
</sonicMatch>
|
||||
</subsonic-response>
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
{
|
||||
"status": "ok",
|
||||
"version": "1.16.1",
|
||||
"type": "navidrome",
|
||||
"serverVersion": "v0.55.0",
|
||||
"openSubsonic": true,
|
||||
"sonicMatch": []
|
||||
}
|
||||
|
|
@ -0,0 +1 @@
|
|||
<subsonic-response xmlns="http://subsonic.org/restapi" status="ok" version="1.16.1" type="navidrome" serverVersion="v0.55.0" openSubsonic="true"></subsonic-response>
|
||||
|
|
@ -62,6 +62,7 @@ type Subsonic struct {
|
|||
LyricsList *LyricsList `xml:"lyricsList,omitempty" json:"lyricsList,omitempty"`
|
||||
PlayQueueByIndex *PlayQueueByIndex `xml:"playQueueByIndex,omitempty" json:"playQueueByIndex,omitempty"`
|
||||
TranscodeDecision *TranscodeDecision `xml:"transcodeDecision,omitempty" json:"transcodeDecision,omitempty"`
|
||||
SonicMatches *Array[SonicMatch] `xml:"sonicMatch,omitempty" json:"sonicMatch,omitempty"`
|
||||
}
|
||||
|
||||
const (
|
||||
|
|
@ -441,6 +442,11 @@ type TopSongs struct {
|
|||
Song []Child `xml:"song,omitempty" json:"song,omitempty"`
|
||||
}
|
||||
|
||||
type SonicMatch struct {
|
||||
Entry Child `xml:"entry" json:"entry"`
|
||||
Similarity float64 `xml:"similarity" json:"similarity"`
|
||||
}
|
||||
|
||||
type PlayQueue struct {
|
||||
Entry []Child `xml:"entry,omitempty" json:"entry,omitempty"`
|
||||
Current string `xml:"current,attr,omitempty" json:"current,omitempty"`
|
||||
|
|
|
|||
|
|
@ -1114,4 +1114,33 @@ var _ = Describe("Responses", func() {
|
|||
})
|
||||
})
|
||||
|
||||
Describe("SonicMatches", func() {
|
||||
Context("without data", func() {
|
||||
BeforeEach(func() {
|
||||
response.SonicMatches = &Array[SonicMatch]{}
|
||||
})
|
||||
It("should match .XML", func() {
|
||||
Expect(xml.MarshalIndent(response, "", " ")).To(MatchSnapshot())
|
||||
})
|
||||
It("should match .JSON", func() {
|
||||
Expect(json.MarshalIndent(response, "", " ")).To(MatchSnapshot())
|
||||
})
|
||||
})
|
||||
|
||||
Context("with data", func() {
|
||||
BeforeEach(func() {
|
||||
response.SonicMatches = &Array[SonicMatch]{
|
||||
{Entry: Child{Id: "1", Title: "Bohemian Rhapsody", IsDir: false}, Similarity: 0.95},
|
||||
{Entry: Child{Id: "2", Title: "We Will Rock You", IsDir: false}, Similarity: 0.78},
|
||||
}
|
||||
})
|
||||
It("should match .XML", func() {
|
||||
Expect(xml.MarshalIndent(response, "", " ")).To(MatchSnapshot())
|
||||
})
|
||||
It("should match .JSON", func() {
|
||||
Expect(json.MarshalIndent(response, "", " ")).To(MatchSnapshot())
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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, nil, nil)
|
||||
router = New(ds, nil, nil, 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)
|
||||
|
|
|
|||
69
server/subsonic/sonic_similarity.go
Normal file
69
server/subsonic/sonic_similarity.go
Normal file
|
|
@ -0,0 +1,69 @@
|
|||
package subsonic
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
|
||||
"github.com/navidrome/navidrome/core/sonic"
|
||||
"github.com/navidrome/navidrome/server/subsonic/responses"
|
||||
"github.com/navidrome/navidrome/utils/req"
|
||||
)
|
||||
|
||||
func (api *Router) GetSonicSimilarTracks(w http.ResponseWriter, r *http.Request) (*responses.Subsonic, error) {
|
||||
if api.sonic == nil || !api.sonic.HasProvider() {
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
return nil, nil
|
||||
}
|
||||
ctx := r.Context()
|
||||
p := req.Params(r)
|
||||
id, err := p.String("id")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
count := p.IntOr("count", 10)
|
||||
|
||||
matches, err := api.sonic.GetSonicSimilarTracks(ctx, id, count)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return sonicMatchResponse(ctx, matches), nil
|
||||
}
|
||||
|
||||
func (api *Router) FindSonicPath(w http.ResponseWriter, r *http.Request) (*responses.Subsonic, error) {
|
||||
if api.sonic == nil || !api.sonic.HasProvider() {
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
return nil, nil
|
||||
}
|
||||
ctx := r.Context()
|
||||
p := req.Params(r)
|
||||
startSongID, err := p.String("startSongId")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
endSongID, err := p.String("endSongId")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
count := p.IntOr("count", 25)
|
||||
|
||||
matches, err := api.sonic.FindSonicPath(ctx, startSongID, endSongID, count)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return sonicMatchResponse(ctx, matches), nil
|
||||
}
|
||||
|
||||
func sonicMatchResponse(ctx context.Context, matches []sonic.SimilarMatch) *responses.Subsonic {
|
||||
response := newResponse()
|
||||
resp := make(responses.Array[responses.SonicMatch], len(matches))
|
||||
for i, m := range matches {
|
||||
resp[i] = responses.SonicMatch{
|
||||
Entry: childFromMediaFile(ctx, m.MediaFile),
|
||||
Similarity: m.Similarity,
|
||||
}
|
||||
}
|
||||
response.SonicMatches = &resp
|
||||
return response
|
||||
}
|
||||
|
|
@ -27,7 +27,7 @@ var _ = Describe("Transcode endpoints", func() {
|
|||
mockMFRepo = &tests.MockMediaFileRepo{}
|
||||
ds = &tests.MockDataStore{MockedMediaFile: mockMFRepo}
|
||||
mockTD = &mockTranscodeDecision{}
|
||||
router = New(ds, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, mockTD)
|
||||
router = New(ds, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, mockTD, nil)
|
||||
w = httptest.NewRecorder()
|
||||
})
|
||||
|
||||
|
|
@ -284,7 +284,7 @@ var _ = Describe("Transcode endpoints", func() {
|
|||
|
||||
It("builds correct StreamRequest for direct play", func() {
|
||||
fakeStreamer := &fakeMediaStreamer{}
|
||||
router = New(ds, nil, fakeStreamer, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, mockTD)
|
||||
router = New(ds, nil, fakeStreamer, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, mockTD, nil)
|
||||
mockMFRepo.SetData(model.MediaFiles{{ID: "song-1"}})
|
||||
mockTD.resolvedReq = stream.Request{}
|
||||
|
||||
|
|
@ -301,7 +301,7 @@ var _ = Describe("Transcode endpoints", func() {
|
|||
|
||||
It("builds correct StreamRequest for transcoding", func() {
|
||||
fakeStreamer := &fakeMediaStreamer{}
|
||||
router = New(ds, nil, fakeStreamer, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, mockTD)
|
||||
router = New(ds, nil, fakeStreamer, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, mockTD, nil)
|
||||
mockMFRepo.SetData(model.MediaFiles{{ID: "song-2"}})
|
||||
mockTD.resolvedReq = stream.Request{
|
||||
Format: "mp3",
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue