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:
+5
-3
@@ -1,6 +1,6 @@
|
||||
// Code generated by Wire. DO NOT EDIT.
|
||||
|
||||
//go:generate go run -mod=mod github.com/google/wire/cmd/wire gen -tags "netgo"
|
||||
//go:generate go run -mod=mod github.com/google/wire/cmd/wire gen -tags "netgo sqlite_fts5"
|
||||
//go:build !wireinject
|
||||
// +build !wireinject
|
||||
|
||||
@@ -16,6 +16,7 @@ import (
|
||||
"github.com/navidrome/navidrome/core/artwork"
|
||||
"github.com/navidrome/navidrome/core/external"
|
||||
"github.com/navidrome/navidrome/core/ffmpeg"
|
||||
"github.com/navidrome/navidrome/core/lyrics"
|
||||
"github.com/navidrome/navidrome/core/metrics"
|
||||
"github.com/navidrome/navidrome/core/playback"
|
||||
"github.com/navidrome/navidrome/core/playlists"
|
||||
@@ -103,7 +104,8 @@ func CreateSubsonicAPIRouter(ctx context.Context) *subsonic.Router {
|
||||
modelScanner := scanner.New(ctx, dataStore, cacheWarmer, broker, playlistsPlaylists, metricsMetrics)
|
||||
playTracker := scrobbler.GetPlayTracker(dataStore, broker, manager)
|
||||
playbackServer := playback.GetInstance(dataStore)
|
||||
router := subsonic.New(dataStore, artworkArtwork, mediaStreamer, archiver, players, provider, modelScanner, broker, playlistsPlaylists, playTracker, share, playbackServer, metricsMetrics)
|
||||
lyricsLyrics := lyrics.NewLyrics(manager)
|
||||
router := subsonic.New(dataStore, artworkArtwork, mediaStreamer, archiver, players, provider, modelScanner, broker, playlistsPlaylists, playTracker, share, playbackServer, metricsMetrics, lyricsLyrics)
|
||||
return router
|
||||
}
|
||||
|
||||
@@ -207,7 +209,7 @@ func getPluginManager() *plugins.Manager {
|
||||
|
||||
// wire_injectors.go:
|
||||
|
||||
var allProviders = wire.NewSet(core.Set, artwork.Set, server.New, subsonic.New, nativeapi.New, public.New, persistence.New, lastfm.NewRouter, listenbrainz.NewRouter, events.GetBroker, scanner.New, scanner.GetWatcher, metrics.GetPrometheusInstance, db.Db, plugins.GetManager, wire.Bind(new(agents.PluginLoader), new(*plugins.Manager)), wire.Bind(new(scrobbler.PluginLoader), new(*plugins.Manager)), wire.Bind(new(nativeapi.PluginManager), new(*plugins.Manager)), wire.Bind(new(core.PluginUnloader), new(*plugins.Manager)), wire.Bind(new(plugins.PluginMetricsRecorder), new(metrics.Metrics)), wire.Bind(new(core.Watcher), new(scanner.Watcher)))
|
||||
var allProviders = wire.NewSet(core.Set, artwork.Set, server.New, subsonic.New, nativeapi.New, public.New, persistence.New, lastfm.NewRouter, listenbrainz.NewRouter, events.GetBroker, scanner.New, scanner.GetWatcher, metrics.GetPrometheusInstance, db.Db, plugins.GetManager, wire.Bind(new(agents.PluginLoader), new(*plugins.Manager)), wire.Bind(new(scrobbler.PluginLoader), new(*plugins.Manager)), wire.Bind(new(lyrics.PluginLoader), new(*plugins.Manager)), wire.Bind(new(nativeapi.PluginManager), new(*plugins.Manager)), wire.Bind(new(core.PluginUnloader), new(*plugins.Manager)), wire.Bind(new(plugins.PluginMetricsRecorder), new(metrics.Metrics)), wire.Bind(new(core.Watcher), new(scanner.Watcher)))
|
||||
|
||||
func GetPluginManager(ctx context.Context) *plugins.Manager {
|
||||
manager := getPluginManager()
|
||||
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
"github.com/navidrome/navidrome/core"
|
||||
"github.com/navidrome/navidrome/core/agents"
|
||||
"github.com/navidrome/navidrome/core/artwork"
|
||||
"github.com/navidrome/navidrome/core/lyrics"
|
||||
"github.com/navidrome/navidrome/core/metrics"
|
||||
"github.com/navidrome/navidrome/core/playback"
|
||||
"github.com/navidrome/navidrome/core/scrobbler"
|
||||
@@ -44,6 +45,7 @@ var allProviders = wire.NewSet(
|
||||
plugins.GetManager,
|
||||
wire.Bind(new(agents.PluginLoader), new(*plugins.Manager)),
|
||||
wire.Bind(new(scrobbler.PluginLoader), new(*plugins.Manager)),
|
||||
wire.Bind(new(lyrics.PluginLoader), new(*plugins.Manager)),
|
||||
wire.Bind(new(nativeapi.PluginManager), new(*plugins.Manager)),
|
||||
wire.Bind(new(core.PluginUnloader), new(*plugins.Manager)),
|
||||
wire.Bind(new(plugins.PluginMetricsRecorder), new(metrics.Metrics)),
|
||||
|
||||
+28
-6
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
@@ -49,3 +49,27 @@ func fromExternalFile(ctx context.Context, mf *model.MediaFile, suffix string) (
|
||||
|
||||
return model.LyricList{*lyrics}, nil
|
||||
}
|
||||
|
||||
// fromPlugin attempts to load lyrics from a plugin with the given name.
|
||||
func (l *lyricsService) fromPlugin(ctx context.Context, mf *model.MediaFile, pluginName string) (model.LyricList, error) {
|
||||
if l.pluginLoader == nil {
|
||||
log.Debug(ctx, "Invalid lyric source", "source", pluginName)
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
provider, ok := l.pluginLoader.LoadLyricsProvider(pluginName)
|
||||
if !ok {
|
||||
log.Warn(ctx, "Lyrics plugin not found", "plugin", pluginName)
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
lyricsList, err := provider.GetLyrics(ctx, mf)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if len(lyricsList) > 0 {
|
||||
log.Trace(ctx, "Retrieved lyrics from plugin", "plugin", pluginName, "count", len(lyricsList))
|
||||
}
|
||||
return lyricsList, nil
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"github.com/navidrome/navidrome/core/agents"
|
||||
"github.com/navidrome/navidrome/core/external"
|
||||
"github.com/navidrome/navidrome/core/ffmpeg"
|
||||
"github.com/navidrome/navidrome/core/lyrics"
|
||||
"github.com/navidrome/navidrome/core/metrics"
|
||||
"github.com/navidrome/navidrome/core/playback"
|
||||
"github.com/navidrome/navidrome/core/playlists"
|
||||
@@ -28,4 +29,5 @@ var Set = wire.NewSet(
|
||||
scrobbler.GetPlayTracker,
|
||||
playback.GetInstance,
|
||||
metrics.GetInstance,
|
||||
lyrics.NewLyrics,
|
||||
)
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
package capabilities
|
||||
|
||||
// Lyrics provides lyrics for a given track from external sources.
|
||||
//
|
||||
//nd:capability name=lyrics required=true
|
||||
type Lyrics interface {
|
||||
//nd:export name=nd_lyrics_get_lyrics
|
||||
GetLyrics(GetLyricsRequest) (GetLyricsResponse, error)
|
||||
}
|
||||
|
||||
// GetLyricsRequest contains the track information for lyrics lookup.
|
||||
type GetLyricsRequest struct {
|
||||
Track TrackInfo `json:"track"`
|
||||
}
|
||||
|
||||
// GetLyricsResponse contains the lyrics returned by the plugin.
|
||||
type GetLyricsResponse struct {
|
||||
Lyrics []LyricsText `json:"lyrics"`
|
||||
}
|
||||
|
||||
// LyricsText represents a single set of lyrics in raw text format.
|
||||
// Text can be plain text or LRC format — Navidrome will parse it.
|
||||
type LyricsText struct {
|
||||
Lang string `json:"lang,omitempty"`
|
||||
Text string `json:"text"`
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
version: v1-draft
|
||||
exports:
|
||||
nd_lyrics_get_lyrics:
|
||||
input:
|
||||
$ref: '#/components/schemas/GetLyricsRequest'
|
||||
contentType: application/json
|
||||
output:
|
||||
$ref: '#/components/schemas/GetLyricsResponse'
|
||||
contentType: application/json
|
||||
components:
|
||||
schemas:
|
||||
ArtistRef:
|
||||
description: ArtistRef is a reference to an artist with name and optional MBID.
|
||||
properties:
|
||||
id:
|
||||
type: string
|
||||
description: ID is the internal Navidrome artist ID (if known).
|
||||
name:
|
||||
type: string
|
||||
description: Name is the artist name.
|
||||
mbid:
|
||||
type: string
|
||||
description: MBID is the MusicBrainz ID for the artist.
|
||||
required:
|
||||
- name
|
||||
GetLyricsRequest:
|
||||
description: GetLyricsRequest contains the track information for lyrics lookup.
|
||||
properties:
|
||||
track:
|
||||
$ref: '#/components/schemas/TrackInfo'
|
||||
required:
|
||||
- track
|
||||
GetLyricsResponse:
|
||||
description: GetLyricsResponse contains the lyrics returned by the plugin.
|
||||
properties:
|
||||
lyrics:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/LyricsText'
|
||||
required:
|
||||
- lyrics
|
||||
LyricsText:
|
||||
description: |-
|
||||
LyricsText represents a single set of lyrics in raw text format.
|
||||
Text can be plain text or LRC format — Navidrome will parse it.
|
||||
properties:
|
||||
lang:
|
||||
type: string
|
||||
text:
|
||||
type: string
|
||||
required:
|
||||
- text
|
||||
TrackInfo:
|
||||
description: TrackInfo contains track metadata.
|
||||
properties:
|
||||
id:
|
||||
type: string
|
||||
description: ID is the internal Navidrome track ID.
|
||||
title:
|
||||
type: string
|
||||
description: Title is the track title.
|
||||
album:
|
||||
type: string
|
||||
description: Album is the album name.
|
||||
artist:
|
||||
type: string
|
||||
description: Artist is the formatted artist name for display (e.g., "Artist1 • Artist2").
|
||||
albumArtist:
|
||||
type: string
|
||||
description: AlbumArtist is the formatted album artist name for display.
|
||||
artists:
|
||||
type: array
|
||||
description: Artists is the list of track artists.
|
||||
items:
|
||||
$ref: '#/components/schemas/ArtistRef'
|
||||
albumArtists:
|
||||
type: array
|
||||
description: AlbumArtists is the list of album artists.
|
||||
items:
|
||||
$ref: '#/components/schemas/ArtistRef'
|
||||
duration:
|
||||
type: number
|
||||
format: float
|
||||
description: Duration is the track duration in seconds.
|
||||
trackNumber:
|
||||
type: integer
|
||||
format: int32
|
||||
description: TrackNumber is the track number on the album.
|
||||
discNumber:
|
||||
type: integer
|
||||
format: int32
|
||||
description: DiscNumber is the disc number.
|
||||
mbzRecordingId:
|
||||
type: string
|
||||
description: MBZRecordingID is the MusicBrainz recording ID.
|
||||
mbzAlbumId:
|
||||
type: string
|
||||
description: MBZAlbumID is the MusicBrainz album/release ID.
|
||||
mbzReleaseGroupId:
|
||||
type: string
|
||||
description: MBZReleaseGroupID is the MusicBrainz release group ID.
|
||||
mbzReleaseTrackId:
|
||||
type: string
|
||||
description: MBZReleaseTrackID is the MusicBrainz release track ID.
|
||||
required:
|
||||
- id
|
||||
- title
|
||||
- album
|
||||
- artist
|
||||
- albumArtist
|
||||
- artists
|
||||
- albumArtists
|
||||
- duration
|
||||
- trackNumber
|
||||
- discNumber
|
||||
@@ -38,7 +38,7 @@ type ArtistRef struct {
|
||||
MBID string `json:"mbid,omitempty"`
|
||||
}
|
||||
|
||||
// TrackInfo contains track metadata for scrobbling.
|
||||
// TrackInfo contains track metadata.
|
||||
type TrackInfo struct {
|
||||
// ID is the internal Navidrome track ID.
|
||||
ID string `json:"id"`
|
||||
|
||||
@@ -77,7 +77,7 @@ components:
|
||||
- track
|
||||
- timestamp
|
||||
TrackInfo:
|
||||
description: TrackInfo contains track metadata for scrobbling.
|
||||
description: TrackInfo contains track metadata.
|
||||
properties:
|
||||
id:
|
||||
type: string
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
package plugins
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/plugins/capabilities"
|
||||
)
|
||||
|
||||
const CapabilityLyrics Capability = "Lyrics"
|
||||
|
||||
const (
|
||||
FuncLyricsGetLyrics = "nd_lyrics_get_lyrics"
|
||||
)
|
||||
|
||||
func init() {
|
||||
registerCapability(
|
||||
CapabilityLyrics,
|
||||
FuncLyricsGetLyrics,
|
||||
)
|
||||
}
|
||||
|
||||
// LyricsPlugin adapts a WASM plugin with the Lyrics capability.
|
||||
type LyricsPlugin struct {
|
||||
name string
|
||||
plugin *plugin
|
||||
}
|
||||
|
||||
// GetLyrics calls the plugin to fetch lyrics, then parses the raw text responses
|
||||
// using model.ToLyrics.
|
||||
func (l *LyricsPlugin) GetLyrics(ctx context.Context, mf *model.MediaFile) (model.LyricList, error) {
|
||||
req := capabilities.GetLyricsRequest{
|
||||
Track: mediaFileToTrackInfo(mf),
|
||||
}
|
||||
resp, err := callPluginFunction[capabilities.GetLyricsRequest, capabilities.GetLyricsResponse](
|
||||
ctx, l.plugin, FuncLyricsGetLyrics, req,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var result model.LyricList
|
||||
for _, lt := range resp.Lyrics {
|
||||
lang := lt.Lang
|
||||
if lang == "" {
|
||||
lang = "xxx"
|
||||
}
|
||||
parsed, err := model.ToLyrics(lang, lt.Text)
|
||||
if err != nil {
|
||||
log.Warn(ctx, "Error parsing plugin lyrics", "plugin", l.name, err)
|
||||
continue
|
||||
}
|
||||
if parsed != nil && !parsed.IsEmpty() {
|
||||
result = append(result, *parsed)
|
||||
}
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
//go:build !windows
|
||||
|
||||
package plugins
|
||||
|
||||
import (
|
||||
"github.com/navidrome/navidrome/model"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
var _ = Describe("LyricsPlugin", Ordered, func() {
|
||||
var (
|
||||
lyricsManager *Manager
|
||||
provider *LyricsPlugin
|
||||
)
|
||||
|
||||
BeforeAll(func() {
|
||||
lyricsManager, _ = createTestManagerWithPlugins(nil,
|
||||
"test-lyrics"+PackageExtension,
|
||||
"test-metadata-agent"+PackageExtension,
|
||||
)
|
||||
|
||||
p, ok := lyricsManager.LoadLyricsProvider("test-lyrics")
|
||||
Expect(ok).To(BeTrue())
|
||||
provider = p.(*LyricsPlugin)
|
||||
})
|
||||
|
||||
Describe("LoadLyricsProvider", func() {
|
||||
It("returns a lyrics provider for a plugin with Lyrics capability", func() {
|
||||
Expect(provider).ToNot(BeNil())
|
||||
})
|
||||
|
||||
It("returns false for a plugin without Lyrics capability", func() {
|
||||
_, ok := lyricsManager.LoadLyricsProvider("test-metadata-agent")
|
||||
Expect(ok).To(BeFalse())
|
||||
})
|
||||
|
||||
It("returns false for non-existent plugin", func() {
|
||||
_, ok := lyricsManager.LoadLyricsProvider("non-existent")
|
||||
Expect(ok).To(BeFalse())
|
||||
})
|
||||
})
|
||||
|
||||
Describe("GetLyrics", func() {
|
||||
It("successfully returns lyrics from the plugin", func() {
|
||||
track := &model.MediaFile{
|
||||
ID: "track-1",
|
||||
Title: "Test Song",
|
||||
Artist: "Test Artist",
|
||||
}
|
||||
|
||||
result, err := provider.GetLyrics(GinkgoT().Context(), track)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(result).To(HaveLen(1))
|
||||
Expect(result[0].Line).ToNot(BeEmpty())
|
||||
Expect(result[0].Line[0].Value).To(ContainSubstring("Test Song"))
|
||||
})
|
||||
|
||||
It("defaults language to 'xxx' when plugin does not provide one", func() {
|
||||
manager, _ := createTestManagerWithPlugins(map[string]map[string]string{
|
||||
"test-lyrics": {"no_lang": "true"},
|
||||
}, "test-lyrics"+PackageExtension)
|
||||
|
||||
p, ok := manager.LoadLyricsProvider("test-lyrics")
|
||||
Expect(ok).To(BeTrue())
|
||||
|
||||
track := &model.MediaFile{ID: "track-1", Title: "Test Song", Artist: "Test Artist"}
|
||||
result, err := p.GetLyrics(GinkgoT().Context(), track)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(result).To(HaveLen(1))
|
||||
Expect(result[0].Lang).To(Equal("xxx"))
|
||||
})
|
||||
|
||||
It("returns error when plugin returns error", func() {
|
||||
manager, _ := createTestManagerWithPlugins(map[string]map[string]string{
|
||||
"test-lyrics": {"error": "service unavailable"},
|
||||
}, "test-lyrics"+PackageExtension)
|
||||
|
||||
p, ok := manager.LoadLyricsProvider("test-lyrics")
|
||||
Expect(ok).To(BeTrue())
|
||||
|
||||
track := &model.MediaFile{ID: "track-1", Title: "Test Song"}
|
||||
_, err := p.GetLyrics(GinkgoT().Context(), track)
|
||||
Expect(err).To(HaveOccurred())
|
||||
})
|
||||
})
|
||||
|
||||
Describe("PluginNames", func() {
|
||||
It("returns plugin names with Lyrics capability", func() {
|
||||
names := lyricsManager.PluginNames("Lyrics")
|
||||
Expect(names).To(ContainElement("test-lyrics"))
|
||||
})
|
||||
|
||||
It("does not return metadata agent plugins for Lyrics capability", func() {
|
||||
names := lyricsManager.PluginNames("Lyrics")
|
||||
Expect(names).ToNot(ContainElement("test-metadata-agent"))
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -16,6 +16,7 @@ import (
|
||||
extism "github.com/extism/go-sdk"
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/core/agents"
|
||||
"github.com/navidrome/navidrome/core/lyrics"
|
||||
"github.com/navidrome/navidrome/core/scrobbler"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
@@ -282,6 +283,22 @@ func (m *Manager) LoadScrobbler(name string) (scrobbler.Scrobbler, bool) {
|
||||
}, true
|
||||
}
|
||||
|
||||
// LoadLyricsProvider loads and returns a lyrics provider plugin by name.
|
||||
func (m *Manager) LoadLyricsProvider(name string) (lyrics.Lyrics, bool) {
|
||||
m.mu.RLock()
|
||||
plugin, ok := m.plugins[name]
|
||||
m.mu.RUnlock()
|
||||
|
||||
if !ok || !hasCapability(plugin.capabilities, CapabilityLyrics) {
|
||||
return nil, false
|
||||
}
|
||||
|
||||
return &LyricsPlugin{
|
||||
name: plugin.name,
|
||||
plugin: plugin,
|
||||
}, true
|
||||
}
|
||||
|
||||
// PluginInfo contains basic information about a plugin for metrics/insights.
|
||||
type PluginInfo struct {
|
||||
Name string
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
// Code generated by ndpgen. DO NOT EDIT.
|
||||
//
|
||||
// This file contains export wrappers for the Lyrics capability.
|
||||
// It is intended for use in Navidrome plugins built with TinyGo.
|
||||
//
|
||||
//go:build wasip1
|
||||
|
||||
package lyrics
|
||||
|
||||
import (
|
||||
"github.com/navidrome/navidrome/plugins/pdk/go/pdk"
|
||||
)
|
||||
|
||||
// ArtistRef is a reference to an artist with name and optional MBID.
|
||||
type ArtistRef struct {
|
||||
// ID is the internal Navidrome artist ID (if known).
|
||||
ID string `json:"id,omitempty"`
|
||||
// Name is the artist name.
|
||||
Name string `json:"name"`
|
||||
// MBID is the MusicBrainz ID for the artist.
|
||||
MBID string `json:"mbid,omitempty"`
|
||||
}
|
||||
|
||||
// GetLyricsRequest contains the track information for lyrics lookup.
|
||||
type GetLyricsRequest struct {
|
||||
Track TrackInfo `json:"track"`
|
||||
}
|
||||
|
||||
// GetLyricsResponse contains the lyrics returned by the plugin.
|
||||
type GetLyricsResponse struct {
|
||||
Lyrics []LyricsText `json:"lyrics"`
|
||||
}
|
||||
|
||||
// LyricsText represents a single set of lyrics in raw text format.
|
||||
// Text can be plain text or LRC format — Navidrome will parse it.
|
||||
type LyricsText struct {
|
||||
Lang string `json:"lang,omitempty"`
|
||||
Text string `json:"text"`
|
||||
}
|
||||
|
||||
// TrackInfo contains track metadata.
|
||||
type TrackInfo struct {
|
||||
// ID is the internal Navidrome track ID.
|
||||
ID string `json:"id"`
|
||||
// Title is the track title.
|
||||
Title string `json:"title"`
|
||||
// Album is the album name.
|
||||
Album string `json:"album"`
|
||||
// Artist is the formatted artist name for display (e.g., "Artist1 • Artist2").
|
||||
Artist string `json:"artist"`
|
||||
// AlbumArtist is the formatted album artist name for display.
|
||||
AlbumArtist string `json:"albumArtist"`
|
||||
// Artists is the list of track artists.
|
||||
Artists []ArtistRef `json:"artists"`
|
||||
// AlbumArtists is the list of album artists.
|
||||
AlbumArtists []ArtistRef `json:"albumArtists"`
|
||||
// Duration is the track duration in seconds.
|
||||
Duration float32 `json:"duration"`
|
||||
// TrackNumber is the track number on the album.
|
||||
TrackNumber int32 `json:"trackNumber"`
|
||||
// DiscNumber is the disc number.
|
||||
DiscNumber int32 `json:"discNumber"`
|
||||
// MBZRecordingID is the MusicBrainz recording ID.
|
||||
MBZRecordingID string `json:"mbzRecordingId,omitempty"`
|
||||
// MBZAlbumID is the MusicBrainz album/release ID.
|
||||
MBZAlbumID string `json:"mbzAlbumId,omitempty"`
|
||||
// MBZReleaseGroupID is the MusicBrainz release group ID.
|
||||
MBZReleaseGroupID string `json:"mbzReleaseGroupId,omitempty"`
|
||||
// MBZReleaseTrackID is the MusicBrainz release track ID.
|
||||
MBZReleaseTrackID string `json:"mbzReleaseTrackId,omitempty"`
|
||||
}
|
||||
|
||||
// Lyrics requires all methods to be implemented.
|
||||
// Lyrics provides lyrics for a given track from external sources.
|
||||
type Lyrics interface {
|
||||
// GetLyrics
|
||||
GetLyrics(GetLyricsRequest) (GetLyricsResponse, error)
|
||||
} // Internal implementation holders
|
||||
var (
|
||||
lyricsImpl func(GetLyricsRequest) (GetLyricsResponse, error)
|
||||
)
|
||||
|
||||
// Register registers a lyrics implementation.
|
||||
// All methods are required.
|
||||
func Register(impl Lyrics) {
|
||||
lyricsImpl = impl.GetLyrics
|
||||
}
|
||||
|
||||
// NotImplementedCode is the standard return code for unimplemented functions.
|
||||
// The host recognizes this and skips the plugin gracefully.
|
||||
const NotImplementedCode int32 = -2
|
||||
|
||||
//go:wasmexport nd_lyrics_get_lyrics
|
||||
func _NdLyricsGetLyrics() int32 {
|
||||
if lyricsImpl == nil {
|
||||
// Return standard code - host will skip this plugin gracefully
|
||||
return NotImplementedCode
|
||||
}
|
||||
|
||||
var input GetLyricsRequest
|
||||
if err := pdk.InputJSON(&input); err != nil {
|
||||
pdk.SetError(err)
|
||||
return -1
|
||||
}
|
||||
|
||||
output, err := lyricsImpl(input)
|
||||
if err != nil {
|
||||
pdk.SetError(err)
|
||||
return -1
|
||||
}
|
||||
|
||||
if err := pdk.OutputJSON(output); err != nil {
|
||||
pdk.SetError(err)
|
||||
return -1
|
||||
}
|
||||
|
||||
return 0
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
// Code generated by ndpgen. DO NOT EDIT.
|
||||
//
|
||||
// This file provides stub implementations for non-WASM platforms.
|
||||
// It allows Go plugins to compile and run tests outside of WASM,
|
||||
// but the actual functionality is only available in WASM builds.
|
||||
//
|
||||
//go:build !wasip1
|
||||
|
||||
package lyrics
|
||||
|
||||
// ArtistRef is a reference to an artist with name and optional MBID.
|
||||
type ArtistRef struct {
|
||||
// ID is the internal Navidrome artist ID (if known).
|
||||
ID string `json:"id,omitempty"`
|
||||
// Name is the artist name.
|
||||
Name string `json:"name"`
|
||||
// MBID is the MusicBrainz ID for the artist.
|
||||
MBID string `json:"mbid,omitempty"`
|
||||
}
|
||||
|
||||
// GetLyricsRequest contains the track information for lyrics lookup.
|
||||
type GetLyricsRequest struct {
|
||||
Track TrackInfo `json:"track"`
|
||||
}
|
||||
|
||||
// GetLyricsResponse contains the lyrics returned by the plugin.
|
||||
type GetLyricsResponse struct {
|
||||
Lyrics []LyricsText `json:"lyrics"`
|
||||
}
|
||||
|
||||
// LyricsText represents a single set of lyrics in raw text format.
|
||||
// Text can be plain text or LRC format — Navidrome will parse it.
|
||||
type LyricsText struct {
|
||||
Lang string `json:"lang,omitempty"`
|
||||
Text string `json:"text"`
|
||||
}
|
||||
|
||||
// TrackInfo contains track metadata.
|
||||
type TrackInfo struct {
|
||||
// ID is the internal Navidrome track ID.
|
||||
ID string `json:"id"`
|
||||
// Title is the track title.
|
||||
Title string `json:"title"`
|
||||
// Album is the album name.
|
||||
Album string `json:"album"`
|
||||
// Artist is the formatted artist name for display (e.g., "Artist1 • Artist2").
|
||||
Artist string `json:"artist"`
|
||||
// AlbumArtist is the formatted album artist name for display.
|
||||
AlbumArtist string `json:"albumArtist"`
|
||||
// Artists is the list of track artists.
|
||||
Artists []ArtistRef `json:"artists"`
|
||||
// AlbumArtists is the list of album artists.
|
||||
AlbumArtists []ArtistRef `json:"albumArtists"`
|
||||
// Duration is the track duration in seconds.
|
||||
Duration float32 `json:"duration"`
|
||||
// TrackNumber is the track number on the album.
|
||||
TrackNumber int32 `json:"trackNumber"`
|
||||
// DiscNumber is the disc number.
|
||||
DiscNumber int32 `json:"discNumber"`
|
||||
// MBZRecordingID is the MusicBrainz recording ID.
|
||||
MBZRecordingID string `json:"mbzRecordingId,omitempty"`
|
||||
// MBZAlbumID is the MusicBrainz album/release ID.
|
||||
MBZAlbumID string `json:"mbzAlbumId,omitempty"`
|
||||
// MBZReleaseGroupID is the MusicBrainz release group ID.
|
||||
MBZReleaseGroupID string `json:"mbzReleaseGroupId,omitempty"`
|
||||
// MBZReleaseTrackID is the MusicBrainz release track ID.
|
||||
MBZReleaseTrackID string `json:"mbzReleaseTrackId,omitempty"`
|
||||
}
|
||||
|
||||
// Lyrics requires all methods to be implemented.
|
||||
// Lyrics provides lyrics for a given track from external sources.
|
||||
type Lyrics interface {
|
||||
// GetLyrics
|
||||
GetLyrics(GetLyricsRequest) (GetLyricsResponse, error)
|
||||
}
|
||||
|
||||
// NotImplementedCode is the standard return code for unimplemented functions.
|
||||
const NotImplementedCode int32 = -2
|
||||
|
||||
// Register is a no-op on non-WASM platforms.
|
||||
// This stub allows code to compile outside of WASM.
|
||||
func Register(_ Lyrics) {}
|
||||
@@ -62,7 +62,7 @@ type ScrobbleRequest struct {
|
||||
Timestamp int64 `json:"timestamp"`
|
||||
}
|
||||
|
||||
// TrackInfo contains track metadata for scrobbling.
|
||||
// TrackInfo contains track metadata.
|
||||
type TrackInfo struct {
|
||||
// ID is the internal Navidrome track ID.
|
||||
ID string `json:"id"`
|
||||
|
||||
@@ -59,7 +59,7 @@ type ScrobbleRequest struct {
|
||||
Timestamp int64 `json:"timestamp"`
|
||||
}
|
||||
|
||||
// TrackInfo contains track metadata for scrobbling.
|
||||
// TrackInfo contains track metadata.
|
||||
type TrackInfo struct {
|
||||
// ID is the internal Navidrome track ID.
|
||||
ID string `json:"id"`
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
//! for implementing Navidrome plugin capabilities in Rust.
|
||||
|
||||
pub mod lifecycle;
|
||||
pub mod lyrics;
|
||||
pub mod metadata;
|
||||
pub mod scheduler;
|
||||
pub mod scrobbler;
|
||||
|
||||
@@ -0,0 +1,148 @@
|
||||
// Code generated by ndpgen. DO NOT EDIT.
|
||||
//
|
||||
// This file contains export wrappers for the Lyrics capability.
|
||||
// It is intended for use in Navidrome plugins built with extism-pdk.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
// Helper functions for skip_serializing_if with numeric types
|
||||
#[allow(dead_code)]
|
||||
fn is_zero_i32(value: &i32) -> bool { *value == 0 }
|
||||
#[allow(dead_code)]
|
||||
fn is_zero_u32(value: &u32) -> bool { *value == 0 }
|
||||
#[allow(dead_code)]
|
||||
fn is_zero_i64(value: &i64) -> bool { *value == 0 }
|
||||
#[allow(dead_code)]
|
||||
fn is_zero_u64(value: &u64) -> bool { *value == 0 }
|
||||
#[allow(dead_code)]
|
||||
fn is_zero_f32(value: &f32) -> bool { *value == 0.0 }
|
||||
#[allow(dead_code)]
|
||||
fn is_zero_f64(value: &f64) -> bool { *value == 0.0 }
|
||||
/// ArtistRef is a reference to an artist with name and optional MBID.
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ArtistRef {
|
||||
/// ID is the internal Navidrome artist ID (if known).
|
||||
#[serde(default, skip_serializing_if = "String::is_empty")]
|
||||
pub id: String,
|
||||
/// Name is the artist name.
|
||||
#[serde(default)]
|
||||
pub name: String,
|
||||
/// MBID is the MusicBrainz ID for the artist.
|
||||
#[serde(default, skip_serializing_if = "String::is_empty")]
|
||||
pub mbid: String,
|
||||
}
|
||||
/// GetLyricsRequest contains the track information for lyrics lookup.
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct GetLyricsRequest {
|
||||
#[serde(default)]
|
||||
pub track: TrackInfo,
|
||||
}
|
||||
/// GetLyricsResponse contains the lyrics returned by the plugin.
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct GetLyricsResponse {
|
||||
#[serde(default)]
|
||||
pub lyrics: Vec<LyricsText>,
|
||||
}
|
||||
/// LyricsText represents a single set of lyrics in raw text format.
|
||||
/// Text can be plain text or LRC format — Navidrome will parse it.
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct LyricsText {
|
||||
#[serde(default, skip_serializing_if = "String::is_empty")]
|
||||
pub lang: String,
|
||||
#[serde(default)]
|
||||
pub text: String,
|
||||
}
|
||||
/// TrackInfo contains track metadata.
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct TrackInfo {
|
||||
/// ID is the internal Navidrome track ID.
|
||||
#[serde(default)]
|
||||
pub id: String,
|
||||
/// Title is the track title.
|
||||
#[serde(default)]
|
||||
pub title: String,
|
||||
/// Album is the album name.
|
||||
#[serde(default)]
|
||||
pub album: String,
|
||||
/// Artist is the formatted artist name for display (e.g., "Artist1 • Artist2").
|
||||
#[serde(default)]
|
||||
pub artist: String,
|
||||
/// AlbumArtist is the formatted album artist name for display.
|
||||
#[serde(default)]
|
||||
pub album_artist: String,
|
||||
/// Artists is the list of track artists.
|
||||
#[serde(default)]
|
||||
pub artists: Vec<ArtistRef>,
|
||||
/// AlbumArtists is the list of album artists.
|
||||
#[serde(default)]
|
||||
pub album_artists: Vec<ArtistRef>,
|
||||
/// Duration is the track duration in seconds.
|
||||
#[serde(default)]
|
||||
pub duration: f32,
|
||||
/// TrackNumber is the track number on the album.
|
||||
#[serde(default)]
|
||||
pub track_number: i32,
|
||||
/// DiscNumber is the disc number.
|
||||
#[serde(default)]
|
||||
pub disc_number: i32,
|
||||
/// MBZRecordingID is the MusicBrainz recording ID.
|
||||
#[serde(default, skip_serializing_if = "String::is_empty")]
|
||||
pub mbz_recording_id: String,
|
||||
/// MBZAlbumID is the MusicBrainz album/release ID.
|
||||
#[serde(default, skip_serializing_if = "String::is_empty")]
|
||||
pub mbz_album_id: String,
|
||||
/// MBZReleaseGroupID is the MusicBrainz release group ID.
|
||||
#[serde(default, skip_serializing_if = "String::is_empty")]
|
||||
pub mbz_release_group_id: String,
|
||||
/// MBZReleaseTrackID is the MusicBrainz release track ID.
|
||||
#[serde(default, skip_serializing_if = "String::is_empty")]
|
||||
pub mbz_release_track_id: String,
|
||||
}
|
||||
|
||||
/// Error represents an error from a capability method.
|
||||
#[derive(Debug)]
|
||||
pub struct Error {
|
||||
pub message: String,
|
||||
}
|
||||
|
||||
impl std::fmt::Display for Error {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
write!(f, "{}", self.message)
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for Error {}
|
||||
|
||||
impl Error {
|
||||
pub fn new(message: impl Into<String>) -> Self {
|
||||
Self { message: message.into() }
|
||||
}
|
||||
}
|
||||
|
||||
/// Lyrics requires all methods to be implemented.
|
||||
/// Lyrics provides lyrics for a given track from external sources.
|
||||
pub trait Lyrics {
|
||||
/// GetLyrics
|
||||
fn get_lyrics(&self, req: GetLyricsRequest) -> Result<GetLyricsResponse, Error>;
|
||||
}
|
||||
|
||||
/// Register all exports for the Lyrics capability.
|
||||
/// This macro generates the WASM export functions for all trait methods.
|
||||
#[macro_export]
|
||||
macro_rules! register_lyrics {
|
||||
($plugin_type:ty) => {
|
||||
#[extism_pdk::plugin_fn]
|
||||
pub fn nd_lyrics_get_lyrics(
|
||||
req: extism_pdk::Json<$crate::lyrics::GetLyricsRequest>
|
||||
) -> extism_pdk::FnResult<extism_pdk::Json<$crate::lyrics::GetLyricsResponse>> {
|
||||
let plugin = <$plugin_type>::default();
|
||||
let result = $crate::lyrics::Lyrics::get_lyrics(&plugin, req.into_inner())?;
|
||||
Ok(extism_pdk::Json(result))
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -76,7 +76,7 @@ pub struct ScrobbleRequest {
|
||||
#[serde(default)]
|
||||
pub timestamp: i64,
|
||||
}
|
||||
/// TrackInfo contains track metadata for scrobbling.
|
||||
/// TrackInfo contains track metadata.
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct TrackInfo {
|
||||
|
||||
Vendored
+16
@@ -0,0 +1,16 @@
|
||||
module test-lyrics
|
||||
|
||||
go 1.25
|
||||
|
||||
require github.com/navidrome/navidrome/plugins/pdk/go v0.0.0
|
||||
|
||||
require (
|
||||
github.com/davecgh/go-spew v1.1.1 // indirect
|
||||
github.com/extism/go-pdk v1.1.3 // indirect
|
||||
github.com/pmezard/go-difflib v1.0.0 // indirect
|
||||
github.com/stretchr/objx v0.5.2 // indirect
|
||||
github.com/stretchr/testify v1.11.1 // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
)
|
||||
|
||||
replace github.com/navidrome/navidrome/plugins/pdk/go => ../../pdk/go
|
||||
Vendored
+14
@@ -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=
|
||||
Vendored
+42
@@ -0,0 +1,42 @@
|
||||
// Test lyrics plugin for Navidrome plugin system integration tests.
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/navidrome/navidrome/plugins/pdk/go/lyrics"
|
||||
"github.com/navidrome/navidrome/plugins/pdk/go/pdk"
|
||||
)
|
||||
|
||||
func init() {
|
||||
lyrics.Register(&testLyrics{})
|
||||
}
|
||||
|
||||
type testLyrics struct{}
|
||||
|
||||
func (t *testLyrics) GetLyrics(input lyrics.GetLyricsRequest) (lyrics.GetLyricsResponse, error) {
|
||||
// Check for configured error
|
||||
errMsg, hasErr := pdk.GetConfig("error")
|
||||
if hasErr && errMsg != "" {
|
||||
return lyrics.GetLyricsResponse{}, fmt.Errorf("%s", errMsg)
|
||||
}
|
||||
|
||||
// Check if we should omit language (to test default language handling)
|
||||
noLang, hasNoLang := pdk.GetConfig("no_lang")
|
||||
lang := "eng"
|
||||
if hasNoLang && noLang == "true" {
|
||||
lang = ""
|
||||
}
|
||||
|
||||
// Return test lyrics based on track info
|
||||
return lyrics.GetLyricsResponse{
|
||||
Lyrics: []lyrics.LyricsText{
|
||||
{
|
||||
Lang: lang,
|
||||
Text: "Test lyrics for " + input.Track.Title + "\nBy " + input.Track.Artist,
|
||||
},
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func main() {}
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"name": "Test Lyrics",
|
||||
"author": "Navidrome Test",
|
||||
"version": "1.0.0",
|
||||
"description": "A test lyrics plugin for integration testing"
|
||||
}
|
||||
@@ -19,6 +19,7 @@ import (
|
||||
"github.com/navidrome/navidrome/core/artwork"
|
||||
"github.com/navidrome/navidrome/core/auth"
|
||||
"github.com/navidrome/navidrome/core/external"
|
||||
"github.com/navidrome/navidrome/core/lyrics"
|
||||
"github.com/navidrome/navidrome/core/metrics"
|
||||
"github.com/navidrome/navidrome/core/playback"
|
||||
"github.com/navidrome/navidrome/core/playlists"
|
||||
@@ -396,6 +397,7 @@ func setupTestDB() {
|
||||
core.NewShare(ds),
|
||||
playback.PlaybackServer(nil),
|
||||
metrics.NewNoopInstance(),
|
||||
lyrics.NewLyrics(nil),
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -27,7 +27,7 @@ var _ = Describe("Album Lists", func() {
|
||||
ds = &tests.MockDataStore{}
|
||||
auth.Init(ds)
|
||||
mockRepo = ds.Album(ctx).(*tests.MockAlbumRepo)
|
||||
router = New(ds, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil)
|
||||
router = New(ds, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil)
|
||||
w = httptest.NewRecorder()
|
||||
})
|
||||
|
||||
|
||||
@@ -14,6 +14,7 @@ import (
|
||||
"github.com/navidrome/navidrome/core"
|
||||
"github.com/navidrome/navidrome/core/artwork"
|
||||
"github.com/navidrome/navidrome/core/external"
|
||||
lyricssvc "github.com/navidrome/navidrome/core/lyrics"
|
||||
"github.com/navidrome/navidrome/core/metrics"
|
||||
"github.com/navidrome/navidrome/core/playback"
|
||||
playlistsvc "github.com/navidrome/navidrome/core/playlists"
|
||||
@@ -48,12 +49,13 @@ type Router struct {
|
||||
share core.Share
|
||||
playback playback.PlaybackServer
|
||||
metrics metrics.Metrics
|
||||
lyrics lyricssvc.Lyrics
|
||||
}
|
||||
|
||||
func New(ds model.DataStore, artwork artwork.Artwork, streamer core.MediaStreamer, archiver core.Archiver,
|
||||
players core.Players, provider external.Provider, scanner model.Scanner, broker events.Broker,
|
||||
playlists playlistsvc.Playlists, scrobbler scrobbler.PlayTracker, share core.Share, playback playback.PlaybackServer,
|
||||
metrics metrics.Metrics,
|
||||
metrics metrics.Metrics, lyrics lyricssvc.Lyrics,
|
||||
) *Router {
|
||||
r := &Router{
|
||||
ds: ds,
|
||||
@@ -69,6 +71,7 @@ func New(ds model.DataStore, artwork artwork.Artwork, streamer core.MediaStreame
|
||||
share: share,
|
||||
playback: playback,
|
||||
metrics: metrics,
|
||||
lyrics: lyrics,
|
||||
}
|
||||
r.Handler = r.routes()
|
||||
return r
|
||||
|
||||
@@ -27,7 +27,7 @@ var _ = Describe("MediaAnnotationController", func() {
|
||||
ds = &tests.MockDataStore{}
|
||||
playTracker = &fakePlayTracker{}
|
||||
eventBroker = &fakeEventBroker{}
|
||||
router = New(ds, nil, nil, nil, nil, nil, nil, eventBroker, nil, playTracker, nil, nil, nil)
|
||||
router = New(ds, nil, nil, nil, nil, nil, nil, eventBroker, nil, playTracker, nil, nil, nil, nil)
|
||||
})
|
||||
|
||||
Describe("Scrobble", func() {
|
||||
|
||||
@@ -10,7 +10,6 @@ import (
|
||||
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/consts"
|
||||
"github.com/navidrome/navidrome/core/lyrics"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/resources"
|
||||
@@ -109,7 +108,7 @@ func (api *Router) GetLyrics(r *http.Request) (*responses.Subsonic, error) {
|
||||
return response, nil
|
||||
}
|
||||
|
||||
structuredLyrics, err := lyrics.GetLyrics(r.Context(), &mediaFiles[0])
|
||||
structuredLyrics, err := api.lyrics.GetLyrics(r.Context(), &mediaFiles[0])
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -142,7 +141,7 @@ func (api *Router) GetLyricsBySongId(r *http.Request) (*responses.Subsonic, erro
|
||||
return nil, err
|
||||
}
|
||||
|
||||
structuredLyrics, err := lyrics.GetLyrics(r.Context(), mediaFile)
|
||||
structuredLyrics, err := api.lyrics.GetLyrics(r.Context(), mediaFile)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ import (
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/conf/configtest"
|
||||
"github.com/navidrome/navidrome/core/artwork"
|
||||
"github.com/navidrome/navidrome/core/lyrics"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/server/subsonic/responses"
|
||||
"github.com/navidrome/navidrome/tests"
|
||||
@@ -33,7 +34,7 @@ var _ = Describe("MediaRetrievalController", func() {
|
||||
MockedMediaFile: mockRepo,
|
||||
}
|
||||
artwork = &fakeArtwork{data: "image data"}
|
||||
router = New(ds, artwork, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil)
|
||||
router = New(ds, artwork, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, lyrics.NewLyrics(nil))
|
||||
w = httptest.NewRecorder()
|
||||
DeferCleanup(configtest.SetupConfig())
|
||||
conf.Server.LyricsPriority = "embedded,.lrc"
|
||||
|
||||
@@ -19,7 +19,7 @@ var _ = Describe("GetOpenSubsonicExtensions", func() {
|
||||
)
|
||||
|
||||
BeforeEach(func() {
|
||||
router = subsonic.New(nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil)
|
||||
router = subsonic.New(nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil)
|
||||
w = httptest.NewRecorder()
|
||||
r = httptest.NewRequest("GET", "/getOpenSubsonicExtensions?f=json", nil)
|
||||
})
|
||||
|
||||
@@ -24,7 +24,7 @@ var _ = Describe("buildPlaylist", func() {
|
||||
|
||||
BeforeEach(func() {
|
||||
ds = &tests.MockDataStore{}
|
||||
router = New(ds, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil)
|
||||
router = New(ds, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil)
|
||||
ctx = context.Background()
|
||||
})
|
||||
|
||||
@@ -224,7 +224,7 @@ var _ = Describe("UpdatePlaylist", func() {
|
||||
BeforeEach(func() {
|
||||
ds = &tests.MockDataStore{}
|
||||
playlists = &fakePlaylists{}
|
||||
router = New(ds, nil, nil, nil, nil, nil, nil, nil, playlists, nil, nil, nil, nil)
|
||||
router = New(ds, nil, nil, nil, nil, nil, nil, nil, playlists, nil, nil, nil, nil, nil)
|
||||
})
|
||||
|
||||
It("clears the comment when parameter is empty", func() {
|
||||
|
||||
@@ -21,7 +21,7 @@ var _ = Describe("Search", func() {
|
||||
ds = &tests.MockDataStore{}
|
||||
auth.Init(ds)
|
||||
|
||||
router = New(ds, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil)
|
||||
router = New(ds, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil)
|
||||
|
||||
// Get references to the mock repositories so we can inspect their Options
|
||||
mockAlbumRepo = ds.Album(nil).(*tests.MockAlbumRepo)
|
||||
|
||||
Reference in New Issue
Block a user