quintodrome/server/subsonic/media_retrieval_test.go

Ignoring revisions in .git-blame-ignore-revs. Click here to bypass and see the normal blame view.

381 lines
12 KiB
Go
Raw Normal View History

package subsonic
2020-01-08 16:45:07 -09:00
2020-01-09 17:58:03 -09:00
import (
2020-11-17 09:30:37 -09:00
"bytes"
"cmp"
"context"
"encoding/json"
2020-01-09 17:58:03 -09:00
"errors"
"io"
"net/http/httptest"
"slices"
"time"
2020-01-09 17:58:03 -09:00
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/conf/configtest"
"github.com/navidrome/navidrome/core/artwork"
"github.com/navidrome/navidrome/core/lyrics"
2020-01-23 15:44:08 -09:00
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/server/subsonic/responses"
2020-11-13 10:57:49 -09:00
"github.com/navidrome/navidrome/tests"
2022-07-26 12:47:16 -08:00
. "github.com/onsi/ginkgo/v2"
2020-01-09 17:58:03 -09:00
. "github.com/onsi/gomega"
)
var _ = Describe("MediaRetrievalController", func() {
var router *Router
var ds model.DataStore
mockRepo := &mockedMediaFile{MockMediaFileRepo: tests.MockMediaFileRepo{}}
var artwork *fakeArtwork
2020-01-09 17:58:03 -09:00
var w *httptest.ResponseRecorder
BeforeEach(func() {
ds = &tests.MockDataStore{
MockedMediaFile: mockRepo,
}
artwork = &fakeArtwork{data: "image data"}
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>
2026-04-27 13:50:09 -08:00
router = New(ds, artwork, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, lyrics.NewLyrics(nil), nil, nil)
2020-01-09 17:58:03 -09:00
w = httptest.NewRecorder()
DeferCleanup(configtest.SetupConfig())
conf.Server.LyricsPriority = "embedded,.lrc"
2020-01-09 17:58:03 -09:00
})
Describe("GetCoverArt", func() {
It("should return data for that id", func() {
r := newGetRequest("id=34", "size=128", "square=true")
_, err := router.GetCoverArt(w, r)
2020-01-09 17:58:03 -09:00
Expect(err).ToNot(HaveOccurred())
Expect(artwork.recvSize).To(Equal(128))
Expect(artwork.recvSquare).To(BeTrue())
Expect(w.Body.String()).To(Equal(artwork.data))
2020-01-09 17:58:03 -09:00
})
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))
})
2020-01-09 17:58:03 -09:00
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)
2020-01-09 17:58:03 -09:00
Expect(err).To(MatchError("Artwork not found"))
2020-01-09 17:58:03 -09:00
})
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)
2020-01-09 17:58:03 -09:00
2020-10-27 11:23:29 -08:00
Expect(err).To(MatchError("weird error"))
2020-01-09 17:58:03 -09:00
})
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())
})
})
2020-01-09 17:58:03 -09:00
})
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: &times[0],
Value: "We're no strangers to love",
},
{
Start: &times[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: &times[0],
Value: "We're no strangers to love",
},
{
Start: &times[1],
Value: "You know the rules and so do I",
},
},
Offset: &offset,
},
},
})
})
})
2020-01-09 17:58:03 -09:00
})
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
}