feat(plugins): add lyrics provider plugin capability (#5126)

* feat(plugins): add lyrics provider plugin capability

Refactor the lyrics system from a static function to an interface-based
service that supports WASM plugin providers. Plugins listed in the
LyricsPriority config (alongside "embedded" and file extensions) are
now resolved through the plugin system.

Includes capability definition, Go/Rust PDK, adapter, Wire integration,
and tests for plugin fallback behavior.

* test(plugins): add lyrics capability integration test with test plugin

* fix(plugins): default lyrics language to 'xxx' when plugin omits it

Per the OpenSubsonic spec, the server must return 'und' or 'xxx' when
the lyrics language is unknown. The lyrics plugin adapter was passing
an empty string through when a plugin didn't provide a language value.
This defaults the language to 'xxx', consistent with all other callers
of model.ToLyrics() in the codebase.

* refactor(plugins): rename lyrics import to improve clarity

Signed-off-by: Deluan <deluan@navidrome.org>

* refactor(lyrics): update TrackInfo description for clarity

Signed-off-by: Deluan <deluan@navidrome.org>

* fix(lyrics): enhance lyrics plugin handling and case sensitivity

Signed-off-by: Deluan <deluan@navidrome.org>

* fix(plugins): update payload type to string with byte format for task data

Signed-off-by: Deluan <deluan@navidrome.org>

---------

Signed-off-by: Deluan <deluan@navidrome.org>
This commit is contained in:
Deluan Quintão
2026-03-03 15:48:39 -05:00
committed by GitHub
parent eeb1bd5f41
commit f03ca44a8e
33 changed files with 930 additions and 28 deletions
+28 -6
View File
@@ -9,23 +9,45 @@ import (
"github.com/navidrome/navidrome/model"
)
func GetLyrics(ctx context.Context, mf *model.MediaFile) (model.LyricList, error) {
// Lyrics can fetch lyrics for a media file.
type Lyrics interface {
GetLyrics(ctx context.Context, mf *model.MediaFile) (model.LyricList, error)
}
// PluginLoader discovers and loads lyrics provider plugins.
type PluginLoader interface {
LoadLyricsProvider(name string) (Lyrics, bool)
}
type lyricsService struct {
pluginLoader PluginLoader
}
// NewLyrics creates a new lyrics service. pluginLoader may be nil if no plugin
// system is available.
func NewLyrics(pluginLoader PluginLoader) Lyrics {
return &lyricsService{pluginLoader: pluginLoader}
}
// GetLyrics returns lyrics for the given media file, trying sources in the
// order specified by conf.Server.LyricsPriority.
func (l *lyricsService) GetLyrics(ctx context.Context, mf *model.MediaFile) (model.LyricList, error) {
var lyricsList model.LyricList
var err error
for pattern := range strings.SplitSeq(strings.ToLower(conf.Server.LyricsPriority), ",") {
for pattern := range strings.SplitSeq(conf.Server.LyricsPriority, ",") {
pattern = strings.TrimSpace(pattern)
switch {
case pattern == "embedded":
case strings.EqualFold(pattern, "embedded"):
lyricsList, err = fromEmbedded(ctx, mf)
case strings.HasPrefix(pattern, "."):
lyricsList, err = fromExternalFile(ctx, mf, pattern)
lyricsList, err = fromExternalFile(ctx, mf, strings.ToLower(pattern))
default:
log.Error(ctx, "Invalid lyric pattern", "pattern", pattern)
lyricsList, err = l.fromPlugin(ctx, mf, pattern)
}
if err != nil {
log.Error(ctx, "error parsing lyrics", "source", pattern, err)
log.Error(ctx, "error getting lyrics", "source", pattern, err)
}
if len(lyricsList) > 0 {
+105 -3
View File
@@ -3,6 +3,7 @@ package lyrics_test
import (
"context"
"encoding/json"
"fmt"
"os"
"github.com/navidrome/navidrome/conf"
@@ -72,7 +73,8 @@ var _ = Describe("sources", func() {
DescribeTable("Lyrics Priority", func(priority string, expected model.LyricList) {
conf.Server.LyricsPriority = priority
list, err := lyrics.GetLyrics(ctx, &mf)
svc := lyrics.NewLyrics(nil)
list, err := svc.GetLyrics(ctx, &mf)
Expect(err).To(BeNil())
Expect(list).To(Equal(expected))
},
@@ -107,7 +109,8 @@ var _ = Describe("sources", func() {
It("should fallback to embedded if an error happens when parsing file", func() {
conf.Server.LyricsPriority = ".mp3,embedded"
list, err := lyrics.GetLyrics(ctx, &mf)
svc := lyrics.NewLyrics(nil)
list, err := svc.GetLyrics(ctx, &mf)
Expect(err).To(BeNil())
Expect(list).To(Equal(embeddedLyrics))
})
@@ -115,10 +118,109 @@ var _ = Describe("sources", func() {
It("should return nothing if error happens when trying to parse file", func() {
conf.Server.LyricsPriority = ".mp3"
list, err := lyrics.GetLyrics(ctx, &mf)
svc := lyrics.NewLyrics(nil)
list, err := svc.GetLyrics(ctx, &mf)
Expect(err).To(BeNil())
Expect(list).To(BeEmpty())
})
})
})
Context("plugin sources", func() {
var mockLoader *mockPluginLoader
BeforeEach(func() {
mockLoader = &mockPluginLoader{}
})
It("should return lyrics from a plugin", func() {
conf.Server.LyricsPriority = "test-lyrics-plugin"
mockLoader.lyrics = unsyncedLyrics
svc := lyrics.NewLyrics(mockLoader)
list, err := svc.GetLyrics(ctx, &mf)
Expect(err).To(BeNil())
Expect(list).To(Equal(unsyncedLyrics))
})
It("should try plugin after embedded returns nothing", func() {
conf.Server.LyricsPriority = "embedded,test-lyrics-plugin"
mf.Lyrics = "" // No embedded lyrics
mockLoader.lyrics = unsyncedLyrics
svc := lyrics.NewLyrics(mockLoader)
list, err := svc.GetLyrics(ctx, &mf)
Expect(err).To(BeNil())
Expect(list).To(Equal(unsyncedLyrics))
})
It("should skip plugin if embedded has lyrics", func() {
conf.Server.LyricsPriority = "embedded,test-lyrics-plugin"
mockLoader.lyrics = unsyncedLyrics
svc := lyrics.NewLyrics(mockLoader)
list, err := svc.GetLyrics(ctx, &mf)
Expect(err).To(BeNil())
Expect(list).To(Equal(embeddedLyrics)) // embedded wins
})
It("should skip unknown plugin names gracefully", func() {
conf.Server.LyricsPriority = "nonexistent-plugin,embedded"
mockLoader.notFound = true
svc := lyrics.NewLyrics(mockLoader)
list, err := svc.GetLyrics(ctx, &mf)
Expect(err).To(BeNil())
Expect(list).To(Equal(embeddedLyrics)) // falls through to embedded
})
It("should preserve plugin name case from config", func() {
conf.Server.LyricsPriority = "MyLyricsPlugin"
mockLoader.pluginName = "MyLyricsPlugin"
mockLoader.lyrics = unsyncedLyrics
svc := lyrics.NewLyrics(mockLoader)
list, err := svc.GetLyrics(ctx, &mf)
Expect(err).To(BeNil())
Expect(list).To(Equal(unsyncedLyrics))
})
It("should handle plugin error gracefully", func() {
conf.Server.LyricsPriority = "test-lyrics-plugin,embedded"
mockLoader.err = fmt.Errorf("plugin error")
svc := lyrics.NewLyrics(mockLoader)
list, err := svc.GetLyrics(ctx, &mf)
Expect(err).To(BeNil())
Expect(list).To(Equal(embeddedLyrics)) // falls through to embedded
})
})
})
type mockPluginLoader struct {
lyrics model.LyricList
err error
notFound bool
pluginName string // expected plugin name (exact match, like real manager)
}
func (m *mockPluginLoader) PluginNames(_ string) []string {
if m.notFound {
return nil
}
return []string{"test-lyrics-plugin"}
}
func (m *mockPluginLoader) LoadLyricsProvider(name string) (lyrics.Lyrics, bool) {
if m.notFound {
return nil, false
}
// If pluginName is set, require exact match (like the real plugin manager)
if m.pluginName != "" && name != m.pluginName {
return nil, false
}
return &mockLyricsProvider{lyrics: m.lyrics, err: m.err}, true
}
type mockLyricsProvider struct {
lyrics model.LyricList
err error
}
func (m *mockLyricsProvider) GetLyrics(_ context.Context, _ *model.MediaFile) (model.LyricList, error) {
return m.lyrics, m.err
}
+24
View File
@@ -49,3 +49,27 @@ func fromExternalFile(ctx context.Context, mf *model.MediaFile, suffix string) (
return model.LyricList{*lyrics}, nil
}
// fromPlugin attempts to load lyrics from a plugin with the given name.
func (l *lyricsService) fromPlugin(ctx context.Context, mf *model.MediaFile, pluginName string) (model.LyricList, error) {
if l.pluginLoader == nil {
log.Debug(ctx, "Invalid lyric source", "source", pluginName)
return nil, nil
}
provider, ok := l.pluginLoader.LoadLyricsProvider(pluginName)
if !ok {
log.Warn(ctx, "Lyrics plugin not found", "plugin", pluginName)
return nil, nil
}
lyricsList, err := provider.GetLyrics(ctx, mf)
if err != nil {
return nil, err
}
if len(lyricsList) > 0 {
log.Trace(ctx, "Retrieved lyrics from plugin", "plugin", pluginName, "count", len(lyricsList))
}
return lyricsList, nil
}
+2
View File
@@ -5,6 +5,7 @@ import (
"github.com/navidrome/navidrome/core/agents"
"github.com/navidrome/navidrome/core/external"
"github.com/navidrome/navidrome/core/ffmpeg"
"github.com/navidrome/navidrome/core/lyrics"
"github.com/navidrome/navidrome/core/metrics"
"github.com/navidrome/navidrome/core/playback"
"github.com/navidrome/navidrome/core/playlists"
@@ -28,4 +29,5 @@ var Set = wire.NewSet(
scrobbler.GetPlayTracker,
playback.GetInstance,
metrics.GetInstance,
lyrics.NewLyrics,
)