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>
276 lines
10 KiB
Go
276 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. SongRef keeps the single
|
|
// Artist/ArtistMBID fields as part of the plugin wire contract; when a plugin sends those instead
|
|
// of the artists array, they are folded into a one-element Artists list here.
|
|
func songRefToAgentSong(s capabilities.SongRef) agents.Song {
|
|
var artists []agents.Artist
|
|
switch {
|
|
case 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}
|
|
}
|
|
case s.Artist != "" || s.ArtistMBID != "":
|
|
artists = []agents.Artist{{Name: s.Artist, MBID: s.ArtistMBID}}
|
|
}
|
|
return agents.Song{
|
|
ID: s.ID,
|
|
Name: s.Name,
|
|
MBID: s.MBID,
|
|
ISRC: s.ISRC,
|
|
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)
|
|
)
|