feat(matcher): match similar/top songs by multiple artists (#5668)

* feat(matcher): add Song.Artists (agents.Artist) and field-wise song dedup

* refactor(matcher): make song equality an agents.Song.Equals method via hashstructure

Move the sameSong free function from core/matcher into an Equals method on
agents.Song, following the model.MediaFile/Album.Equals convention. Uses strict
hashstructure hashing (nil opts, no IgnoreZeroValue) to preserve the original
whole-value equality contract. Tests moved to core/agents.

* feat(matcher): match by multiple artists with overlap ranking and artist-ID fast-path

* refactor(matcher): rank artist overlap and specificity above the preferred-track flag

Identity signals (specificityLevel, artistOverlap) now outrank the taste
signal (preferredMatch) in betterThan. A starred/4-star track that is a worse
identity match no longer beats a more specific or higher-overlap track.
PreferStarred still breaks ties when specificity and overlap are equal.

* fix(matcher): score artist-MBID specificity against all credited artists, not just the last

sanitizedTrack.artistMBID (string) replaced with artistMBIDs (map[string]struct{}) so
bucketTracks collects all credited owned MBIDs per query instead of last-write-wins.
computeSpecificityLevel tests set membership, letting each of a collaboration's
MBID-bearing artists reach the proper specificity level (4/5) independently.

* feat(plugins): carry multiple artists (with IDs) through SongRef conversions

* feat(plugins): regenerate schemas and PDK wrappers for multi-artist SongRef

* refactor(matcher): tidy bucketTracks accumulator and artist resolution

Replace bucketTracks' two parallel per-track maps (overlapByQuery/mbidsByQuery)
with a named queryAccum struct (F2). Collapse resolveArtists' four hand-mutated
parallel maps into a pendingArtist slice with derived nameToQueries/mbidToQueries
maps (F1). Replace the own() method on resolvedArtists with a package-level
addToSet helper that drops the method/receiver indirection (F3).

* docs(matcher): trim comments that restate the code

* fix(plugins): use Vec::is_empty for slice fields in generated Rust PDK

* fix(matcher): treat a resolved artist ID as an identity match for specificity

* docs(matcher): reflect artist-ID identity in the specificity ladder
This commit is contained in:
Deluan Quintão 2026-06-26 14:46:09 -04:00 • committed by GitHub
commit 7b7721f002
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
21 changed files with 763 additions and 163 deletions

View file

@ -148,6 +148,8 @@ type SongRef struct {
Artist string `json:"artist,omitempty"`
// ArtistMBID is the MusicBrainz artist ID.
ArtistMBID string `json:"artistMbid,omitempty"`
// Artists is the full artist list; when set, takes precedence over Artist/ArtistMBID for matching.
Artists []ArtistRef `json:"artists,omitempty"`
// Album is the album name.
Album string `json:"album,omitempty"`
// AlbumMBID is the MusicBrainz release ID.

View file

@ -352,6 +352,11 @@ components:
artistMbid:
type: string
description: ArtistMBID is the MusicBrainz artist ID.
artists:
type: array
description: Artists is the full artist list; when set, takes precedence over Artist/ArtistMBID for matching.
items:
$ref: '#/components/schemas/ArtistRef'
album:
type: string
description: Album is the album name.

View file

@ -16,6 +16,20 @@ exports:
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
FindSonicPathRequest:
properties:
startSong:
@ -60,6 +74,11 @@ components:
artistMbid:
type: string
description: ArtistMBID is the MusicBrainz artist ID.
artists:
type: array
description: Artists is the full artist list; when set, takes precedence over Artist/ArtistMBID for matching.
items:
$ref: '#/components/schemas/ArtistRef'
album:
type: string
description: Album is the album name.

View file

@ -568,9 +568,16 @@ func rustConstName(name string) string {
}
// skipSerializingFunc returns the appropriate skip_serializing_if function name.
// The check must match the rendered Rust type: pointers become Option<T>, slices Vec<T>,
// and maps HashMap<K,V>, each with a different emptiness predicate.
func skipSerializingFunc(goType string) string {
if strings.HasPrefix(goType, "*") || strings.HasPrefix(goType, "[]") || strings.HasPrefix(goType, "map[") {
switch {
case strings.HasPrefix(goType, "*"):
return "Option::is_none"
case strings.HasPrefix(goType, "[]"):
return "Vec::is_empty"
case strings.HasPrefix(goType, "map["):
return "HashMap::is_empty"
}
switch goType {
case "string":

View file

@ -1289,12 +1289,16 @@ type OnInitOutput struct {
var _ = Describe("Rust Generation", func() {
Describe("skipSerializingFunc", func() {
It("should return Option::is_none for pointer, slice, and map types", func() {
It("should return Option::is_none for pointer types", func() {
Expect(skipSerializingFunc("*string")).To(Equal("Option::is_none"))
Expect(skipSerializingFunc("*MyStruct")).To(Equal("Option::is_none"))
Expect(skipSerializingFunc("[]string")).To(Equal("Option::is_none"))
Expect(skipSerializingFunc("[]int32")).To(Equal("Option::is_none"))
Expect(skipSerializingFunc("map[string]int")).To(Equal("Option::is_none"))
})
It("should return the matching emptiness predicate for slice and map types", func() {
// The predicate must match the rendered Rust type: []T -> Vec<T>, map[K]V -> HashMap<K,V>.
Expect(skipSerializingFunc("[]string")).To(Equal("Vec::is_empty"))
Expect(skipSerializingFunc("[]int32")).To(Equal("Vec::is_empty"))
Expect(skipSerializingFunc("map[string]int")).To(Equal("HashMap::is_empty"))
})
It("should return String::is_empty for string type", func() {

View file

@ -229,6 +229,13 @@ func (a *MetadataAgent) GetSimilarSongsByArtist(ctx context.Context, id, name, m
// songRefToAgentSong converts a single SongRef to agents.Song
func songRefToAgentSong(s capabilities.SongRef) agents.Song {
var artists []agents.Artist
if len(s.Artists) > 0 {
artists = make([]agents.Artist, len(s.Artists))
for i, a := range s.Artists {
artists[i] = agents.Artist{ID: a.ID, Name: a.Name, MBID: a.MBID}
}
}
return agents.Song{
ID: s.ID,
Name: s.Name,
@ -236,6 +243,7 @@ func songRefToAgentSong(s capabilities.SongRef) agents.Song {
ISRC: s.ISRC,
Artist: s.Artist,
ArtistMBID: s.ArtistMBID,
Artists: artists,
Album: s.Album,
AlbumMBID: s.AlbumMBID,
Duration: uint32(s.Duration * 1000),

View file

@ -4,6 +4,7 @@ package plugins
import (
"github.com/navidrome/navidrome/core/agents"
"github.com/navidrome/navidrome/plugins/capabilities"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
@ -329,3 +330,24 @@ var _ = Describe("MetadataAgent partial implementation", Ordered, func() {
Expect(err).To(MatchError(errNotImplemented))
})
})
var _ = Describe("songRefToAgentSong multi-artist", func() {
It("maps ArtistRef to agents.Artist", func() {
ref := capabilities.SongRef{Name: "Collab", Artist: "Drake", Artists: []capabilities.ArtistRef{
{ID: "id-drake", Name: "Drake", MBID: "m-drake"},
{Name: "Future", MBID: "m-future"},
}}
got := songRefToAgentSong(ref)
Expect(got.Artists).To(Equal([]agents.Artist{
{ID: "id-drake", Name: "Drake", MBID: "m-drake"},
{Name: "Future", MBID: "m-future"},
}))
})
It("leaves Artists nil and keeps the single Artist when no Artists provided", func() {
ref := capabilities.SongRef{Name: "Solo", Artist: "Drake", ArtistMBID: "m-drake"}
got := songRefToAgentSong(ref)
Expect(got.Artists).To(BeNil())
Expect(got.Artist).To(Equal("Drake"))
Expect(got.ArtistMBID).To(Equal("m-drake"))
})
})

View file

@ -177,6 +177,8 @@ type SongRef struct {
Artist string `json:"artist,omitempty"`
// ArtistMBID is the MusicBrainz artist ID.
ArtistMBID string `json:"artistMbid,omitempty"`
// Artists is the full artist list; when set, takes precedence over Artist/ArtistMBID for matching.
Artists []ArtistRef `json:"artists,omitempty"`
// Album is the album name.
Album string `json:"album,omitempty"`
// AlbumMBID is the MusicBrainz release ID.

View file

@ -174,6 +174,8 @@ type SongRef struct {
Artist string `json:"artist,omitempty"`
// ArtistMBID is the MusicBrainz artist ID.
ArtistMBID string `json:"artistMbid,omitempty"`
// Artists is the full artist list; when set, takes precedence over Artist/ArtistMBID for matching.
Artists []ArtistRef `json:"artists,omitempty"`
// Album is the album name.
Album string `json:"album,omitempty"`
// AlbumMBID is the MusicBrainz release ID.

View file

@ -11,6 +11,16 @@ 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"`
}
// FindSonicPathRequest represents the FindSonicPathRequest data structure.
type FindSonicPathRequest struct {
StartSong SongRef `json:"startSong"`
@ -38,6 +48,8 @@ type SongRef struct {
Artist string `json:"artist,omitempty"`
// ArtistMBID is the MusicBrainz artist ID.
ArtistMBID string `json:"artistMbid,omitempty"`
// Artists is the full artist list; when set, takes precedence over Artist/ArtistMBID for matching.
Artists []ArtistRef `json:"artists,omitempty"`
// Album is the album name.
Album string `json:"album,omitempty"`
// AlbumMBID is the MusicBrainz release ID.

View file

@ -8,6 +8,16 @@
package sonicsimilarity
// 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"`
}
// FindSonicPathRequest represents the FindSonicPathRequest data structure.
type FindSonicPathRequest struct {
StartSong SongRef `json:"startSong"`
@ -35,6 +45,8 @@ type SongRef struct {
Artist string `json:"artist,omitempty"`
// ArtistMBID is the MusicBrainz artist ID.
ArtistMBID string `json:"artistMbid,omitempty"`
// Artists is the full artist list; when set, takes precedence over Artist/ArtistMBID for matching.
Artists []ArtistRef `json:"artists,omitempty"`
// Album is the album name.
Album string `json:"album,omitempty"`
// AlbumMBID is the MusicBrainz release ID.

View file

@ -251,6 +251,9 @@ pub struct SongRef {
/// ArtistMBID is the MusicBrainz artist ID.
#[serde(default, skip_serializing_if = "String::is_empty")]
pub artist_mbid: String,
/// Artists is the full artist list; when set, takes precedence over Artist/ArtistMBID for matching.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub artists: Vec<ArtistRef>,
/// Album is the album name.
#[serde(default, skip_serializing_if = "String::is_empty")]
pub album: String,

View file

@ -18,6 +18,20 @@ fn is_zero_u64(value: &u64) -> bool { *value == 0 }
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,
}
/// FindSonicPathRequest represents the FindSonicPathRequest data structure.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
@ -60,6 +74,9 @@ pub struct SongRef {
/// ArtistMBID is the MusicBrainz artist ID.
#[serde(default, skip_serializing_if = "String::is_empty")]
pub artist_mbid: String,
/// Artists is the full artist list; when set, takes precedence over Artist/ArtistMBID for matching.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub artists: Vec<ArtistRef>,
/// Album is the album name.
#[serde(default, skip_serializing_if = "String::is_empty")]
pub album: String,

View file

@ -72,6 +72,9 @@ func mediaFileToSongRef(mf *model.MediaFile) capabilities.SongRef {
AlbumMBID: mf.MbzAlbumID,
Duration: mf.Duration,
}
for _, p := range mf.Participants[model.RoleArtist] {
ref.Artists = append(ref.Artists, capabilities.ArtistRef{ID: p.ID, Name: p.Name, MBID: p.MbzArtistID})
}
if isrcs := mf.Tags.Values(model.TagISRC); len(isrcs) > 0 {
ref.ISRC = isrcs[0]
}

View file

@ -5,6 +5,7 @@ package plugins
import (
"github.com/navidrome/navidrome/core/sonic"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/plugins/capabilities"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
@ -108,3 +109,24 @@ var _ = Describe("SonicSimilarityPlugin error handling", Ordered, func() {
Expect(err.Error()).To(ContainSubstring("simulated plugin error"))
})
})
var _ = Describe("mediaFileToSongRef multi-artist", func() {
It("fills Artists (with IDs) from role=artist participants", func() {
mf := &model.MediaFile{ID: "x", Title: "Collab", Participants: model.Participants{
model.RoleArtist: model.ParticipantList{
{Artist: model.Artist{ID: "ar-drake", Name: "Drake", MbzArtistID: "m-drake"}},
{Artist: model.Artist{ID: "ar-future", Name: "Future", MbzArtistID: "m-future"}},
},
}}
ref := mediaFileToSongRef(mf)
Expect(ref.Artists).To(Equal([]capabilities.ArtistRef{
{ID: "ar-drake", Name: "Drake", MBID: "m-drake"},
{ID: "ar-future", Name: "Future", MBID: "m-future"},
}))
})
It("leaves Artists nil when the track has no role=artist participants", func() {
mf := &model.MediaFile{ID: "x", Title: "Solo", Artist: "Drake"}
ref := mediaFileToSongRef(mf)
Expect(ref.Artists).To(BeNil())
})
})