mirror of
https://github.com/navidrome/navidrome.git
synced 2026-11-14 06:08:46 +01:00
125 lines
3.7 KiB
Go
125 lines
3.7 KiB
Go
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().GetAll(ctx, 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)
|
|
}
|
|
}
|