mirror of
https://github.com/navidrome/navidrome.git
synced 2025-07-14 07:31:28 +03:00
* fix(provider): ensure that similar artists retreived from provider are no more than limit * add overlimit multiplier
425 lines
11 KiB
Go
425 lines
11 KiB
Go
package agents
|
|
|
|
import (
|
|
"context"
|
|
"slices"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/navidrome/navidrome/conf"
|
|
"github.com/navidrome/navidrome/consts"
|
|
"github.com/navidrome/navidrome/log"
|
|
"github.com/navidrome/navidrome/model"
|
|
"github.com/navidrome/navidrome/utils"
|
|
"github.com/navidrome/navidrome/utils/singleton"
|
|
)
|
|
|
|
// PluginLoader defines an interface for loading plugins
|
|
type PluginLoader interface {
|
|
// PluginNames returns the names of all plugins that implement a particular service
|
|
PluginNames(serviceName string) []string
|
|
// LoadMediaAgent loads and returns a media agent plugin
|
|
LoadMediaAgent(name string) (Interface, bool)
|
|
}
|
|
|
|
type cachedAgent struct {
|
|
agent Interface
|
|
expiration time.Time
|
|
}
|
|
|
|
// Encapsulates agent caching logic
|
|
// agentCache is a simple TTL cache for agents
|
|
// Not exported, only used by Agents
|
|
|
|
type agentCache struct {
|
|
mu sync.Mutex
|
|
items map[string]cachedAgent
|
|
ttl time.Duration
|
|
}
|
|
|
|
// TTL for cached agents
|
|
const agentCacheTTL = 5 * time.Minute
|
|
|
|
func newAgentCache(ttl time.Duration) *agentCache {
|
|
return &agentCache{
|
|
items: make(map[string]cachedAgent),
|
|
ttl: ttl,
|
|
}
|
|
}
|
|
|
|
func (c *agentCache) Get(name string) Interface {
|
|
c.mu.Lock()
|
|
defer c.mu.Unlock()
|
|
cached, ok := c.items[name]
|
|
if ok && cached.expiration.After(time.Now()) {
|
|
return cached.agent
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (c *agentCache) Set(name string, agent Interface) {
|
|
c.mu.Lock()
|
|
defer c.mu.Unlock()
|
|
c.items[name] = cachedAgent{
|
|
agent: agent,
|
|
expiration: time.Now().Add(c.ttl),
|
|
}
|
|
}
|
|
|
|
type Agents struct {
|
|
ds model.DataStore
|
|
pluginLoader PluginLoader
|
|
cache *agentCache
|
|
}
|
|
|
|
// GetAgents returns the singleton instance of Agents
|
|
func GetAgents(ds model.DataStore, pluginLoader PluginLoader) *Agents {
|
|
return singleton.GetInstance(func() *Agents {
|
|
return createAgents(ds, pluginLoader)
|
|
})
|
|
}
|
|
|
|
// createAgents creates a new Agents instance. Used in tests
|
|
func createAgents(ds model.DataStore, pluginLoader PluginLoader) *Agents {
|
|
return &Agents{
|
|
ds: ds,
|
|
pluginLoader: pluginLoader,
|
|
cache: newAgentCache(agentCacheTTL),
|
|
}
|
|
}
|
|
|
|
// getEnabledAgentNames returns the current list of enabled agent names, including:
|
|
// 1. Built-in agents and plugins from config (in the specified order)
|
|
// 2. Always include LocalAgentName
|
|
// 3. If config is empty, include ONLY LocalAgentName
|
|
func (a *Agents) getEnabledAgentNames() []string {
|
|
// If no agents configured, ONLY use the local agent
|
|
if conf.Server.Agents == "" {
|
|
return []string{LocalAgentName}
|
|
}
|
|
|
|
// Get all available plugin names
|
|
var availablePlugins []string
|
|
if a.pluginLoader != nil {
|
|
availablePlugins = a.pluginLoader.PluginNames("MetadataAgent")
|
|
}
|
|
|
|
configuredAgents := strings.Split(conf.Server.Agents, ",")
|
|
|
|
// Always add LocalAgentName if not already included
|
|
hasLocalAgent := false
|
|
for _, name := range configuredAgents {
|
|
if name == LocalAgentName {
|
|
hasLocalAgent = true
|
|
break
|
|
}
|
|
}
|
|
if !hasLocalAgent {
|
|
configuredAgents = append(configuredAgents, LocalAgentName)
|
|
}
|
|
|
|
// Filter to only include valid agents (built-in or plugins)
|
|
var validNames []string
|
|
for _, name := range configuredAgents {
|
|
// Check if it's a built-in agent
|
|
isBuiltIn := Map[name] != nil
|
|
|
|
// Check if it's a plugin
|
|
isPlugin := slices.Contains(availablePlugins, name)
|
|
|
|
if isBuiltIn || isPlugin {
|
|
validNames = append(validNames, name)
|
|
} else {
|
|
log.Warn("Unknown agent ignored", "name", name)
|
|
}
|
|
}
|
|
return validNames
|
|
}
|
|
|
|
func (a *Agents) getAgent(name string) Interface {
|
|
// Check cache first
|
|
agent := a.cache.Get(name)
|
|
if agent != nil {
|
|
return agent
|
|
}
|
|
|
|
// Try to get built-in agent
|
|
constructor, ok := Map[name]
|
|
if ok {
|
|
agent := constructor(a.ds)
|
|
if agent != nil {
|
|
a.cache.Set(name, agent)
|
|
return agent
|
|
}
|
|
log.Debug("Built-in agent not available. Missing configuration?", "name", name)
|
|
}
|
|
|
|
// Try to load WASM plugin agent (if plugin loader is available)
|
|
if a.pluginLoader != nil {
|
|
agent, ok := a.pluginLoader.LoadMediaAgent(name)
|
|
if ok && agent != nil {
|
|
a.cache.Set(name, agent)
|
|
return agent
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func (a *Agents) AgentName() string {
|
|
return "agents"
|
|
}
|
|
|
|
func (a *Agents) GetArtistMBID(ctx context.Context, id string, name string) (string, error) {
|
|
switch id {
|
|
case consts.UnknownArtistID:
|
|
return "", ErrNotFound
|
|
case consts.VariousArtistsID:
|
|
return "", nil
|
|
}
|
|
start := time.Now()
|
|
for _, agentName := range a.getEnabledAgentNames() {
|
|
ag := a.getAgent(agentName)
|
|
if ag == nil {
|
|
continue
|
|
}
|
|
if utils.IsCtxDone(ctx) {
|
|
break
|
|
}
|
|
retriever, ok := ag.(ArtistMBIDRetriever)
|
|
if !ok {
|
|
continue
|
|
}
|
|
mbid, err := retriever.GetArtistMBID(ctx, id, name)
|
|
if mbid != "" && err == nil {
|
|
log.Debug(ctx, "Got MBID", "agent", ag.AgentName(), "artist", name, "mbid", mbid, "elapsed", time.Since(start))
|
|
return mbid, nil
|
|
}
|
|
}
|
|
return "", ErrNotFound
|
|
}
|
|
|
|
func (a *Agents) GetArtistURL(ctx context.Context, id, name, mbid string) (string, error) {
|
|
switch id {
|
|
case consts.UnknownArtistID:
|
|
return "", ErrNotFound
|
|
case consts.VariousArtistsID:
|
|
return "", nil
|
|
}
|
|
start := time.Now()
|
|
for _, agentName := range a.getEnabledAgentNames() {
|
|
ag := a.getAgent(agentName)
|
|
if ag == nil {
|
|
continue
|
|
}
|
|
if utils.IsCtxDone(ctx) {
|
|
break
|
|
}
|
|
retriever, ok := ag.(ArtistURLRetriever)
|
|
if !ok {
|
|
continue
|
|
}
|
|
url, err := retriever.GetArtistURL(ctx, id, name, mbid)
|
|
if url != "" && err == nil {
|
|
log.Debug(ctx, "Got External Url", "agent", ag.AgentName(), "artist", name, "url", url, "elapsed", time.Since(start))
|
|
return url, nil
|
|
}
|
|
}
|
|
return "", ErrNotFound
|
|
}
|
|
|
|
func (a *Agents) GetArtistBiography(ctx context.Context, id, name, mbid string) (string, error) {
|
|
switch id {
|
|
case consts.UnknownArtistID:
|
|
return "", ErrNotFound
|
|
case consts.VariousArtistsID:
|
|
return "", nil
|
|
}
|
|
start := time.Now()
|
|
for _, agentName := range a.getEnabledAgentNames() {
|
|
ag := a.getAgent(agentName)
|
|
if ag == nil {
|
|
continue
|
|
}
|
|
if utils.IsCtxDone(ctx) {
|
|
break
|
|
}
|
|
retriever, ok := ag.(ArtistBiographyRetriever)
|
|
if !ok {
|
|
continue
|
|
}
|
|
bio, err := retriever.GetArtistBiography(ctx, id, name, mbid)
|
|
if err == nil {
|
|
log.Debug(ctx, "Got Biography", "agent", ag.AgentName(), "artist", name, "len", len(bio), "elapsed", time.Since(start))
|
|
return bio, nil
|
|
}
|
|
}
|
|
return "", ErrNotFound
|
|
}
|
|
|
|
// GetSimilarArtists returns similar artists by id, name, and/or mbid. Because some artists returned from an enabled
|
|
// agent may not exist in the database, return at most limit * conf.Server.DevExternalArtistFetchMultiplier items.
|
|
func (a *Agents) GetSimilarArtists(ctx context.Context, id, name, mbid string, limit int) ([]Artist, error) {
|
|
switch id {
|
|
case consts.UnknownArtistID:
|
|
return nil, ErrNotFound
|
|
case consts.VariousArtistsID:
|
|
return nil, nil
|
|
}
|
|
|
|
overLimit := int(float64(limit) * conf.Server.DevExternalArtistFetchMultiplier)
|
|
|
|
start := time.Now()
|
|
for _, agentName := range a.getEnabledAgentNames() {
|
|
ag := a.getAgent(agentName)
|
|
if ag == nil {
|
|
continue
|
|
}
|
|
if utils.IsCtxDone(ctx) {
|
|
break
|
|
}
|
|
retriever, ok := ag.(ArtistSimilarRetriever)
|
|
if !ok {
|
|
continue
|
|
}
|
|
similar, err := retriever.GetSimilarArtists(ctx, id, name, mbid, overLimit)
|
|
if len(similar) > 0 && err == nil {
|
|
if log.IsGreaterOrEqualTo(log.LevelTrace) {
|
|
log.Debug(ctx, "Got Similar Artists", "agent", ag.AgentName(), "artist", name, "similar", similar, "elapsed", time.Since(start))
|
|
} else {
|
|
log.Debug(ctx, "Got Similar Artists", "agent", ag.AgentName(), "artist", name, "similarReceived", len(similar), "elapsed", time.Since(start))
|
|
}
|
|
return similar, err
|
|
}
|
|
}
|
|
return nil, ErrNotFound
|
|
}
|
|
|
|
func (a *Agents) GetArtistImages(ctx context.Context, id, name, mbid string) ([]ExternalImage, error) {
|
|
switch id {
|
|
case consts.UnknownArtistID:
|
|
return nil, ErrNotFound
|
|
case consts.VariousArtistsID:
|
|
return nil, nil
|
|
}
|
|
start := time.Now()
|
|
for _, agentName := range a.getEnabledAgentNames() {
|
|
ag := a.getAgent(agentName)
|
|
if ag == nil {
|
|
continue
|
|
}
|
|
if utils.IsCtxDone(ctx) {
|
|
break
|
|
}
|
|
retriever, ok := ag.(ArtistImageRetriever)
|
|
if !ok {
|
|
continue
|
|
}
|
|
images, err := retriever.GetArtistImages(ctx, id, name, mbid)
|
|
if len(images) > 0 && err == nil {
|
|
log.Debug(ctx, "Got Images", "agent", ag.AgentName(), "artist", name, "images", images, "elapsed", time.Since(start))
|
|
return images, nil
|
|
}
|
|
}
|
|
return nil, ErrNotFound
|
|
}
|
|
|
|
// GetArtistTopSongs returns top songs by id, name, and/or mbid. Because some songs returned from an enabled
|
|
// agent may not exist in the database, return at most limit * conf.Server.DevExternalArtistFetchMultiplier items.
|
|
func (a *Agents) GetArtistTopSongs(ctx context.Context, id, artistName, mbid string, count int) ([]Song, error) {
|
|
switch id {
|
|
case consts.UnknownArtistID:
|
|
return nil, ErrNotFound
|
|
case consts.VariousArtistsID:
|
|
return nil, nil
|
|
}
|
|
|
|
overLimit := int(float64(count) * conf.Server.DevExternalArtistFetchMultiplier)
|
|
|
|
start := time.Now()
|
|
for _, agentName := range a.getEnabledAgentNames() {
|
|
ag := a.getAgent(agentName)
|
|
if ag == nil {
|
|
continue
|
|
}
|
|
if utils.IsCtxDone(ctx) {
|
|
break
|
|
}
|
|
retriever, ok := ag.(ArtistTopSongsRetriever)
|
|
if !ok {
|
|
continue
|
|
}
|
|
songs, err := retriever.GetArtistTopSongs(ctx, id, artistName, mbid, overLimit)
|
|
if len(songs) > 0 && err == nil {
|
|
log.Debug(ctx, "Got Top Songs", "agent", ag.AgentName(), "artist", artistName, "songs", songs, "elapsed", time.Since(start))
|
|
return songs, nil
|
|
}
|
|
}
|
|
return nil, ErrNotFound
|
|
}
|
|
|
|
func (a *Agents) GetAlbumInfo(ctx context.Context, name, artist, mbid string) (*AlbumInfo, error) {
|
|
if name == consts.UnknownAlbum {
|
|
return nil, ErrNotFound
|
|
}
|
|
start := time.Now()
|
|
for _, agentName := range a.getEnabledAgentNames() {
|
|
ag := a.getAgent(agentName)
|
|
if ag == nil {
|
|
continue
|
|
}
|
|
if utils.IsCtxDone(ctx) {
|
|
break
|
|
}
|
|
retriever, ok := ag.(AlbumInfoRetriever)
|
|
if !ok {
|
|
continue
|
|
}
|
|
album, err := retriever.GetAlbumInfo(ctx, name, artist, mbid)
|
|
if err == nil {
|
|
log.Debug(ctx, "Got Album Info", "agent", ag.AgentName(), "album", name, "artist", artist,
|
|
"mbid", mbid, "elapsed", time.Since(start))
|
|
return album, nil
|
|
}
|
|
}
|
|
return nil, ErrNotFound
|
|
}
|
|
|
|
func (a *Agents) GetAlbumImages(ctx context.Context, name, artist, mbid string) ([]ExternalImage, error) {
|
|
if name == consts.UnknownAlbum {
|
|
return nil, ErrNotFound
|
|
}
|
|
start := time.Now()
|
|
for _, agentName := range a.getEnabledAgentNames() {
|
|
ag := a.getAgent(agentName)
|
|
if ag == nil {
|
|
continue
|
|
}
|
|
if utils.IsCtxDone(ctx) {
|
|
break
|
|
}
|
|
retriever, ok := ag.(AlbumImageRetriever)
|
|
if !ok {
|
|
continue
|
|
}
|
|
images, err := retriever.GetAlbumImages(ctx, name, artist, mbid)
|
|
if len(images) > 0 && err == nil {
|
|
log.Debug(ctx, "Got Album Images", "agent", ag.AgentName(), "album", name, "artist", artist,
|
|
"mbid", mbid, "elapsed", time.Since(start))
|
|
return images, nil
|
|
}
|
|
}
|
|
return nil, ErrNotFound
|
|
}
|
|
|
|
var _ Interface = (*Agents)(nil)
|
|
var _ ArtistMBIDRetriever = (*Agents)(nil)
|
|
var _ ArtistURLRetriever = (*Agents)(nil)
|
|
var _ ArtistBiographyRetriever = (*Agents)(nil)
|
|
var _ ArtistSimilarRetriever = (*Agents)(nil)
|
|
var _ ArtistImageRetriever = (*Agents)(nil)
|
|
var _ ArtistTopSongsRetriever = (*Agents)(nil)
|
|
var _ AlbumInfoRetriever = (*Agents)(nil)
|
|
var _ AlbumImageRetriever = (*Agents)(nil)
|