mirror of
https://github.com/navidrome/navidrome.git
synced 2026-10-08 02:17:25 +02:00
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:
parent
63a5954e4f
commit
7b7721f002
21 changed files with 763 additions and 163 deletions
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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":
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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"))
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
})
|
||||
})
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue