navidrome/core/external/provider_similarsongs_test.go
Deluan Quintão bd9fa1c602
feat(listenbrainz): match collaboration top-songs via all credited artist MBIDs (#5670)
* 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>
2026-06-26 17:06:14 -04:00

457 lines
19 KiB
Go

package external_test
import (
"context"
"errors"
"strings"
"github.com/Masterminds/squirrel"
"github.com/navidrome/navidrome/core/agents"
. "github.com/navidrome/navidrome/core/external"
"github.com/navidrome/navidrome/core/matcher"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/tests"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
"github.com/stretchr/testify/mock"
)
var _ = Describe("Provider - SimilarSongs", func() {
var ds model.DataStore
var provider Provider
var mockAgent *mockSimilarArtistAgent
var mockTopAgent agents.ArtistTopSongsRetriever
var mockSimilarAgent agents.ArtistSimilarRetriever
var agentsCombined *mockAgents
var artistRepo *mockArtistRepo
var mediaFileRepo *mockMediaFileRepo
var albumRepo *mockAlbumRepo
var ctx context.Context
BeforeEach(func() {
ctx = GinkgoT().Context()
artistRepo = newMockArtistRepo()
mediaFileRepo = newMockMediaFileRepo()
albumRepo = newMockAlbumRepo()
ds = &tests.MockDataStore{
MockedArtist: artistRepo,
MockedMediaFile: mediaFileRepo,
MockedAlbum: albumRepo,
}
mockAgent = &mockSimilarArtistAgent{}
mockTopAgent = mockAgent
mockSimilarAgent = mockAgent
agentsCombined = &mockAgents{
topSongsAgent: mockTopAgent,
similarAgent: mockSimilarAgent,
}
provider = NewProvider(ds, agentsCombined, matcher.New(ds))
})
Describe("dispatch by entity type", func() {
Context("when ID is a MediaFile (track)", func() {
It("calls GetSimilarSongsByTrack and returns matched songs", func() {
track := model.MediaFile{ID: "track-1", Title: "Just Can't Get Enough", Artist: "Depeche Mode", MbzRecordingID: "track-mbid"}
// Depeche Mode artist row used by matcher artist resolution and track-fetch back-mapping.
dmArtist := model.Artist{ID: "dm-1", Name: "Depeche Mode", OrderArtistName: "depeche mode", MbzArtistID: "artist-mbid"}
dmParticipant := model.Participant{Artist: dmArtist}
matchedSong := model.MediaFile{
ID: "matched-1", Title: "Dreaming of Me", Artist: "Depeche Mode",
Participants: model.Participants{model.RoleArtist: model.ParticipantList{dmParticipant}},
}
// GetEntityByID tries Artist, Album, Playlist, then MediaFile
artistRepo.On("Get", "track-1").Return(nil, model.ErrNotFound).Once()
albumRepo.On("Get", "track-1").Return(nil, model.ErrNotFound).Once()
mediaFileRepo.On("Get", "track-1").Return(&track, nil).Once()
agentsCombined.On("GetSimilarSongsByTrack", mock.Anything, "track-1", "Just Can't Get Enough", "Depeche Mode", "track-mbid", 5).
Return([]agents.Song{
{Name: "Dreaming of Me", MBID: "", Artists: []agents.Artist{{Name: "Depeche Mode", MBID: "artist-mbid"}}},
}, nil).Once()
// Matcher artist resolution: resolve Depeche Mode in the artist table.
artistRepo.On("GetAll", mock.Anything).Return(model.Artists{dmArtist}, nil).Maybe()
// ID phase: no IDs → squirrel.And with media_file.id; won't be called but guard it.
mediaFileRepo.On("GetAll", mock.MatchedBy(func(opt model.QueryOptions) bool {
_, ok := opt.Filters.(squirrel.Eq)
return ok
})).Return(model.MediaFiles{}, nil).Maybe()
// MBID phase: won't fire (empty MBID).
mediaFileRepo.On("GetAll", mock.MatchedBy(func(opt model.QueryOptions) bool {
and, ok := opt.Filters.(squirrel.And)
if !ok || len(and) < 1 {
return false
}
eq, hasEq := and[0].(squirrel.Eq)
if !hasEq {
return false
}
_, hasMBID := eq["mbz_recording_id"]
return hasMBID
})).Return(model.MediaFiles{}, nil).Maybe()
// Matcher track-fetch: subquery returns the matched song with participants.
mediaFileRepo.On("GetAll", mock.MatchedBy(func(opt model.QueryOptions) bool {
and, ok := opt.Filters.(squirrel.And)
if !ok {
return false
}
for _, f := range and {
sql, _, err := f.ToSql()
if err == nil && strings.Contains(sql, "media_file_artists") {
return true
}
}
return false
})).Return(model.MediaFiles{matchedSong}, nil).Maybe()
songs, err := provider.SimilarSongs(ctx, "track-1", 5)
Expect(err).ToNot(HaveOccurred())
Expect(songs).To(HaveLen(1))
Expect(songs[0].ID).To(Equal("matched-1"))
})
It("falls back to artist-based algorithm when GetSimilarSongsByTrack returns empty", func() {
track := model.MediaFile{ID: "track-1", Title: "Track", Artist: "Artist", ArtistID: "artist-1"}
artist := model.Artist{ID: "artist-1", Name: "Artist"}
song := model.MediaFile{ID: "song-1", Title: "Song One", ArtistID: "artist-1", MbzRecordingID: "mbid-1"}
// GetEntityByID for the initial call tries Artist, Album, Playlist, then MediaFile
artistRepo.On("Get", "track-1").Return(nil, model.ErrNotFound).Once()
albumRepo.On("Get", "track-1").Return(nil, model.ErrNotFound).Once()
mediaFileRepo.On("Get", "track-1").Return(&track, nil).Once()
agentsCombined.On("GetSimilarSongsByTrack", mock.Anything, "track-1", "Track", "Artist", "", mock.Anything).
Return([]agents.Song{}, nil).Once()
// Fallback calls getArtist(id) which calls GetEntityByID again - this time it finds the mediafile
// and recursively calls getArtist(v.ArtistID)
artistRepo.On("Get", "track-1").Return(nil, model.ErrNotFound).Once()
albumRepo.On("Get", "track-1").Return(nil, model.ErrNotFound).Once()
mediaFileRepo.On("Get", "track-1").Return(&track, nil).Once()
// Then it recurses with the artist-1 ID
artistRepo.On("Get", "artist-1").Return(&artist, nil).Maybe()
artistRepo.On("GetAll", mock.MatchedBy(func(opt model.QueryOptions) bool {
return opt.Max == 1 && opt.Filters != nil
})).Return(model.Artists{artist}, nil).Maybe()
mockAgent.On("GetSimilarArtists", mock.Anything, "artist-1", "Artist", "", 15).
Return([]agents.Artist{}, nil).Once()
artistRepo.On("GetAll", mock.MatchedBy(func(opt model.QueryOptions) bool {
return opt.Max == 0 && opt.Filters != nil
})).Return(model.Artists{}, nil).Once()
mockAgent.On("GetArtistTopSongs", mock.Anything, "artist-1", "Artist", "", mock.Anything).
Return([]agents.Song{{Name: "Song One", MBID: "mbid-1"}}, nil).Once()
mediaFileRepo.On("GetAll", mock.AnythingOfType("model.QueryOptions")).Return(model.MediaFiles{song}, nil).Once()
songs, err := provider.SimilarSongs(ctx, "track-1", 5)
Expect(err).ToNot(HaveOccurred())
Expect(songs).To(HaveLen(1))
Expect(songs[0].ID).To(Equal("song-1"))
})
})
Context("when ID is an Album", func() {
It("calls GetSimilarSongsByAlbum and returns matched songs", func() {
album := model.Album{ID: "album-1", Name: "Speak & Spell", AlbumArtist: "Depeche Mode", MbzAlbumID: "album-mbid"}
matchedSong := model.MediaFile{ID: "matched-1", Title: "New Life", Artist: "Depeche Mode", MbzRecordingID: "song-mbid"}
// GetEntityByID tries Artist, Album, Playlist, then MediaFile
artistRepo.On("Get", "album-1").Return(nil, model.ErrNotFound).Once()
albumRepo.On("Get", "album-1").Return(&album, nil).Once()
agentsCombined.On("GetSimilarSongsByAlbum", mock.Anything, "album-1", "Speak & Spell", "Depeche Mode", "album-mbid", 5).
Return([]agents.Song{
{Name: "New Life", MBID: "song-mbid", Artists: []agents.Artist{{Name: "Depeche Mode"}}},
}, nil).Once()
// Mock loadTracksByID - no ID matches
mediaFileRepo.On("GetAll", mock.MatchedBy(func(opt model.QueryOptions) bool {
_, ok := opt.Filters.(squirrel.Eq)
return ok
})).Return(model.MediaFiles{}, nil).Once()
// Mock loadTracksByMBID - MBID match
mediaFileRepo.On("GetAll", mock.MatchedBy(func(opt model.QueryOptions) bool {
and, ok := opt.Filters.(squirrel.And)
if !ok || len(and) < 1 {
return false
}
_, hasEq := and[0].(squirrel.Eq)
return hasEq
})).Return(model.MediaFiles{matchedSong}, nil).Once()
songs, err := provider.SimilarSongs(ctx, "album-1", 5)
Expect(err).ToNot(HaveOccurred())
Expect(songs).To(HaveLen(1))
Expect(songs[0].ID).To(Equal("matched-1"))
})
It("falls back when GetSimilarSongsByAlbum returns ErrNotFound", func() {
album := model.Album{ID: "album-1", Name: "Album", AlbumArtist: "Artist", AlbumArtistID: "artist-1"}
artist := model.Artist{ID: "artist-1", Name: "Artist"}
song := model.MediaFile{ID: "song-1", Title: "Song One", ArtistID: "artist-1", MbzRecordingID: "mbid-1"}
// GetEntityByID for the initial call tries Artist, Album, Playlist, then MediaFile
artistRepo.On("Get", "album-1").Return(nil, model.ErrNotFound).Once()
albumRepo.On("Get", "album-1").Return(&album, nil).Once()
agentsCombined.On("GetSimilarSongsByAlbum", mock.Anything, "album-1", "Album", "Artist", "", mock.Anything).
Return(nil, agents.ErrNotFound).Once()
// Fallback calls getArtist(id) which calls GetEntityByID again - this time it finds the album
// and recursively calls getArtist(v.AlbumArtistID)
artistRepo.On("Get", "album-1").Return(nil, model.ErrNotFound).Once()
albumRepo.On("Get", "album-1").Return(&album, nil).Once()
// Then it recurses with the artist-1 ID
artistRepo.On("Get", "artist-1").Return(&artist, nil).Maybe()
artistRepo.On("GetAll", mock.MatchedBy(func(opt model.QueryOptions) bool {
return opt.Max == 1 && opt.Filters != nil
})).Return(model.Artists{artist}, nil).Maybe()
mockAgent.On("GetSimilarArtists", mock.Anything, "artist-1", "Artist", "", 15).
Return([]agents.Artist{}, nil).Once()
artistRepo.On("GetAll", mock.MatchedBy(func(opt model.QueryOptions) bool {
return opt.Max == 0 && opt.Filters != nil
})).Return(model.Artists{}, nil).Once()
mockAgent.On("GetArtistTopSongs", mock.Anything, "artist-1", "Artist", "", mock.Anything).
Return([]agents.Song{{Name: "Song One", MBID: "mbid-1"}}, nil).Once()
mediaFileRepo.On("GetAll", mock.AnythingOfType("model.QueryOptions")).Return(model.MediaFiles{song}, nil).Once()
songs, err := provider.SimilarSongs(ctx, "album-1", 5)
Expect(err).ToNot(HaveOccurred())
Expect(songs).To(HaveLen(1))
Expect(songs[0].ID).To(Equal("song-1"))
})
})
Context("when ID is an Artist", func() {
It("calls GetSimilarSongsByArtist and returns matched songs", func() {
artist := model.Artist{ID: "artist-1", Name: "Depeche Mode", MbzArtistID: "artist-mbid"}
matchedSong := model.MediaFile{ID: "matched-1", Title: "Enjoy the Silence", Artist: "Depeche Mode", MbzRecordingID: "song-mbid"}
artistRepo.On("Get", "artist-1").Return(&artist, nil).Once()
agentsCombined.On("GetSimilarSongsByArtist", mock.Anything, "artist-1", "Depeche Mode", "artist-mbid", 5).
Return([]agents.Song{
{Name: "Enjoy the Silence", MBID: "song-mbid", Artists: []agents.Artist{{Name: "Depeche Mode"}}},
}, nil).Once()
// Mock loadTracksByID - no ID matches
mediaFileRepo.On("GetAll", mock.MatchedBy(func(opt model.QueryOptions) bool {
_, ok := opt.Filters.(squirrel.Eq)
return ok
})).Return(model.MediaFiles{}, nil).Once()
// Mock loadTracksByMBID - MBID match
mediaFileRepo.On("GetAll", mock.MatchedBy(func(opt model.QueryOptions) bool {
and, ok := opt.Filters.(squirrel.And)
if !ok || len(and) < 1 {
return false
}
_, hasEq := and[0].(squirrel.Eq)
return hasEq
})).Return(model.MediaFiles{matchedSong}, nil).Once()
songs, err := provider.SimilarSongs(ctx, "artist-1", 5)
Expect(err).ToNot(HaveOccurred())
Expect(songs).To(HaveLen(1))
Expect(songs[0].ID).To(Equal("matched-1"))
})
})
})
It("returns similar songs from main artist and similar artists", func() {
artist1 := model.Artist{ID: "artist-1", Name: "Artist One"}
similarArtist := model.Artist{ID: "artist-3", Name: "Similar Artist"}
song1 := model.MediaFile{ID: "song-1", Title: "Song One", ArtistID: "artist-1", MbzRecordingID: "mbid-1"}
song2 := model.MediaFile{ID: "song-2", Title: "Song Two", ArtistID: "artist-1", MbzRecordingID: "mbid-2"}
song3 := model.MediaFile{ID: "song-3", Title: "Song Three", ArtistID: "artist-3", MbzRecordingID: "mbid-3"}
artistRepo.On("Get", "artist-1").Return(&artist1, nil).Maybe()
artistRepo.On("Get", "artist-3").Return(&similarArtist, nil).Maybe()
artistRepo.On("GetAll", mock.MatchedBy(func(opt model.QueryOptions) bool {
return opt.Max == 1 && opt.Filters != nil
})).Return(model.Artists{artist1}, nil).Once()
// New similar songs by artist returns ErrNotFound to trigger fallback
agentsCombined.On("GetSimilarSongsByArtist", mock.Anything, "artist-1", "Artist One", "", mock.Anything).
Return(nil, agents.ErrNotFound).Once()
similarAgentsResp := []agents.Artist{
{Name: "Similar Artist", MBID: "similar-mbid"},
}
mockAgent.On("GetSimilarArtists", mock.Anything, "artist-1", "Artist One", "", 15).
Return(similarAgentsResp, nil).Once()
// Mock the three-phase artist lookup: ID (skipped - no IDs), MBID, then Name
// MBID lookup returns empty (no match)
artistRepo.On("GetAll", mock.MatchedBy(func(opt model.QueryOptions) bool {
_, ok := opt.Filters.(squirrel.Eq)
return opt.Max == 0 && ok
})).Return(model.Artists{}, nil).Once()
// Name lookup returns the similar artist
artistRepo.On("GetAll", mock.MatchedBy(func(opt model.QueryOptions) bool {
_, ok := opt.Filters.(squirrel.Or)
return opt.Max == 0 && ok
})).Return(model.Artists{similarArtist}, nil).Once()
mockAgent.On("GetArtistTopSongs", mock.Anything, "artist-1", "Artist One", "", mock.Anything).
Return([]agents.Song{
{Name: "Song One", MBID: "mbid-1"},
{Name: "Song Two", MBID: "mbid-2"},
}, nil).Once()
mockAgent.On("GetArtistTopSongs", mock.Anything, "artist-3", "Similar Artist", "", mock.Anything).
Return([]agents.Song{
{Name: "Song Three", MBID: "mbid-3"},
}, nil).Once()
mediaFileRepo.On("GetAll", mock.AnythingOfType("model.QueryOptions")).Return(model.MediaFiles{song1, song2}, nil).Once()
mediaFileRepo.On("GetAll", mock.AnythingOfType("model.QueryOptions")).Return(model.MediaFiles{song3}, nil).Once()
songs, err := provider.SimilarSongs(ctx, "artist-1", 3)
Expect(err).ToNot(HaveOccurred())
Expect(songs).To(HaveLen(3))
for _, song := range songs {
Expect(song.ID).To(BeElementOf("song-1", "song-2", "song-3"))
}
})
It("returns ErrNotFound when artist is not found", func() {
artistRepo.On("Get", "artist-unknown-artist").Return(nil, model.ErrNotFound)
mediaFileRepo.On("Get", "artist-unknown-artist").Return(nil, model.ErrNotFound)
albumRepo.On("Get", "artist-unknown-artist").Return(nil, model.ErrNotFound)
artistRepo.On("GetAll", mock.MatchedBy(func(opt model.QueryOptions) bool {
return opt.Max == 1 && opt.Filters != nil
})).Return(model.Artists{}, nil).Maybe()
songs, err := provider.SimilarSongs(ctx, "artist-unknown-artist", 5)
Expect(err).To(Equal(model.ErrNotFound))
Expect(songs).To(BeNil())
})
It("returns songs from main artist when GetSimilarArtists returns error", func() {
artist1 := model.Artist{ID: "artist-1", Name: "Artist One"}
song1 := model.MediaFile{ID: "song-1", Title: "Song One", ArtistID: "artist-1", MbzRecordingID: "mbid-1"}
artistRepo.On("Get", "artist-1").Return(&artist1, nil).Maybe()
artistRepo.On("GetAll", mock.MatchedBy(func(opt model.QueryOptions) bool {
return opt.Max == 1 && opt.Filters != nil
})).Return(model.Artists{artist1}, nil).Maybe()
// New similar songs by artist returns ErrNotFound to trigger fallback
agentsCombined.On("GetSimilarSongsByArtist", mock.Anything, "artist-1", "Artist One", "", mock.Anything).
Return(nil, agents.ErrNotFound).Once()
mockAgent.On("GetSimilarArtists", mock.Anything, "artist-1", "Artist One", "", 15).
Return(nil, errors.New("error getting similar artists")).Once()
artistRepo.On("GetAll", mock.MatchedBy(func(opt model.QueryOptions) bool {
return opt.Max == 0 && opt.Filters != nil
})).Return(model.Artists{}, nil).Once()
mockAgent.On("GetArtistTopSongs", mock.Anything, "artist-1", "Artist One", "", mock.Anything).
Return([]agents.Song{
{Name: "Song One", MBID: "mbid-1"},
}, nil).Once()
mediaFileRepo.On("GetAll", mock.AnythingOfType("model.QueryOptions")).Return(model.MediaFiles{song1}, nil).Once()
songs, err := provider.SimilarSongs(ctx, "artist-1", 5)
Expect(err).ToNot(HaveOccurred())
Expect(songs).To(HaveLen(1))
Expect(songs[0].ID).To(Equal("song-1"))
})
It("returns empty list when GetArtistTopSongs returns error", func() {
artist1 := model.Artist{ID: "artist-1", Name: "Artist One"}
artistRepo.On("Get", "artist-1").Return(&artist1, nil).Maybe()
artistRepo.On("GetAll", mock.MatchedBy(func(opt model.QueryOptions) bool {
return opt.Max == 1 && opt.Filters != nil
})).Return(model.Artists{artist1}, nil).Maybe()
// New similar songs by artist returns ErrNotFound to trigger fallback
agentsCombined.On("GetSimilarSongsByArtist", mock.Anything, "artist-1", "Artist One", "", mock.Anything).
Return(nil, agents.ErrNotFound).Once()
mockAgent.On("GetSimilarArtists", mock.Anything, "artist-1", "Artist One", "", 15).
Return([]agents.Artist{}, nil).Once()
artistRepo.On("GetAll", mock.MatchedBy(func(opt model.QueryOptions) bool {
return opt.Max == 0 && opt.Filters != nil
})).Return(model.Artists{}, nil).Once()
mockAgent.On("GetArtistTopSongs", mock.Anything, "artist-1", "Artist One", "", mock.Anything).
Return(nil, errors.New("error getting top songs")).Once()
songs, err := provider.SimilarSongs(ctx, "artist-1", 5)
Expect(err).ToNot(HaveOccurred())
Expect(songs).To(BeEmpty())
})
It("respects count parameter", func() {
artist1 := model.Artist{ID: "artist-1", Name: "Artist One"}
song1 := model.MediaFile{ID: "song-1", Title: "Song One", ArtistID: "artist-1", MbzRecordingID: "mbid-1"}
song2 := model.MediaFile{ID: "song-2", Title: "Song Two", ArtistID: "artist-1", MbzRecordingID: "mbid-2"}
artistRepo.On("Get", "artist-1").Return(&artist1, nil).Maybe()
artistRepo.On("GetAll", mock.MatchedBy(func(opt model.QueryOptions) bool {
return opt.Max == 1 && opt.Filters != nil
})).Return(model.Artists{artist1}, nil).Maybe()
// New similar songs by artist returns ErrNotFound to trigger fallback
agentsCombined.On("GetSimilarSongsByArtist", mock.Anything, "artist-1", "Artist One", "", mock.Anything).
Return(nil, agents.ErrNotFound).Once()
mockAgent.On("GetSimilarArtists", mock.Anything, "artist-1", "Artist One", "", 15).
Return([]agents.Artist{}, nil).Once()
artistRepo.On("GetAll", mock.MatchedBy(func(opt model.QueryOptions) bool {
return opt.Max == 0 && opt.Filters != nil
})).Return(model.Artists{}, nil).Once()
mockAgent.On("GetArtistTopSongs", mock.Anything, "artist-1", "Artist One", "", mock.Anything).
Return([]agents.Song{
{Name: "Song One", MBID: "mbid-1"},
{Name: "Song Two", MBID: "mbid-2"},
}, nil).Once()
mediaFileRepo.On("GetAll", mock.AnythingOfType("model.QueryOptions")).Return(model.MediaFiles{song1, song2}, nil).Once()
songs, err := provider.SimilarSongs(ctx, "artist-1", 1)
Expect(err).ToNot(HaveOccurred())
Expect(songs).To(HaveLen(1))
Expect(songs[0].ID).To(BeElementOf("song-1", "song-2"))
})
})