package lyrics import ( "context" "strings" . "github.com/Masterminds/squirrel" "github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/persistence" ) // maxLegacyLyricsCandidates bounds the duplicate window scanned by the legacy // artist/title lookup, so source-priority resolution can still reach older // matches without turning it into an unbounded table scan. const maxLegacyLyricsCandidates = 10 // Provider fetches lyrics for a single media file. It is the contract // implemented by individual lyrics sources, such as plugins. type Provider interface { GetLyrics(ctx context.Context, mf *model.MediaFile) (model.LyricList, error) } // Lyrics resolves lyrics for media files, honoring the configured source // priority. type Lyrics interface { Provider GetLyricsByArtistTitle(ctx context.Context, artist, title string) (model.LyricList, error) } // PluginLoader discovers and loads lyrics provider plugins. type PluginLoader interface { LoadLyricsProvider(name string) (Provider, bool) } type lyricsService struct { ds model.DataStore pluginLoader PluginLoader } // NewLyrics creates a new lyrics service. pluginLoader may be nil if no plugin // system is available. func NewLyrics(ds model.DataStore, pluginLoader PluginLoader) Lyrics { return &lyricsService{ds: ds, pluginLoader: pluginLoader} } // GetLyrics returns lyrics for the given media file, trying sources in the // order specified by conf.Server.LyricsPriority. func (l *lyricsService) GetLyrics(ctx context.Context, mf *model.MediaFile) (model.LyricList, error) { return l.getLyricsForCandidates(ctx, []*model.MediaFile{mf}) } // GetLyricsByArtistTitle resolves lyrics for the legacy artist/title lookup, // scanning a bounded window of duplicate matches so source priority still wins // across them. func (l *lyricsService) GetLyricsByArtistTitle(ctx context.Context, artist, title string) (model.LyricList, error) { opts := songsByArtistTitleWithLyricsFirst(artist, title) opts.Max = maxLegacyLyricsCandidates mediaFiles, err := l.ds.MediaFile(ctx).GetAll(opts) if err != nil { return nil, err } if len(mediaFiles) == 0 { return nil, nil } candidates := make([]*model.MediaFile, 0, len(mediaFiles)) for i := range mediaFiles { candidates = append(candidates, &mediaFiles[i]) } return l.getLyricsForCandidates(ctx, candidates) } func songsByArtistTitleWithLyricsFirst(artist, title string) model.QueryOptions { return model.QueryOptions{ Sort: "lyrics, updated_at", Order: "desc", Filters: And{ Eq{"missing": false}, Eq{"title": title}, Or{ persistence.Exists("json_tree(participants, '$.albumartist')", Eq{"value": artist}), persistence.Exists("json_tree(participants, '$.artist')", Eq{"value": artist}), }, }, } } func (l *lyricsService) getLyricsForCandidates(ctx context.Context, mediaFiles []*model.MediaFile) (model.LyricList, error) { for pattern := range strings.SplitSeq(conf.Server.LyricsPriority, ",") { pattern = strings.TrimSpace(pattern) if pattern == "" { continue } for _, mf := range mediaFiles { if mf == nil { continue } lyricsList, err := l.getLyricsFromSource(ctx, mf, pattern) if err != nil { log.Error(ctx, "error getting lyrics", "source", pattern, err) continue } if len(lyricsList) > 0 { return lyricsList, nil } } } return nil, nil } func (l *lyricsService) getLyricsFromSource(ctx context.Context, mf *model.MediaFile, pattern string) (model.LyricList, error) { switch { case strings.EqualFold(pattern, "embedded"): return fromEmbedded(ctx, mf) case strings.HasPrefix(pattern, "."): return fromExternalFile(ctx, mf, pattern) default: return l.fromPlugin(ctx, mf, pattern) } }