navidrome/plugins/metadata_agent.go
Deluan Quintão 59a4ed8e79
fix(plugins): stop reporting plugin call failures as not-found (#5953)
* fix(plugins): stop reporting plugin call failures as not-found

MetadataAgent joined agents.ErrNotFound onto every failed plugin call, so a
transport fault was indistinguishable from a definitive miss. The artwork
circuit breaker treats a not-found as a successful, definitive answer and
resets its failure counter, so it never opened for a failing plugin and kept
calling it on every request. Observed with the apple-music plugin against
prod: ~900 iTunes 429s in 27 minutes with the breaker never tripping.

Return the underlying error instead. The genuine empty-result branches still
return agents.ErrNotFound, and agent fallback is unaffected because
callAgentMethod/callAgentSliceMethod continue on any error, not only on
ErrNotFound.

* test(plugins): fold duplicate metadata agent error specs into one table

The error-handling container drove all 11 MetadataAgent methods twice: once
to assert the message, once to assert the failure is not an ErrNotFound. The
argument lists were identical, so each method cost two WASM instantiations for
one method's worth of coverage, and a new capability had to be registered in
two places to stay guarded.

Fold both assertions into a single DescribeTable, document the ErrNotFound
contract at the sentinel where agent implementers will read it, and collapse
breaker.record's hand-inlined predicate onto isTransientExternal, which it
already duplicated by hand with a keep-in-sync comment.

* fix(plugins): keep an unimplemented plugin method a definitive miss

Returning the raw plugin error made errNotImplemented and errFunctionNotFound
look like provider faults. Every MetadataAgent satisfies ArtistImageRetriever
and AlbumImageRetriever regardless of what the plugin actually exports, so
artwork resolution calls those stubs on a partially-implemented plugin: each
call counted toward the artwork circuit breaker and kept the item in the retry
queue instead of settling it absent.

Map both sentinels back onto agents.ErrNotFound, joined so the underlying
reason survives for diagnostics, and leave real call failures untouched. This
matches what ScrobblerPlugin already does for the same two sentinels.

The partial-implementation specs asserted only MatchError(errNotImplemented),
which the previous errors.Join satisfied incidentally, so nothing caught the
lost not-found semantics. They now assert both and are folded into one table.

* test(plugins): cover the missing-export arm of agentErr

The partial-metadata-agent fixture registers through the Go PDK, which exports
every method and answers with the not-implemented code, so no fixture reaches
the errFunctionNotFound branch. Building one would mean hand-writing Extism
exports to deliberately omit a function, which tests the manager's function
lookup rather than the mapping this PR added.

Cover agentErr directly instead: both sentinels classify as a definitive miss,
a call failure and a non-zero exit stay faults, and the underlying reason
survives in every case.

* fix(artwork): stop counting a cancelled run against the circuit breaker

callPluginFunction returns ctx.Err() when a plugin call is cancelled, and that
reached breaker.record as an ordinary error, so cancellations counted toward
the five consecutive failures that open a gate. A cancellation says nothing
about the provider, so it now neither counts nor clears the failure run.

Deliberately scoped to breaker.record rather than isTransientExternal: the
latter also drives whether the queue item is rescheduled, and a cancelled item
must still be retried rather than settling absent. context.DeadlineExceeded is
left counting as a fault, since a provider that blows the budget is one worth
backing off from.

Reachable today only at shutdown, where the in-memory breaker state is
discarded anyway. It becomes live the moment Worker.gate is used on a
request-scoped context, which is why it is worth closing now.
2026-08-13 22:56:09 -04:00

286 lines
11 KiB
Go

package plugins
import (
"context"
"errors"
"github.com/navidrome/navidrome/core/agents"
"github.com/navidrome/navidrome/plugins/capabilities"
"github.com/navidrome/navidrome/plugins/types"
"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}
}
// agentErr keeps a plugin fault distinguishable from a definitive miss: a method the plugin
// simply does not implement has answered, so it must not count against a caller's back-off.
func agentErr(err error) error {
if errors.Is(err, errNotImplemented) || errors.Is(err, errFunctionNotFound) {
return errors.Join(agents.ErrNotFound, err)
}
return err
}
// 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 "", agentErr(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 "", agentErr(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 "", agentErr(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, agentErr(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, agentErr(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, agentErr(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, agentErr(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, agentErr(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, agentErr(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 types.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: s.DurationInMs(),
}
}
// songRefsToAgentSongs converts a slice of SongRef to agents.Song
func songRefsToAgentSongs(refs []types.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)
)