navidrome/plugins/metadata_agent.go
Deluan Quintão 7b7721f002
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
2026-06-26 14:46:09 -04:00

272 lines
10 KiB
Go

package plugins
import (
"context"
"errors"
"github.com/navidrome/navidrome/core/agents"
"github.com/navidrome/navidrome/plugins/capabilities"
"github.com/navidrome/navidrome/utils/slice"
)
// CapabilityMetadataAgent indicates the plugin can provide artist/album metadata.
// Detected when the plugin exports at least one of the metadata agent functions.
const CapabilityMetadataAgent Capability = "MetadataAgent"
// Export function names (snake_case as per design)
const (
FuncGetArtistMBID = "nd_get_artist_mbid"
FuncGetArtistURL = "nd_get_artist_url"
FuncGetArtistBiography = "nd_get_artist_biography"
FuncGetSimilarArtists = "nd_get_similar_artists"
FuncGetArtistImages = "nd_get_artist_images"
FuncGetArtistTopSongs = "nd_get_artist_top_songs"
FuncGetAlbumInfo = "nd_get_album_info"
FuncGetAlbumImages = "nd_get_album_images"
FuncGetSimilarSongsByTrack = "nd_get_similar_songs_by_track"
FuncGetSimilarSongsByAlbum = "nd_get_similar_songs_by_album"
FuncGetSimilarSongsByArtist = "nd_get_similar_songs_by_artist"
)
func init() {
registerCapability(
CapabilityMetadataAgent,
FuncGetArtistMBID,
FuncGetArtistURL,
FuncGetArtistBiography,
FuncGetSimilarArtists,
FuncGetArtistImages,
FuncGetArtistTopSongs,
FuncGetAlbumInfo,
FuncGetAlbumImages,
FuncGetSimilarSongsByTrack,
FuncGetSimilarSongsByAlbum,
FuncGetSimilarSongsByArtist,
)
}
func newMetadataAgent(p *plugin) *MetadataAgent {
return &MetadataAgent{name: p.name, plugin: p}
}
// MetadataAgent is an adapter that wraps an Extism plugin and implements
// the agents interfaces for metadata retrieval.
type MetadataAgent struct {
name string
plugin *plugin
}
// AgentName returns the plugin name
func (a *MetadataAgent) AgentName() string {
return a.name
}
// --- Interface implementations ---
// GetArtistMBID retrieves the MusicBrainz ID for an artist
func (a *MetadataAgent) GetArtistMBID(ctx context.Context, id string, name string) (string, error) {
input := capabilities.ArtistMBIDRequest{ID: id, Name: name}
result, err := callPluginFunction[capabilities.ArtistMBIDRequest, *capabilities.ArtistMBIDResponse](ctx, a.plugin, FuncGetArtistMBID, input)
if err != nil {
return "", errors.Join(agents.ErrNotFound, err)
}
if result == nil || result.MBID == "" {
return "", agents.ErrNotFound
}
return result.MBID, nil
}
// GetArtistURL retrieves the external URL for an artist
func (a *MetadataAgent) GetArtistURL(ctx context.Context, id, name, mbid string) (string, error) {
input := capabilities.ArtistRequest{ID: id, Name: name, MBID: mbid}
result, err := callPluginFunction[capabilities.ArtistRequest, *capabilities.ArtistURLResponse](ctx, a.plugin, FuncGetArtistURL, input)
if err != nil {
return "", errors.Join(agents.ErrNotFound, err)
}
if result == nil || result.URL == "" {
return "", agents.ErrNotFound
}
return result.URL, nil
}
// GetArtistBiography retrieves the biography for an artist
func (a *MetadataAgent) GetArtistBiography(ctx context.Context, id, name, mbid string) (string, error) {
input := capabilities.ArtistRequest{ID: id, Name: name, MBID: mbid}
result, err := callPluginFunction[capabilities.ArtistRequest, *capabilities.ArtistBiographyResponse](ctx, a.plugin, FuncGetArtistBiography, input)
if err != nil {
return "", errors.Join(agents.ErrNotFound, err)
}
if result == nil || result.Biography == "" {
return "", agents.ErrNotFound
}
return result.Biography, nil
}
// GetSimilarArtists retrieves similar artists
func (a *MetadataAgent) GetSimilarArtists(ctx context.Context, id, name, mbid string, limit int) ([]agents.Artist, error) {
input := capabilities.SimilarArtistsRequest{ID: id, Name: name, MBID: mbid, Limit: int32(limit)}
result, err := callPluginFunction[capabilities.SimilarArtistsRequest, *capabilities.SimilarArtistsResponse](ctx, a.plugin, FuncGetSimilarArtists, input)
if err != nil {
return nil, errors.Join(agents.ErrNotFound, err)
}
if result == nil || len(result.Artists) == 0 {
return nil, agents.ErrNotFound
}
artists := make([]agents.Artist, len(result.Artists))
for i, ar := range result.Artists {
artists[i] = agents.Artist{ID: ar.ID, Name: ar.Name, MBID: ar.MBID}
}
return artists, nil
}
// GetArtistImages retrieves images for an artist
func (a *MetadataAgent) GetArtistImages(ctx context.Context, id, name, mbid string) ([]agents.ExternalImage, error) {
input := capabilities.ArtistRequest{ID: id, Name: name, MBID: mbid}
result, err := callPluginFunction[capabilities.ArtistRequest, *capabilities.ArtistImagesResponse](ctx, a.plugin, FuncGetArtistImages, input)
if err != nil {
return nil, errors.Join(agents.ErrNotFound, err)
}
if result == nil || len(result.Images) == 0 {
return nil, agents.ErrNotFound
}
images := make([]agents.ExternalImage, len(result.Images))
for i, img := range result.Images {
images[i] = agents.ExternalImage{URL: img.URL, Size: int(img.Size)}
}
return images, nil
}
// GetArtistTopSongs retrieves top songs for an artist
func (a *MetadataAgent) GetArtistTopSongs(ctx context.Context, id, artistName, mbid string, count int) ([]agents.Song, error) {
input := capabilities.TopSongsRequest{ID: id, Name: artistName, MBID: mbid, Count: int32(count)}
result, err := callPluginFunction[capabilities.TopSongsRequest, *capabilities.TopSongsResponse](ctx, a.plugin, FuncGetArtistTopSongs, input)
if err != nil {
return nil, errors.Join(agents.ErrNotFound, err)
}
if result == nil || len(result.Songs) == 0 {
return nil, agents.ErrNotFound
}
return songRefsToAgentSongs(result.Songs), nil
}
// GetAlbumInfo retrieves album information
func (a *MetadataAgent) GetAlbumInfo(ctx context.Context, name, artist, mbid string) (*agents.AlbumInfo, error) {
input := capabilities.AlbumRequest{Name: name, Artist: artist, MBID: mbid}
result, err := callPluginFunction[capabilities.AlbumRequest, *capabilities.AlbumInfoResponse](ctx, a.plugin, FuncGetAlbumInfo, input)
if err != nil {
return nil, errors.Join(agents.ErrNotFound, err)
}
if result == nil {
return nil, agents.ErrNotFound
}
return &agents.AlbumInfo{
Name: result.Name,
MBID: result.MBID,
Description: result.Description,
URL: result.URL,
}, nil
}
// GetAlbumImages retrieves images for an album
func (a *MetadataAgent) GetAlbumImages(ctx context.Context, name, artist, mbid string) ([]agents.ExternalImage, error) {
input := capabilities.AlbumRequest{Name: name, Artist: artist, MBID: mbid}
result, err := callPluginFunction[capabilities.AlbumRequest, *capabilities.AlbumImagesResponse](ctx, a.plugin, FuncGetAlbumImages, input)
if err != nil {
return nil, errors.Join(agents.ErrNotFound, err)
}
if result == nil || len(result.Images) == 0 {
return nil, agents.ErrNotFound
}
images := make([]agents.ExternalImage, len(result.Images))
for i, img := range result.Images {
images[i] = agents.ExternalImage{URL: img.URL, Size: int(img.Size)}
}
return images, nil
}
func callSimilarSongsPluginFunction[T any](ctx context.Context, plugin *plugin, funcName string, input T) ([]agents.Song, error) {
result, err := callPluginFunction[T, *capabilities.SimilarSongsResponse](ctx, plugin, funcName, input)
if err != nil {
return nil, err
}
if result == nil || len(result.Songs) == 0 {
return nil, agents.ErrNotFound
}
return songRefsToAgentSongs(result.Songs), nil
}
// GetSimilarSongsByTrack retrieves songs similar to a specific track
func (a *MetadataAgent) GetSimilarSongsByTrack(ctx context.Context, id, name, artist, mbid string, count int) ([]agents.Song, error) {
return callSimilarSongsPluginFunction[capabilities.SimilarSongsByTrackRequest](ctx, a.plugin, FuncGetSimilarSongsByTrack, capabilities.SimilarSongsByTrackRequest{ID: id, Name: name, Artist: artist, MBID: mbid, Count: int32(count)})
}
// GetSimilarSongsByAlbum retrieves songs similar to tracks on an album
func (a *MetadataAgent) GetSimilarSongsByAlbum(ctx context.Context, id, name, artist, mbid string, count int) ([]agents.Song, error) {
return callSimilarSongsPluginFunction[capabilities.SimilarSongsByAlbumRequest](ctx, a.plugin, FuncGetSimilarSongsByAlbum, capabilities.SimilarSongsByAlbumRequest{ID: id, Name: name, Artist: artist, MBID: mbid, Count: int32(count)})
}
// GetSimilarSongsByArtist retrieves songs similar to an artist's catalog
func (a *MetadataAgent) GetSimilarSongsByArtist(ctx context.Context, id, name, mbid string, count int) ([]agents.Song, error) {
return callSimilarSongsPluginFunction[capabilities.SimilarSongsByArtistRequest](ctx, a.plugin, FuncGetSimilarSongsByArtist, capabilities.SimilarSongsByArtistRequest{ID: id, Name: name, MBID: mbid, Count: int32(count)})
}
// 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,
MBID: s.MBID,
ISRC: s.ISRC,
Artist: s.Artist,
ArtistMBID: s.ArtistMBID,
Artists: artists,
Album: s.Album,
AlbumMBID: s.AlbumMBID,
Duration: uint32(s.Duration * 1000),
}
}
// songRefsToAgentSongs converts a slice of SongRef to agents.Song
func songRefsToAgentSongs(refs []capabilities.SongRef) []agents.Song {
return slice.Map(refs, songRefToAgentSong)
}
// Verify interface implementations at compile time
var (
_ agents.Interface = (*MetadataAgent)(nil)
_ agents.ArtistMBIDRetriever = (*MetadataAgent)(nil)
_ agents.ArtistURLRetriever = (*MetadataAgent)(nil)
_ agents.ArtistBiographyRetriever = (*MetadataAgent)(nil)
_ agents.ArtistSimilarRetriever = (*MetadataAgent)(nil)
_ agents.ArtistImageRetriever = (*MetadataAgent)(nil)
_ agents.ArtistTopSongsRetriever = (*MetadataAgent)(nil)
_ agents.AlbumInfoRetriever = (*MetadataAgent)(nil)
_ agents.AlbumImageRetriever = (*MetadataAgent)(nil)
_ agents.SimilarSongsByTrackRetriever = (*MetadataAgent)(nil)
_ agents.SimilarSongsByAlbumRetriever = (*MetadataAgent)(nil)
_ agents.SimilarSongsByArtistRetriever = (*MetadataAgent)(nil)
)