diff --git a/conf/configuration.go b/conf/configuration.go index db14f84c..db65966f 100644 --- a/conf/configuration.go +++ b/conf/configuration.go @@ -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) { diff --git a/consts/consts.go b/consts/consts.go index b52b2168..8d98a8bb 100644 --- a/consts/consts.go +++ b/consts/consts.go @@ -52,6 +52,7 @@ const ( ArtistInfoTimeToLive = 24 * time.Hour AlbumInfoTimeToLive = 7 * 24 * time.Hour + LyricsInfoTimeToLive = 30 * 24 * time.Hour I18nFolder = "i18n" SkipScanFile = ".ndignore" diff --git a/core/agents/agents.go b/core/agents/agents.go index 0a11297c..dd70d2e3 100644 --- a/core/agents/agents.go +++ b/core/agents/agents.go @@ -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) diff --git a/core/agents/agents_test.go b/core/agents/agents_test.go index 33687048..efe556d3 100644 --- a/core/agents/agents_test.go +++ b/core/agents/agents_test.go @@ -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 diff --git a/core/agents/filesystem/agent.go b/core/agents/filesystem/agent.go new file mode 100644 index 00000000..2098dcf0 --- /dev/null +++ b/core/agents/filesystem/agent.go @@ -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) diff --git a/core/agents/filesystem/agent_test.go b/core/agents/filesystem/agent_test.go new file mode 100644 index 00000000..8d5c8c06 --- /dev/null +++ b/core/agents/filesystem/agent_test.go @@ -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, + }, + })) + }) + }) +}) diff --git a/core/agents/filesystem/filesystem_suite_test.go b/core/agents/filesystem/filesystem_suite_test.go new file mode 100644 index 00000000..53e8aee3 --- /dev/null +++ b/core/agents/filesystem/filesystem_suite_test.go @@ -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") +} diff --git a/core/agents/interfaces.go b/core/agents/interfaces.go index 00f75627..25604790 100644 --- a/core/agents/interfaces.go +++ b/core/agents/interfaces.go @@ -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) { diff --git a/core/agents/local_agent.go b/core/agents/local_agent.go index ce8f9f07..f8fb9b46 100644 --- a/core/agents/local_agent.go +++ b/core/agents/local_agent.go @@ -50,3 +50,5 @@ func (p *localAgent) GetArtistTopSongs(ctx context.Context, id, artistName, mbid func init() { Register(LocalAgentName, localsConstructor) } + +var _ ArtistTopSongsRetriever = (*localAgent)(nil) diff --git a/core/agents/lrclib/agent.go b/core/agents/lrclib/agent.go new file mode 100644 index 00000000..b6caa84f --- /dev/null +++ b/core/agents/lrclib/agent.go @@ -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)() diff --git a/core/agents/lrclib/agent_test.go b/core/agents/lrclib/agent_test.go new file mode 100644 index 00000000..224e8fdc --- /dev/null +++ b/core/agents/lrclib/agent_test.go @@ -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", + })) + }) + }) +}) diff --git a/core/agents/lrclib/client.go b/core/agents/lrclib/client.go new file mode 100644 index 00000000..d1bb7081 --- /dev/null +++ b/core/agents/lrclib/client.go @@ -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 +} diff --git a/core/agents/lrclib/client_test.go b/core/agents/lrclib/client_test.go new file mode 100644 index 00000000..e8ca09b2 --- /dev/null +++ b/core/agents/lrclib/client_test.go @@ -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", + })) + }) + }) + }) +}) diff --git a/core/agents/lrclib/lrclib_suite_test.go b/core/agents/lrclib/lrclib_suite_test.go new file mode 100644 index 00000000..5d6c7fc5 --- /dev/null +++ b/core/agents/lrclib/lrclib_suite_test.go @@ -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") +} diff --git a/core/external_metadata.go b/core/external_metadata.go index 33206bde..e4a5e914 100644 --- a/core/external_metadata.go +++ b/core/external_metadata.go @@ -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() { diff --git a/db/migration/20240309052135_add_external_lyrics.go b/db/migration/20240309052135_add_external_lyrics.go new file mode 100644 index 00000000..9eb3c334 --- /dev/null +++ b/db/migration/20240309052135_add_external_lyrics.go @@ -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 +} diff --git a/model/mediafile.go b/model/mediafile.go index fc8793ca..f83c2197 100644 --- a/model/mediafile.go +++ b/model/mediafile.go @@ -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 diff --git a/server/subsonic/media_retrieval.go b/server/subsonic/media_retrieval.go index 07b91730..7b583af6 100644 --- a/server/subsonic/media_retrieval.go +++ b/server/subsonic/media_retrieval.go @@ -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) diff --git a/server/subsonic/media_retrieval_test.go b/server/subsonic/media_retrieval_test.go index 12e32dad..fb3f67ee 100644 --- a/server/subsonic/media_retrieval_test.go +++ b/server/subsonic/media_retrieval_test.go @@ -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{}, + }) + }) }) }) diff --git a/tests/fixtures/01 Invisible (RED) Edit Version.lrc b/tests/fixtures/01 Invisible (RED) Edit Version.lrc new file mode 100644 index 00000000..1cab09ed --- /dev/null +++ b/tests/fixtures/01 Invisible (RED) Edit Version.lrc @@ -0,0 +1,3 @@ +[00:00.00]Line 1 +[00:05.21]Line 2 +[00:12.45]Line 3 \ No newline at end of file diff --git a/tests/fixtures/artist/artist.txt b/tests/fixtures/artist/artist.txt new file mode 100644 index 00000000..3abecd23 --- /dev/null +++ b/tests/fixtures/artist/artist.txt @@ -0,0 +1 @@ +This is an artist biography \ No newline at end of file diff --git a/tests/fixtures/lrclib.get.instrumental.json b/tests/fixtures/lrclib.get.instrumental.json new file mode 100644 index 00000000..f7c706c7 --- /dev/null +++ b/tests/fixtures/lrclib.get.instrumental.json @@ -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": ""} \ No newline at end of file diff --git a/tests/fixtures/lrclib.get.success.json b/tests/fixtures/lrclib.get.success.json new file mode 100644 index 00000000..ecfdfee4 --- /dev/null +++ b/tests/fixtures/lrclib.get.success.json @@ -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"} \ No newline at end of file diff --git a/tests/fixtures/test.lrc b/tests/fixtures/test.lrc new file mode 100644 index 00000000..fdbf0684 --- /dev/null +++ b/tests/fixtures/test.lrc @@ -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 \ No newline at end of file diff --git a/tests/fixtures/test.txt b/tests/fixtures/test.txt new file mode 100644 index 00000000..b7ee1f98 --- /dev/null +++ b/tests/fixtures/test.txt @@ -0,0 +1,3 @@ +Unsynchronized lyric line 1 +Unsynchronized lyric line 2 +Unsynchronized lyric line 3 \ No newline at end of file diff --git a/tests/mock_external_metadata.go b/tests/mock_external_metadata.go new file mode 100644 index 00000000..79dc94da --- /dev/null +++ b/tests/mock_external_metadata.go @@ -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 +}