f03ca44a8e
* 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>
381 lines
12 KiB
Go
381 lines
12 KiB
Go
package subsonic
|
|
|
|
import (
|
|
"bytes"
|
|
"cmp"
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"io"
|
|
"net/http/httptest"
|
|
"slices"
|
|
"time"
|
|
|
|
"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"
|
|
. "github.com/onsi/ginkgo/v2"
|
|
. "github.com/onsi/gomega"
|
|
)
|
|
|
|
var _ = Describe("MediaRetrievalController", func() {
|
|
var router *Router
|
|
var ds model.DataStore
|
|
mockRepo := &mockedMediaFile{MockMediaFileRepo: tests.MockMediaFileRepo{}}
|
|
var artwork *fakeArtwork
|
|
var w *httptest.ResponseRecorder
|
|
|
|
BeforeEach(func() {
|
|
ds = &tests.MockDataStore{
|
|
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))
|
|
w = httptest.NewRecorder()
|
|
DeferCleanup(configtest.SetupConfig())
|
|
conf.Server.LyricsPriority = "embedded,.lrc"
|
|
})
|
|
|
|
Describe("GetCoverArt", func() {
|
|
It("should return data for that id", func() {
|
|
r := newGetRequest("id=34", "size=128", "square=true")
|
|
_, err := router.GetCoverArt(w, r)
|
|
|
|
Expect(err).ToNot(HaveOccurred())
|
|
Expect(artwork.recvSize).To(Equal(128))
|
|
Expect(artwork.recvSquare).To(BeTrue())
|
|
Expect(w.Body.String()).To(Equal(artwork.data))
|
|
})
|
|
|
|
It("should return placeholder if id parameter is missing (mimicking Subsonic)", func() {
|
|
r := newGetRequest() // No id parameter
|
|
_, err := router.GetCoverArt(w, r)
|
|
|
|
Expect(err).To(BeNil())
|
|
Expect(artwork.recvId).To(BeEmpty())
|
|
Expect(w.Body.String()).To(Equal(artwork.data))
|
|
})
|
|
|
|
It("should fail when the file is not found", func() {
|
|
artwork.err = model.ErrNotFound
|
|
r := newGetRequest("id=34", "size=128", "square=true")
|
|
_, err := router.GetCoverArt(w, r)
|
|
|
|
Expect(err).To(MatchError("Artwork not found"))
|
|
})
|
|
|
|
It("should fail when there is an unknown error", func() {
|
|
artwork.err = errors.New("weird error")
|
|
r := newGetRequest("id=34", "size=128")
|
|
_, err := router.GetCoverArt(w, r)
|
|
|
|
Expect(err).To(MatchError("weird error"))
|
|
})
|
|
|
|
When("client disconnects (context is cancelled)", func() {
|
|
It("should not call the service if cancelled before the call", func() {
|
|
// Create a request
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
r := newGetRequest("id=34", "size=128", "square=true")
|
|
r = r.WithContext(ctx)
|
|
cancel() // Cancel the context before the call
|
|
|
|
// Call the GetCoverArt method
|
|
_, err := router.GetCoverArt(w, r)
|
|
|
|
// Expect no error and no call to the artwork service
|
|
Expect(err).ToNot(HaveOccurred())
|
|
Expect(artwork.recvId).To(Equal(""))
|
|
Expect(artwork.recvSize).To(Equal(0))
|
|
Expect(artwork.recvSquare).To(BeFalse())
|
|
Expect(w.Body.String()).To(BeEmpty())
|
|
})
|
|
|
|
It("should not return data if cancelled during the call", func() {
|
|
// Create a request with a context that will be cancelled
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
defer cancel() // Ensure the context is cancelled after the test (best practices)
|
|
r := newGetRequest("id=34", "size=128", "square=true")
|
|
r = r.WithContext(ctx)
|
|
artwork.ctxCancelFunc = cancel // Set the cancel function to simulate cancellation in the service
|
|
|
|
// Call the GetCoverArt method
|
|
_, err := router.GetCoverArt(w, r)
|
|
|
|
// Expect no error and the service to have been called
|
|
Expect(err).ToNot(HaveOccurred())
|
|
Expect(artwork.recvId).To(Equal("34"))
|
|
Expect(artwork.recvSize).To(Equal(128))
|
|
Expect(artwork.recvSquare).To(BeTrue())
|
|
Expect(w.Body.String()).To(BeEmpty())
|
|
})
|
|
})
|
|
})
|
|
|
|
Describe("GetLyrics", func() {
|
|
It("should return data for given artist & title", func() {
|
|
r := newGetRequest("artist=Rick+Astley", "title=Never+Gonna+Give+You+Up")
|
|
lyrics, _ := model.ToLyrics("eng", "[00:18.80]We're no strangers to love\n[00:22.80]You know the rules and so do I")
|
|
lyricsJson, err := json.Marshal(model.LyricList{
|
|
*lyrics,
|
|
})
|
|
Expect(err).ToNot(HaveOccurred())
|
|
|
|
baseTime := time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC)
|
|
mockRepo.SetData(model.MediaFiles{
|
|
{
|
|
ID: "2",
|
|
Artist: "Rick Astley",
|
|
Title: "Never Gonna Give You Up",
|
|
Lyrics: "[]",
|
|
UpdatedAt: baseTime.Add(2 * time.Hour), // No lyrics, newer
|
|
},
|
|
{
|
|
ID: "1",
|
|
Artist: "Rick Astley",
|
|
Title: "Never Gonna Give You Up",
|
|
Lyrics: string(lyricsJson),
|
|
UpdatedAt: baseTime.Add(1 * time.Hour), // Has lyrics, older
|
|
},
|
|
{
|
|
ID: "3",
|
|
Artist: "Rick Astley",
|
|
Title: "Never Gonna Give You Up",
|
|
Lyrics: "[]",
|
|
UpdatedAt: baseTime.Add(3 * time.Hour), // No lyrics, newest
|
|
},
|
|
})
|
|
response, err := router.GetLyrics(r)
|
|
Expect(err).ToNot(HaveOccurred())
|
|
Expect(response.Lyrics.Artist).To(Equal("Rick Astley"))
|
|
Expect(response.Lyrics.Title).To(Equal("Never Gonna Give You Up"))
|
|
Expect(response.Lyrics.Value).To(Equal("We're no strangers to love\nYou know the rules and so do I\n"))
|
|
})
|
|
It("should return empty subsonic response if the record corresponding to the given artist & title is not found", func() {
|
|
r := newGetRequest("artist=Dheeraj", "title=Rinkiya+Ke+Papa")
|
|
mockRepo.SetData(model.MediaFiles{})
|
|
response, err := router.GetLyrics(r)
|
|
Expect(err).ToNot(HaveOccurred())
|
|
Expect(response.Lyrics.Artist).To(Equal(""))
|
|
Expect(response.Lyrics.Title).To(Equal(""))
|
|
Expect(response.Lyrics.Value).To(Equal(""))
|
|
})
|
|
It("should return lyric file when finding mediafile with no embedded lyrics but present on filesystem", func() {
|
|
r := newGetRequest("artist=Rick+Astley", "title=Never+Gonna+Give+You+Up")
|
|
mockRepo.SetData(model.MediaFiles{
|
|
{
|
|
Path: "tests/fixtures/test.mp3",
|
|
ID: "1",
|
|
Artist: "Rick Astley",
|
|
Title: "Never Gonna Give You Up",
|
|
},
|
|
{
|
|
Path: "tests/fixtures/test.mp3",
|
|
ID: "2",
|
|
Artist: "Rick Astley",
|
|
Title: "Never Gonna Give You Up",
|
|
},
|
|
})
|
|
response, err := router.GetLyrics(r)
|
|
Expect(err).ToNot(HaveOccurred())
|
|
Expect(response.Lyrics.Artist).To(Equal("Rick Astley"))
|
|
Expect(response.Lyrics.Title).To(Equal("Never Gonna Give You Up"))
|
|
Expect(response.Lyrics.Value).To(Equal("We're no strangers to love\nYou know the rules and so do I\n"))
|
|
})
|
|
})
|
|
|
|
Describe("GetLyricsBySongId", func() {
|
|
const syncedLyrics = "[00:18.80]We're no strangers to love\n[00:22.801]You know the rules and so do I"
|
|
const unsyncedLyrics = "We're no strangers to love\nYou know the rules and so do I"
|
|
const metadata = "[ar:Rick Astley]\n[ti:That one song]\n[offset:-100]"
|
|
var times = []int64{18800, 22801}
|
|
|
|
compareResponses := func(actual *responses.LyricsList, expected responses.LyricsList) {
|
|
Expect(actual).ToNot(BeNil())
|
|
Expect(actual.StructuredLyrics).To(HaveLen(len(expected.StructuredLyrics)))
|
|
for i, realLyric := range actual.StructuredLyrics {
|
|
expectedLyric := expected.StructuredLyrics[i]
|
|
|
|
Expect(realLyric.DisplayArtist).To(Equal(expectedLyric.DisplayArtist))
|
|
Expect(realLyric.DisplayTitle).To(Equal(expectedLyric.DisplayTitle))
|
|
Expect(realLyric.Lang).To(Equal(expectedLyric.Lang))
|
|
Expect(realLyric.Synced).To(Equal(expectedLyric.Synced))
|
|
|
|
if expectedLyric.Offset == nil {
|
|
Expect(realLyric.Offset).To(BeNil())
|
|
} else {
|
|
Expect(*realLyric.Offset).To(Equal(*expectedLyric.Offset))
|
|
}
|
|
|
|
Expect(realLyric.Line).To(HaveLen(len(expectedLyric.Line)))
|
|
for j, realLine := range realLyric.Line {
|
|
expectedLine := expectedLyric.Line[j]
|
|
Expect(realLine.Value).To(Equal(expectedLine.Value))
|
|
|
|
if expectedLine.Start == nil {
|
|
Expect(realLine.Start).To(BeNil())
|
|
} else {
|
|
Expect(*realLine.Start).To(Equal(*expectedLine.Start))
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
It("should return mixed lyrics", func() {
|
|
r := newGetRequest("id=1")
|
|
synced, _ := model.ToLyrics("eng", syncedLyrics)
|
|
unsynced, _ := model.ToLyrics("xxx", unsyncedLyrics)
|
|
lyricsJson, err := json.Marshal(model.LyricList{
|
|
*synced, *unsynced,
|
|
})
|
|
Expect(err).ToNot(HaveOccurred())
|
|
|
|
mockRepo.SetData(model.MediaFiles{
|
|
{
|
|
ID: "1",
|
|
Artist: "Rick Astley",
|
|
Title: "Never Gonna Give You Up",
|
|
Lyrics: string(lyricsJson),
|
|
},
|
|
})
|
|
|
|
response, err := router.GetLyricsBySongId(r)
|
|
Expect(err).ToNot(HaveOccurred())
|
|
compareResponses(response.LyricsList, responses.LyricsList{
|
|
StructuredLyrics: responses.StructuredLyrics{
|
|
{
|
|
Lang: "eng",
|
|
DisplayArtist: "Rick Astley",
|
|
DisplayTitle: "Never Gonna Give You Up",
|
|
Synced: true,
|
|
Line: []responses.Line{
|
|
{
|
|
Start: ×[0],
|
|
Value: "We're no strangers to love",
|
|
},
|
|
{
|
|
Start: ×[1],
|
|
Value: "You know the rules and so do I",
|
|
},
|
|
},
|
|
},
|
|
{
|
|
Lang: "xxx",
|
|
DisplayArtist: "Rick Astley",
|
|
DisplayTitle: "Never Gonna Give You Up",
|
|
Synced: false,
|
|
Line: []responses.Line{
|
|
{
|
|
Value: "We're no strangers to love",
|
|
},
|
|
{
|
|
Value: "You know the rules and so do I",
|
|
},
|
|
},
|
|
},
|
|
},
|
|
})
|
|
})
|
|
|
|
It("should parse lrc metadata", func() {
|
|
r := newGetRequest("id=1")
|
|
synced, _ := model.ToLyrics("eng", metadata+"\n"+syncedLyrics)
|
|
lyricsJson, err := json.Marshal(model.LyricList{
|
|
*synced,
|
|
})
|
|
Expect(err).ToNot(HaveOccurred())
|
|
mockRepo.SetData(model.MediaFiles{
|
|
{
|
|
ID: "1",
|
|
Artist: "Rick Astley",
|
|
Title: "Never Gonna Give You Up",
|
|
Lyrics: string(lyricsJson),
|
|
},
|
|
})
|
|
|
|
response, err := router.GetLyricsBySongId(r)
|
|
Expect(err).ToNot(HaveOccurred())
|
|
|
|
offset := int64(-100)
|
|
compareResponses(response.LyricsList, responses.LyricsList{
|
|
StructuredLyrics: responses.StructuredLyrics{
|
|
{
|
|
DisplayArtist: "Rick Astley",
|
|
DisplayTitle: "That one song",
|
|
Lang: "eng",
|
|
Synced: true,
|
|
Line: []responses.Line{
|
|
{
|
|
Start: ×[0],
|
|
Value: "We're no strangers to love",
|
|
},
|
|
{
|
|
Start: ×[1],
|
|
Value: "You know the rules and so do I",
|
|
},
|
|
},
|
|
Offset: &offset,
|
|
},
|
|
},
|
|
})
|
|
})
|
|
})
|
|
})
|
|
|
|
type fakeArtwork struct {
|
|
artwork.Artwork
|
|
data string
|
|
err error
|
|
ctxCancelFunc func()
|
|
recvId string
|
|
recvSize int
|
|
recvSquare bool
|
|
}
|
|
|
|
func (c *fakeArtwork) GetOrPlaceholder(_ context.Context, id string, size int, square bool) (io.ReadCloser, time.Time, error) {
|
|
if c.err != nil {
|
|
return nil, time.Time{}, c.err
|
|
}
|
|
c.recvId = id
|
|
c.recvSize = size
|
|
c.recvSquare = square
|
|
if c.ctxCancelFunc != nil {
|
|
c.ctxCancelFunc() // Simulate context cancellation
|
|
return nil, time.Time{}, context.Canceled
|
|
}
|
|
return io.NopCloser(bytes.NewReader([]byte(c.data))), time.Time{}, nil
|
|
}
|
|
|
|
type mockedMediaFile struct {
|
|
tests.MockMediaFileRepo
|
|
}
|
|
|
|
func (m *mockedMediaFile) GetAll(opts ...model.QueryOptions) (model.MediaFiles, error) {
|
|
data, err := m.MockMediaFileRepo.GetAll(opts...)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if len(opts) == 0 || opts[0].Sort != "lyrics, updated_at" {
|
|
return data, nil
|
|
}
|
|
|
|
// Hardcoded support for lyrics sorting
|
|
result := slices.Clone(data)
|
|
// Sort by presence of lyrics, then by updated_at. Respect the order specified in opts.
|
|
slices.SortFunc(result, func(a, b model.MediaFile) int {
|
|
diff := cmp.Or(
|
|
cmp.Compare(a.Lyrics, b.Lyrics),
|
|
cmp.Compare(a.UpdatedAt.Unix(), b.UpdatedAt.Unix()),
|
|
)
|
|
if opts[0].Order == "desc" {
|
|
return -diff
|
|
}
|
|
return diff
|
|
})
|
|
return result, nil
|
|
}
|