This commit is contained in:
Kendall Garner 2024-04-27 22:10:35 +02:00 committed by GitHub
commit e5bd04cf02
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
26 changed files with 1095 additions and 4 deletions

View File

@ -105,6 +105,7 @@ type configOptions struct {
DevArtworkThrottleBacklogTimeout time.Duration
DevArtistInfoTimeToLive time.Duration
DevAlbumInfoTimeToLive time.Duration
DevLyricsTimeToLive time.Duration
}
type scannerOptions struct {
@ -230,7 +231,7 @@ func disableExternalServices() {
Server.LastFM.Enabled = false
Server.Spotify.ID = ""
Server.ListenBrainz.Enabled = false
Server.Agents = ""
Server.Agents = "filesystem"
if Server.UILoginBackgroundURL == consts.DefaultUILoginBackgroundURL {
Server.UILoginBackgroundURL = consts.DefaultUILoginBackgroundURLOffline
}
@ -340,7 +341,7 @@ func init() {
viper.SetDefault("scanner.genreseparators", ";/,")
viper.SetDefault("scanner.groupalbumreleases", false)
viper.SetDefault("agents", "lastfm,spotify")
viper.SetDefault("agents", "filesystem,lastfm,spotify,lrclib")
viper.SetDefault("lastfm.enabled", true)
viper.SetDefault("lastfm.language", "en")
viper.SetDefault("lastfm.apikey", "")
@ -367,6 +368,7 @@ func init() {
viper.SetDefault("devartworkthrottlebacklogtimeout", consts.RequestThrottleBacklogTimeout)
viper.SetDefault("devartistinfotimetolive", consts.ArtistInfoTimeToLive)
viper.SetDefault("devalbuminfotimetolive", consts.AlbumInfoTimeToLive)
viper.SetDefault("devlyricstimetolive", consts.LyricsInfoTimeToLive)
}
func InitConfig(cfgFile string) {

View File

@ -52,6 +52,7 @@ const (
ArtistInfoTimeToLive = 24 * time.Hour
AlbumInfoTimeToLive = 7 * 24 * time.Hour
LyricsInfoTimeToLive = 30 * 24 * time.Hour
I18nFolder = "i18n"
SkipScanFile = ".ndignore"

View File

@ -22,7 +22,18 @@ func New(ds model.DataStore) *Agents {
if conf.Server.Agents != "" {
order = strings.Split(conf.Server.Agents, ",")
}
order = append(order, LocalAgentName)
hasLocal := false
for _, agent := range order {
if agent == LocalAgentName {
hasLocal = true
break
}
}
if !hasLocal {
order = append(order, LocalAgentName)
}
var res []Interface
for _, name := range order {
init, ok := Map[name]
@ -218,6 +229,25 @@ func (a *Agents) GetAlbumInfo(ctx context.Context, name, artist, mbid string) (*
return nil, ErrNotFound
}
func (a *Agents) GetSongLyrics(ctx context.Context, mf *model.MediaFile) (model.LyricList, error) {
start := time.Now()
for _, ag := range a.agents {
if utils.IsCtxDone(ctx) {
break
}
agent, ok := ag.(LyricsRetriever)
if !ok {
continue
}
lyrics, err := agent.GetSongLyrics(ctx, mf)
if err == nil && (lyrics == nil || len(lyrics) > 0) {
log.Debug(ctx, "Got lyrics", "agent", ag.AgentName(), "id", mf.ID, "elapsed", time.Since(start))
return lyrics, nil
}
}
return nil, ErrNotFound
}
var _ Interface = (*Agents)(nil)
var _ ArtistMBIDRetriever = (*Agents)(nil)
var _ ArtistURLRetriever = (*Agents)(nil)
@ -226,3 +256,4 @@ var _ ArtistSimilarRetriever = (*Agents)(nil)
var _ ArtistImageRetriever = (*Agents)(nil)
var _ ArtistTopSongsRetriever = (*Agents)(nil)
var _ AlbumInfoRetriever = (*Agents)(nil)
var _ LyricsRetriever = (*Agents)(nil)

View File

@ -39,6 +39,30 @@ var _ = Describe("Agents", func() {
})
})
Describe("Settings", func() {
It("does not duplicate local agent", func() {
conf.Server.Agents = LocalAgentName
ag := New(ds)
Expect(ag.agents).To(HaveLen(1))
Expect(ag.agents[0].AgentName()).To(Equal(LocalAgentName))
})
It("uses orders local correctly", func() {
for _, agent := range []string{"lastfm", "spotify", "lrclib"} {
Register(agent, func(ds model.DataStore) Interface {
return struct {
Interface
}{}
})
}
conf.Server.Agents = "lastfm,spotify,local,lrclib"
ag := New(ds)
Expect(ag.agents).To(HaveLen(4))
Expect(ag.agents[2].AgentName()).To(Equal(LocalAgentName))
})
})
Describe("Agents", func() {
var ag *Agents
var mock *mockAgent

View File

@ -0,0 +1,96 @@
package filesystem
import (
"context"
"fmt"
"os"
"path/filepath"
"strings"
"github.com/Masterminds/squirrel"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/consts"
"github.com/navidrome/navidrome/core/agents"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/utils"
)
const filesystemAgentName = "filesystem"
var (
supportedExtensions = []string{"lrc", "txt"}
)
type filesystemAgent struct {
ds model.DataStore
}
func filesystemConstructor(ds model.DataStore) *filesystemAgent {
return &filesystemAgent{
ds: ds,
}
}
func (f *filesystemAgent) AgentName() string {
return filesystemAgentName
}
func (f *filesystemAgent) GetArtistBiography(ctx context.Context, id, name, mbid string) (string, error) {
_, err := f.ds.Artist(ctx).Get(id)
if err != nil {
return "", err
}
als, err := f.ds.Album(ctx).GetAll(model.QueryOptions{Filters: squirrel.Eq{"album_artist_id": id}})
if err != nil {
return "", err
}
var paths []string
for _, al := range als {
paths = append(paths, strings.Split(al.Paths, consts.Zwsp)...)
}
artistFolder := utils.LongestCommonPrefix(paths)
if !strings.HasSuffix(artistFolder, string(filepath.Separator)) {
artistFolder, _ = filepath.Split(artistFolder)
}
artistBioPath := filepath.Join(artistFolder, "artist.txt")
contents, err := os.ReadFile(artistBioPath)
if err != nil {
return "", agents.ErrNotFound
}
return string(contents), nil
}
func (f *filesystemAgent) GetSongLyrics(ctx context.Context, mf *model.MediaFile) (model.LyricList, error) {
lyrics := model.LyricList{}
extension := filepath.Ext(mf.Path)
basePath := mf.Path[0 : len(mf.Path)-len(extension)]
for _, ext := range supportedExtensions {
lrcPath := fmt.Sprintf("%s.%s", basePath, ext)
contents, err := os.ReadFile(lrcPath)
if err != nil {
continue
}
lyric, err := model.ToLyrics("xxx", string(contents))
if err != nil {
return nil, err
}
lyrics = append(lyrics, *lyric)
}
return lyrics, nil
}
func init() {
conf.AddHook(func() {
agents.Register(filesystemAgentName, func(ds model.DataStore) agents.Interface {
return filesystemConstructor(ds)
})
})
}
var _ agents.ArtistBiographyRetriever = (*filesystemAgent)(nil)
var _ agents.LyricsRetriever = (*filesystemAgent)(nil)

View File

@ -0,0 +1,146 @@
package filesystem
import (
"context"
"github.com/navidrome/navidrome/core/agents"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/tests"
. "github.com/navidrome/navidrome/utils/gg"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
var _ = Describe("localAgent", func() {
var ds model.DataStore
var ctx context.Context
var agent *filesystemAgent
BeforeEach(func() {
ds = &tests.MockDataStore{}
ctx = context.Background()
agent = filesystemConstructor(ds)
})
Describe("GetArtistBiography", func() {
BeforeEach(func() {
ds.Artist(ctx).(*tests.MockArtistRepo).SetData(model.Artists{
model.Artist{ID: "ar-1234",
Name: "artist"},
})
})
It("should fetch artist biography", func() {
ds.Album(ctx).(*tests.MockAlbumRepo).SetData(model.Albums{
model.Album{ID: "al-1234",
AlbumArtistID: "ar-1234",
Name: "album",
Paths: "tests/fixtures/artist/an-album",
},
})
bio, err := agent.GetArtistBiography(ctx, "ar-1234", "album", "")
Expect(err).ToNot(HaveOccurred())
Expect(bio).To(Equal("This is an artist biography"))
})
It("should fetch artist biography with slash", func() {
ds.Album(ctx).(*tests.MockAlbumRepo).SetData(model.Albums{
model.Album{ID: "al-1234",
AlbumArtistID: "ar-1234",
Name: "album",
Paths: "tests/fixtures/artist/",
},
})
bio, err := agent.GetArtistBiography(ctx, "ar-1234", "album", "")
Expect(err).ToNot(HaveOccurred())
Expect(bio).To(Equal("This is an artist biography"))
})
It("should error when file doesn't exist", func() {
ds.Album(ctx).(*tests.MockAlbumRepo).SetData(model.Albums{
model.Album{ID: "al-1234",
AlbumArtistID: "ar-1234",
Name: "album",
Paths: "tests/fixtures/fake-artist/fake-album",
},
})
bio, err := agent.GetArtistBiography(ctx, "ar-1234", "album", "")
Expect(err).To(Equal(agents.ErrNotFound))
Expect(bio).To(Equal(""))
})
})
Describe("GetSongLyrics", func() {
It("should parse LRC file", func() {
mf := model.MediaFile{
Path: "tests/fixtures/01 Invisible (RED) Edit Version.mp3",
}
lyrics, err := agent.GetSongLyrics(ctx, &mf)
Expect(err).ToNot(HaveOccurred())
Expect(lyrics).To(Equal(model.LyricList{
{
DisplayArtist: "",
DisplayTitle: "",
Lang: "xxx",
Line: []model.Line{
{Start: P(int64(0)), Value: "Line 1"},
{Start: P(int64(5210)), Value: "Line 2"},
{Start: P(int64(12450)), Value: "Line 3"},
},
Offset: nil,
Synced: true,
},
}))
})
It("should parse both LRC and TXT", func() {
mf := model.MediaFile{
Path: "tests/fixtures/test.wav",
}
lyrics, err := agent.GetSongLyrics(ctx, &mf)
Expect(err).ToNot(HaveOccurred())
Expect(lyrics).To(Equal(model.LyricList{
{
DisplayArtist: "Artist",
DisplayTitle: "Title",
Lang: "xxx",
Line: []model.Line{
{Start: P(int64(0)), Value: "Line 1"},
{Start: P(int64(5210)), Value: "Line 2"},
{Start: P(int64(12450)), Value: "Line 5"},
},
Offset: P(int64(100)),
Synced: true,
},
{
DisplayArtist: "",
DisplayTitle: "",
Lang: "xxx",
Line: []model.Line{
{
Start: nil,
Value: "Unsynchronized lyric line 1",
},
{
Start: nil,
Value: "Unsynchronized lyric line 2",
},
{
Start: nil,
Value: "Unsynchronized lyric line 3",
},
},
Offset: nil,
Synced: false,
},
}))
})
})
})

View File

@ -0,0 +1,17 @@
package filesystem
import (
"testing"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/tests"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
func TestFilesystem(t *testing.T) {
tests.Init(t, false)
log.SetLevel(log.LevelFatal)
RegisterFailHandler(Fail)
RunSpecs(t, "Spotify Test Suite")
}

View File

@ -69,6 +69,14 @@ type ArtistTopSongsRetriever interface {
GetArtistTopSongs(ctx context.Context, id, artistName, mbid string, count int) ([]Song, error)
}
type LyricsRetriever interface {
// There are three possible results:
// 1. nil, err: Any error
// 2. lyrics, nil: track has one or more lyrics
// 3. nil, nil: track was found, but is instrumental
GetSongLyrics(ctx context.Context, mf *model.MediaFile) (model.LyricList, error)
}
var Map map[string]Constructor
func Register(name string, init Constructor) {

View File

@ -50,3 +50,5 @@ func (p *localAgent) GetArtistTopSongs(ctx context.Context, id, artistName, mbid
func init() {
Register(LocalAgentName, localsConstructor)
}
var _ ArtistTopSongsRetriever = (*localAgent)(nil)

View File

@ -0,0 +1,89 @@
package lrclib
import (
"context"
"errors"
"net/http"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/consts"
"github.com/navidrome/navidrome/core/agents"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/utils"
)
const (
lrclibAgentName = "lrclib"
)
type lrclibAgent struct {
client *client
}
func (l *lrclibAgent) GetSongLyrics(ctx context.Context, mf *model.MediaFile) (model.LyricList, error) {
lyrics, err := l.client.getLyrics(ctx, mf.Title, mf.AlbumArtist, mf.Album, mf.Duration)
if err != nil {
var lrclibError *lrclibError
isLrclibError := errors.As(err, &lrclibError)
if isLrclibError && lrclibError.Code == 404 {
log.Info(ctx, "Track not present in LrcLib")
return nil, agents.ErrNotFound
}
log.Error(ctx, "Error fetching lyrics", "id", mf.ID, err)
return nil, err
}
songLyrics := model.LyricList{}
if lyrics.Instrumental {
return nil, nil
}
if lyrics.SyncedLyrics != "" {
lyrics, err := model.ToLyrics("xxx", lyrics.SyncedLyrics)
if err != nil {
return nil, err
}
songLyrics = append(songLyrics, *lyrics)
}
if lyrics.PlainLyrics != "" {
lyrics, err := model.ToLyrics("xxx", lyrics.PlainLyrics)
if err != nil {
return nil, err
}
songLyrics = append(songLyrics, *lyrics)
}
return songLyrics, nil
}
func lrclibConstructor() *lrclibAgent {
l := &lrclibAgent{}
hc := &http.Client{
Timeout: consts.DefaultHttpClientTimeOut,
}
chc := utils.NewCachedHTTPClient(hc, consts.DefaultHttpClientTimeOut)
l.client = newClient(chc)
return l
}
func (l *lrclibAgent) AgentName() string {
return lrclibAgentName
}
func init() {
conf.AddHook(func() {
agents.Register(lrclibAgentName, func(ds model.DataStore) agents.Interface {
return lrclibConstructor()
})
})
}
var _ agents.LyricsRetriever = (lrclibConstructor)()

View File

@ -0,0 +1,106 @@
package lrclib
import (
"bytes"
"context"
"io"
"net/http"
"os"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/tests"
. "github.com/navidrome/navidrome/utils/gg"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
var _ = Describe("lrclibAgent", func() {
var ctx context.Context
var agent *lrclibAgent
var httpClient *tests.FakeHttpClient
BeforeEach(func() {
ctx = context.Background()
httpClient = &tests.FakeHttpClient{}
agent = lrclibConstructor()
agent.client = newClient(httpClient)
})
Describe("getLyrics", func() {
It("handles parses lyrics successfully", func() {
f, _ := os.Open("tests/fixtures/lrclib.get.success.json")
httpClient.Res = http.Response{Body: f, StatusCode: 200}
mf := model.MediaFile{Album: "album", AlbumArtist: "artist", Title: "title", Duration: 233.5}
lyrics, err := agent.GetSongLyrics(ctx, &mf)
Expect(httpClient.SavedRequest.URL.String()).To(Equal("https://lrclib.net/api/get?album_name=album&artist_name=artist&duration=233&track_name=title"))
Expect(err).ToNot(HaveOccurred())
Expect(lyrics).To(Equal(model.LyricList{
{
DisplayArtist: "",
DisplayTitle: "",
Lang: "xxx",
Line: []model.Line{
{
Start: P(int64(17120)),
Value: "I feel your breath upon my neck...",
},
{
Start: P(int64(200310)),
Value: "The clock won't stop and this is what we get",
},
},
Offset: nil,
Synced: true,
},
{
DisplayArtist: "",
DisplayTitle: "",
Lang: "xxx",
Line: []model.Line{
{
Start: nil,
Value: "I feel your breath upon my neck...",
},
{
Start: nil,
Value: "The clock won't stop and this is what we get",
},
},
Offset: nil,
Synced: false,
},
}))
})
It("returns nil for instrumental tracks", func() {
f, _ := os.Open("tests/fixtures/lrclib.get.instrumental.json")
httpClient.Res = http.Response{Body: f, StatusCode: 200}
mf := model.MediaFile{Album: "album", AlbumArtist: "artist", Title: "title"}
lyrics, err := agent.GetSongLyrics(ctx, &mf)
Expect(err).ToNot(HaveOccurred())
Expect(lyrics).To(BeNil())
})
It("handles error correctly", func() {
httpClient.Res = http.Response{
Body: io.NopCloser(bytes.NewBufferString(`{"code": 404, "name": "TrackNotFound", "message": "Failed to find specified track"}`)),
StatusCode: 404,
}
mf := model.MediaFile{Album: "album", AlbumArtist: "artist", Title: "title"}
lyrics, err := agent.GetSongLyrics(ctx, &mf)
Expect(lyrics).To(BeNil())
Expect(err).To(Equal(lrclibError{
Code: 404,
Name: "TrackNotFound",
Message: "Failed to find specified track",
}))
})
})
})

View File

@ -0,0 +1,85 @@
package lrclib
import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"strconv"
)
const apiBaseUrl = "https://lrclib.net/api/"
type httpDoer interface {
Do(req *http.Request) (*http.Response, error)
}
func newClient(hc httpDoer) *client {
return &client{hc}
}
type client struct {
hc httpDoer
}
type lyricInfo struct {
Id int `json:"id"`
Instrumental bool `json:"instrumental"`
PlainLyrics string `json:"plainLyrics,omitempty"`
SyncedLyrics string `json:"syncedLyrics,omitempty"`
}
type lrclibError struct {
Code int `json:"code"`
Name string `json:"name,omitempty"`
Message string `json:"message,omitempty"`
}
func (e lrclibError) Error() string {
return fmt.Sprintf("lrclib error(%d, %s): %s", e.Code, e.Name, e.Message)
}
func (c *client) getLyrics(ctx context.Context, trackName, artistName, albumName string, durationSec float32) (*lyricInfo, error) {
params := url.Values{}
params.Add("track_name", trackName)
params.Add("artist_name", artistName)
params.Add("album_name", albumName)
params.Add("duration", strconv.Itoa(int(durationSec)))
req, _ := http.NewRequestWithContext(ctx, "GET", apiBaseUrl+"get", nil)
req.URL.RawQuery = params.Encode()
resp, err := c.hc.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
data, err := io.ReadAll(resp.Body)
if err != nil {
return nil, err
}
if resp.StatusCode != 200 {
return nil, c.parseError(data)
}
var lyricData lyricInfo
err = json.Unmarshal(data, &lyricData)
if err != nil {
return nil, err
}
return &lyricData, nil
}
func (c *client) parseError(data []byte) error {
var e lrclibError
err := json.Unmarshal(data, &e)
if err != nil {
return err
}
return e
}

View File

@ -0,0 +1,97 @@
package lrclib
import (
"bytes"
"context"
"encoding/json"
"io"
"net/http"
"os"
"github.com/navidrome/navidrome/tests"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
var _ = Describe("client", func() {
lrcError := `{"code": 404, "name": "TrackNotFound", "message": "Failed to find specified track"}`
Describe("responses", func() {
It("parses a successful response correctly", func() {
var response lyricInfo
simpleLyrics, _ := os.ReadFile("tests/fixtures/lrclib.get.success.json")
err := json.Unmarshal(simpleLyrics, &response)
Expect(err).ToNot(HaveOccurred())
Expect(response.Id).To(Equal(3396226))
Expect(response.Instrumental).To(Equal(false))
Expect(response.PlainLyrics).To(Equal("I feel your breath upon my neck...\nThe clock won't stop and this is what we get\n"))
Expect(response.SyncedLyrics).To(Equal("[00:17.12] I feel your breath upon my neck...\n[03:20.31] The clock won't stop and this is what we get\n"))
})
It("parses error successfully", func() {
var response lrclibError
err := json.Unmarshal([]byte(lrcError), &response)
Expect(err).ToNot(HaveOccurred())
Expect(response.Code).To(Equal(404))
Expect(response.Message).To(Equal("Failed to find specified track"))
Expect(response.Name).To(Equal("TrackNotFound"))
})
})
Describe("client", func() {
var httpClient *tests.FakeHttpClient
var client *client
BeforeEach(func() {
httpClient = &tests.FakeHttpClient{}
client = newClient(httpClient)
})
Describe("getLyrics", func() {
It("should return lyrics properly", func() {
f, _ := os.Open("tests/fixtures/lrclib.get.success.json")
httpClient.Res = http.Response{Body: f, StatusCode: 200}
lyrics, err := client.getLyrics(context.Background(), "trackName", "artistName", "albumName", 100)
Expect(err).ToNot(HaveOccurred())
Expect(lyrics).ToNot(BeNil())
Expect(lyrics.Id).To(Equal(3396226))
Expect(lyrics.Instrumental).To(Equal(false))
Expect(lyrics.PlainLyrics).To(Equal("I feel your breath upon my neck...\nThe clock won't stop and this is what we get\n"))
Expect(lyrics.SyncedLyrics).To(Equal("[00:17.12] I feel your breath upon my neck...\n[03:20.31] The clock won't stop and this is what we get\n"))
Expect(httpClient.SavedRequest.URL.String()).To(Equal("https://lrclib.net/api/get?album_name=albumName&artist_name=artistName&duration=100&track_name=trackName"))
})
It("should parse instrumental possible", func() {
f, _ := os.Open("tests/fixtures/lrclib.get.instrumental.json")
httpClient.Res = http.Response{Body: f, StatusCode: 200}
lyrics, err := client.getLyrics(context.Background(), "trackName", "artistName", "albumName", 100)
Expect(err).ToNot(HaveOccurred())
Expect(lyrics).ToNot(BeNil())
Expect(lyrics.Id).To(Equal(3396226))
Expect(lyrics.Instrumental).To(Equal(true))
Expect(lyrics.PlainLyrics).To(Equal(""))
Expect(lyrics.SyncedLyrics).To(Equal(""))
Expect(httpClient.SavedRequest.URL.String()).To(Equal("https://lrclib.net/api/get?album_name=albumName&artist_name=artistName&duration=100&track_name=trackName"))
})
It("should handle error", func() {
httpClient.Res = http.Response{
Body: io.NopCloser(bytes.NewBufferString(lrcError)),
StatusCode: 404,
}
lyrics, err := client.getLyrics(context.Background(), "trackName", "artistName", "albumName", 100)
Expect(lyrics).To(BeNil())
Expect(err).To(Equal(lrclibError{
Code: 404,
Name: "TrackNotFound",
Message: "Failed to find specified track",
}))
})
})
})
})

View File

@ -0,0 +1,17 @@
package lrclib
import (
"testing"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/tests"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
func TestLrcLib(t *testing.T) {
tests.Init(t, false)
log.SetLevel(log.LevelFatal)
RegisterFailHandler(Fail)
RunSpecs(t, "Spotify Test Suite")
}

View File

@ -2,6 +2,7 @@ package core
import (
"context"
"encoding/json"
"errors"
"net/url"
"sort"
@ -12,8 +13,10 @@ import (
"github.com/deluan/sanitize"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/core/agents"
_ "github.com/navidrome/navidrome/core/agents/filesystem"
_ "github.com/navidrome/navidrome/core/agents/lastfm"
_ "github.com/navidrome/navidrome/core/agents/listenbrainz"
_ "github.com/navidrome/navidrome/core/agents/lrclib"
_ "github.com/navidrome/navidrome/core/agents/spotify"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model"
@ -37,6 +40,7 @@ type ExternalMetadata interface {
TopSongs(ctx context.Context, artist string, count int) (model.MediaFiles, error)
ArtistImage(ctx context.Context, id string) (*url.URL, error)
AlbumImage(ctx context.Context, id string) (*url.URL, error)
ExternalLyrics(ctx context.Context, id string) (model.LyricList, error)
}
type externalMetadata struct {
@ -44,6 +48,7 @@ type externalMetadata struct {
ag *agents.Agents
artistQueue chan<- *auxArtist
albumQueue chan<- *auxAlbum
lyricsQueue chan<- *model.MediaFile
}
type auxAlbum struct {
@ -60,6 +65,7 @@ func NewExternalMetadata(ds model.DataStore, agents *agents.Agents) ExternalMeta
e := &externalMetadata{ds: ds, ag: agents}
e.artistQueue = startRefreshQueue(context.TODO(), e.populateArtistInfo)
e.albumQueue = startRefreshQueue(context.TODO(), e.populateAlbumInfo)
e.lyricsQueue = startRefreshQueue(context.TODO(), e.populateSongLyrics)
return e
}
@ -561,6 +567,69 @@ func (e *externalMetadata) loadSimilar(ctx context.Context, artist *auxArtist, c
return nil
}
func (e *externalMetadata) ExternalLyrics(ctx context.Context, id string) (model.LyricList, error) {
mf, err := e.ds.MediaFile(ctx).Get(id)
if err != nil {
return nil, err
}
updatedAt := V(mf.ExternalLyricsUpdatedAt)
if updatedAt.IsZero() {
log.Debug(ctx, "Lyrics not cached. Retrieving it now", "updatedAt", updatedAt, "id", mf.ID, "title", mf.Title)
err := e.populateSongLyrics(ctx, mf)
if err != nil {
return nil, err
}
}
if time.Since(updatedAt) > conf.Server.DevLyricsTimeToLive {
log.Debug("Found expired cached lyrics, refreshing in the background", "updatedAt", updatedAt, "title", mf.Title)
enqueueRefresh(e.lyricsQueue, mf)
}
return mf.StructuredExternalLyrics()
}
func (e *externalMetadata) populateSongLyrics(ctx context.Context, mf *model.MediaFile) error {
start := time.Now()
lyrics, err := e.ag.GetSongLyrics(ctx, mf)
if errors.Is(err, agents.ErrNotFound) {
return nil
}
if err != nil {
log.Error(ctx, "Error trying to fetch external lyrics", "id", mf.ID,
"title", mf.Title, "elapsed", time.Since(start), err)
return err
}
mf.ExternalLyricsUpdatedAt = P(time.Now())
if lyrics != nil {
content, err := json.Marshal(lyrics)
if err != nil {
log.Error(ctx, "Error marshalling lyrics", "id", mf.ID,
"title", mf.Title, "elapsed", time.Since(start), err)
return err
}
mf.ExternalLyrics = string(content)
} else {
mf.ExternalLyrics = ""
}
err = e.ds.MediaFile(ctx).Put(mf)
if err != nil {
log.Error(ctx, "Error trying to update external lyrics", "id", mf.ID,
"title", mf.Title, "elapsed", time.Since(start), err)
} else {
log.Trace(ctx, "External lyrics collected", "title", mf.ID, "elapsed", time.Since(start))
}
return nil
}
func startRefreshQueue[T any](ctx context.Context, processFn func(context.Context, T) error) chan<- T {
queue := make(chan T, refreshQueueLength)
go func() {

View File

@ -0,0 +1,28 @@
package migrations
import (
"context"
"database/sql"
"github.com/pressly/goose/v3"
)
func init() {
goose.AddMigrationContext(upAddExternalLyrics, downAddExternalLyrics)
}
func upAddExternalLyrics(ctx context.Context, tx *sql.Tx) error {
_, err := tx.ExecContext(ctx, `
alter table media_file
add external_lyrics text default '' not null;
alter table media_file
add external_lyrics_updated_at datetime;
`)
return err
}
func downAddExternalLyrics(ctx context.Context, tx *sql.Tx) error {
// This code is executed when the migration is rolled back.
return nil
}

View File

@ -71,6 +71,9 @@ type MediaFile struct {
RgAlbumPeak float64 `structs:"rg_album_peak" json:"rgAlbumPeak"`
RgTrackGain float64 `structs:"rg_track_gain" json:"rgTrackGain"`
RgTrackPeak float64 `structs:"rg_track_peak" json:"rgTrackPeak"`
ExternalLyrics string `structs:"external_lyrics" json:"externalLyrics,omitempty"`
ExternalLyricsUpdatedAt *time.Time `structs:"external_lyrics_updated_at" json:"externalLyricsUpdatedAt"`
CreatedAt time.Time `structs:"created_at" json:"createdAt"` // Time this entry was created in the DB
UpdatedAt time.Time `structs:"updated_at" json:"updatedAt"` // Time of file last update (mtime)
@ -102,6 +105,15 @@ func (mf MediaFile) StructuredLyrics() (LyricList, error) {
return lyrics, nil
}
func (mf MediaFile) StructuredExternalLyrics() (LyricList, error) {
lyrics := LyricList{}
err := json.Unmarshal([]byte(mf.ExternalLyrics), &lyrics)
if err != nil {
return nil, err
}
return lyrics, nil
}
type MediaFiles []MediaFile
// Dirs returns a deduped list of all directories from the MediaFiles' paths

View File

@ -111,6 +111,17 @@ func (api *Router) GetLyrics(r *http.Request) (*responses.Subsonic, error) {
return nil, err
}
if len(structuredLyrics) == 0 {
newLyrics, err := api.externalMetadata.ExternalLyrics(r.Context(), mediaFiles[0].ID)
if err != nil {
return nil, err
}
if newLyrics != nil {
structuredLyrics = newLyrics
}
}
if len(structuredLyrics) == 0 {
return response, nil
}
@ -144,6 +155,17 @@ func (api *Router) GetLyricsBySongId(r *http.Request) (*responses.Subsonic, erro
return nil, err
}
if len(lyrics) == 0 {
newLyrics, err := api.externalMetadata.ExternalLyrics(r.Context(), id)
if err != nil {
return nil, err
}
if newLyrics != nil {
lyrics = newLyrics
}
}
response := newResponse()
response.LyricsList = buildLyricsList(mediaFile, lyrics)

View File

@ -14,6 +14,7 @@ import (
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/server/subsonic/responses"
"github.com/navidrome/navidrome/tests"
. "github.com/navidrome/navidrome/utils/gg"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
@ -24,13 +25,15 @@ var _ = Describe("MediaRetrievalController", func() {
mockRepo := &mockedMediaFile{}
var artwork *fakeArtwork
var w *httptest.ResponseRecorder
var mockedMetadata *tests.MockExternalMetadata
BeforeEach(func() {
ds = &tests.MockDataStore{
MockedMediaFile: mockRepo,
}
artwork = &fakeArtwork{}
router = New(ds, artwork, nil, nil, nil, nil, nil, nil, nil, nil, nil)
mockedMetadata = tests.CreateMockExternalMetadata()
router = New(ds, artwork, nil, nil, nil, mockedMetadata, nil, nil, nil, nil, nil)
w = httptest.NewRecorder()
})
@ -109,6 +112,59 @@ var _ = Describe("MediaRetrievalController", func() {
Expect(response.Lyrics.Title).To(Equal(""))
Expect(response.Lyrics.Value).To(Equal(""))
})
It("should return lyrics from external metadata", func() {
r := newGetRequest("artist=Rick+Astley", "title=Never+Gonna+Give+You+Up")
mockRepo.SetData(model.MediaFiles{
{
ID: "1",
Artist: "Rick Astley",
Title: "Never Gonna Give You Up",
Lyrics: "[]",
},
})
mockedMetadata.SetLyrics(model.LyricList{
{
DisplayArtist: "Artist",
DisplayTitle: "Title",
Lang: "xxx",
Line: []model.Line{
{Start: P(int64(0)), Value: "Line 1"},
{Start: P(int64(5210)), Value: "Line 2"},
{Start: P(int64(12450)), Value: "Line 5"},
},
Offset: P(int64(100)),
Synced: true,
},
})
response, err := router.GetLyrics(r)
if err != nil {
log.Error("You're missing something.", err)
}
Expect(err).To(BeNil())
Expect(response.Lyrics.Artist).To(Equal("Rick Astley"))
Expect(response.Lyrics.Title).To(Equal("Never Gonna Give You Up"))
Expect(response.Lyrics.Value).To(Equal("Line 1\nLine 2\nLine 5\n"))
})
It("should return nothing if no external metadata", func() {
r := newGetRequest("artist=Rick+Astley", "title=Never+Gonna+Give+You+Up")
mockRepo.SetData(model.MediaFiles{
{
ID: "1",
Artist: "Rick Astley",
Title: "Never Gonna Give You Up",
Lyrics: "[]",
},
})
mockedMetadata.SetLyrics(nil)
response, err := router.GetLyrics(r)
if err != nil {
log.Error("You're missing something.", err)
}
Expect(err).To(BeNil())
Expect(response.Lyrics.Artist).To(Equal(""))
Expect(response.Lyrics.Title).To(Equal(""))
Expect(response.Lyrics.Value).To(Equal(""))
})
})
Describe("getLyricsBySongId", func() {
@ -246,6 +302,68 @@ var _ = Describe("MediaRetrievalController", func() {
},
})
})
It("should get lyrics from external metadata", func() {
r := newGetRequest("id=1")
mockRepo.SetData(model.MediaFiles{
{
ID: "1",
Artist: "Rick Astley",
Title: "Never Gonna Give You Up",
Lyrics: "[]",
},
})
mockedMetadata.SetLyrics(model.LyricList{
{
DisplayArtist: "Artist",
DisplayTitle: "Title",
Lang: "xxx",
Line: []model.Line{
{Start: P(int64(0)), Value: "Line 1"},
{Start: P(int64(5210)), Value: "Line 2"},
{Start: P(int64(12450)), Value: "Line 5"},
},
Offset: P(int64(100)),
Synced: true,
},
})
response, err := router.GetLyricsBySongId(r)
Expect(err).ToNot(HaveOccurred())
compareResponses(response.LyricsList, responses.LyricsList{
StructuredLyrics: responses.StructuredLyrics{
{
DisplayArtist: "Artist",
DisplayTitle: "Title",
Lang: "xxx",
Line: []responses.Line{
{Start: P(int64(0)), Value: "Line 1"},
{Start: P(int64(5210)), Value: "Line 2"},
{Start: P(int64(12450)), Value: "Line 5"},
},
Offset: P(int64(100)),
Synced: true,
},
},
})
})
It("should get have no lyrics if external metadata returns nil", func() {
r := newGetRequest("id=1")
mockRepo.SetData(model.MediaFiles{
{
ID: "1",
Artist: "Rick Astley",
Title: "Never Gonna Give You Up",
Lyrics: "[]",
},
})
mockedMetadata.SetLyrics(nil)
response, err := router.GetLyricsBySongId(r)
Expect(err).ToNot(HaveOccurred())
compareResponses(response.LyricsList, responses.LyricsList{
StructuredLyrics: responses.StructuredLyrics{},
})
})
})
})

View File

@ -0,0 +1,3 @@
[00:00.00]Line 1
[00:05.21]Line 2
[00:12.45]Line 3

1
tests/fixtures/artist/artist.txt vendored Normal file
View File

@ -0,0 +1 @@
This is an artist biography

View File

@ -0,0 +1 @@
{"id":3396226,"trackName":"I Want to Live","artistName":"Borislav Slavov","albumName": "Baldur's Gate 3 (Original Game Soundtrack)","duration": 233, "instrumental":true,"plainLyrics":"","syncedLyrics": ""}

View File

@ -0,0 +1 @@
{"id":3396226,"trackName":"I Want to Live","artistName":"Borislav Slavov","albumName": "Baldur's Gate 3 (Original Game Soundtrack)","duration": 233, "instrumental":false,"plainLyrics":"I feel your breath upon my neck...\nThe clock won't stop and this is what we get\n","syncedLyrics": "[00:17.12] I feel your breath upon my neck...\n[03:20.31] The clock won't stop and this is what we get\n"}

6
tests/fixtures/test.lrc vendored Normal file
View File

@ -0,0 +1,6 @@
[ti:Title]
[ar:Artist]
[offset:100]
[00:00.00]Line 1
[00:05.21]Line 2
[00:12.45]Line 5

3
tests/fixtures/test.txt vendored Normal file
View File

@ -0,0 +1,3 @@
Unsynchronized lyric line 1
Unsynchronized lyric line 2
Unsynchronized lyric line 3

View File

@ -0,0 +1,106 @@
package tests
import (
"context"
"errors"
"net/url"
"github.com/navidrome/navidrome/model"
)
var (
ErrMockMetadata = errors.New("mock metadata error")
)
type MockExternalMetadata struct {
album *model.Album
artist *model.Artist
err bool
lyrics model.LyricList
mf model.MediaFiles
url *url.URL
}
func CreateMockExternalMetadata() *MockExternalMetadata {
return &MockExternalMetadata{}
}
func (m *MockExternalMetadata) SetAlbum(album *model.Album) {
m.album = album
}
func (m *MockExternalMetadata) SetArtist(artist *model.Artist) {
m.artist = artist
}
func (m *MockExternalMetadata) SetError(err bool) {
m.err = err
}
func (m *MockExternalMetadata) SetLyrics(lyrics model.LyricList) {
m.lyrics = lyrics
}
func (m *MockExternalMetadata) SetMediaFiles(mf model.MediaFiles) {
m.mf = mf
}
func (m *MockExternalMetadata) SetUrl(url *url.URL) {
m.url = url
}
func (m *MockExternalMetadata) AlbumImage(ctx context.Context, id string) (*url.URL, error) {
if m.err {
return nil, ErrMockMetadata
}
return m.url, nil
}
func (m *MockExternalMetadata) ArtistImage(ctx context.Context, id string) (*url.URL, error) {
if m.err {
return nil, ErrMockMetadata
}
return m.url, nil
}
func (m *MockExternalMetadata) ExternalLyrics(ctx context.Context, id string) (model.LyricList, error) {
if m.err {
return nil, ErrMockMetadata
}
return m.lyrics, nil
}
func (m *MockExternalMetadata) SimilarSongs(ctx context.Context, id string, count int) (model.MediaFiles, error) {
if m.err {
return nil, ErrMockMetadata
}
return m.mf, nil
}
func (m *MockExternalMetadata) TopSongs(ctx context.Context, artist string, count int) (model.MediaFiles, error) {
if m.err {
return nil, ErrMockMetadata
}
return m.mf, nil
}
func (m *MockExternalMetadata) UpdateAlbumInfo(ctx context.Context, id string) (*model.Album, error) {
if m.err {
return nil, ErrMockMetadata
}
return m.album, nil
}
func (m *MockExternalMetadata) UpdateArtistInfo(ctx context.Context, id string, count int, includeNotPresent bool) (*model.Artist, error) {
if m.err {
return nil, ErrMockMetadata
}
return m.artist, nil
}