mirror of
https://github.com/navidrome/navidrome.git
synced 2026-08-01 07:21:17 +00:00
* refactor(agents): drop single Artist fields from Song, keep only Artists Song now represents credited artists solely via the Artists slice; the single Artist/ArtistMBID fields and the ArtistList passthrough are removed. Equals continues to hash the whole value. * refactor(lastfm,listenbrainz): build Song.Artists in built-in agents Last.fm and ListenBrainz now populate the Artists slice directly. For ListenBrainz top songs, all credited artist MBIDs are mapped (the combined display name on the first credit plus MBID-only collaborators) instead of keeping only the first MBID, feeding the matcher's per-MBID specificity. * refactor(external): set Song.Artists in top-songs enrichment getMatchingTopSongs now seeds an Artists entry from the known artist when a song carries none, replacing the single Artist/ArtistMBID writes. * refactor(plugins): fold single-artist SongRef into Song.Artists SongRef keeps its single Artist/ArtistMBID fields as part of the plugin wire contract; songRefToAgentSong now folds them into a one-element Artists list when a plugin sends no artists array. * refactor(matcher): read Song.Artists directly groupQueries consumes s.Artists now that ArtistList is gone; test inputs build the Artists slice. * fix(matcher): keep MBID-only artists as identity signals Review follow-up: an artist credited only by MBID (empty ID and name) was dropped before resolution in four places, defeating the multi-MBID matching path this PR adds. - matcher.groupQueries: treat a non-empty MBID as a usable artist signal - external.getMatchingTopSongs: backfill the primary credit's name/MBID when the agent left them empty - plugins.songRefToAgentSong: fold a single-artist SongRef when only ArtistMBID is set (not just when Artist name is set) - listenbrainz.topSongArtists: return nil instead of an empty-name placeholder when neither name nor MBIDs are present * fix(external): only backfill top-song artist onto an unnamed credit Review follow-up (codex P2): the previous backfill stamped the queried artist's MBID onto Artists[0] whenever it was empty, even when that credit already named a different (e.g. featured) artist — producing a mismatched name+MBID pair that could mis-rank matches. Now only an unnamed first credit is filled (it is, by construction, the queried artist); an already-named credit is left untouched. Signed-off-by: Deluan <deluan@navidrome.org> --------- Signed-off-by: Deluan <deluan@navidrome.org>
147 lines
4.3 KiB
Go
147 lines
4.3 KiB
Go
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", Artists: []agents.Artist{{Name: "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())
|
|
})
|
|
})
|
|
})
|