From 5621551dd09eb4a5807a509cca98f0a77493f331 Mon Sep 17 00:00:00 2001 From: Dheeraj Lalwani <52416311+dheerajdlalwani@users.noreply.github.com> Date: Wed, 20 Oct 2021 02:03:06 +0530 Subject: [PATCH] Adds Lyrics Support to Subsonic API (#1379) * Add function 'isSynced' that identifies if lyrics are synced or not and add tests for the same * implement 'getLyrics' which returns lyrics if they exist Signed-off-by: Dheeraj Lalwani * remove timestamps frorom the the lyrics if they are synced, fix filters & clean up code Signed-off-by: Dheeraj Lalwani * add snapshot tests for the 'Lyrics' response & add some clean up Signed-off-by: Dheeraj Lalwani * add tests for 'GetLyrics' function Signed-off-by: Dheeraj Lalwani * update the snapshot test & the test for 'GetLyrics' function Signed-off-by: Dheeraj Lalwani --- server/subsonic/api.go | 2 +- server/subsonic/filter/filters.go | 8 ++ server/subsonic/media_retrieval.go | 41 ++++++++++ server/subsonic/media_retrieval_test.go | 76 ++++++++++++++++++- ...ponses Lyrics with data should match .JSON | 1 + ...sponses Lyrics with data should match .XML | 1 + ...ses Lyrics without data should match .JSON | 1 + ...nses Lyrics without data should match .XML | 1 + server/subsonic/responses/responses.go | 7 ++ server/subsonic/responses/responses_test.go | 33 ++++++++ 10 files changed, 169 insertions(+), 2 deletions(-) create mode 100644 server/subsonic/responses/.snapshots/responses-snapshotMatcher-Match-Responses Lyrics with data should match .JSON create mode 100644 server/subsonic/responses/.snapshots/responses-snapshotMatcher-Match-Responses Lyrics with data should match .XML create mode 100644 server/subsonic/responses/.snapshots/responses-snapshotMatcher-Match-Responses Lyrics without data should match .JSON create mode 100644 server/subsonic/responses/.snapshots/responses-snapshotMatcher-Match-Responses Lyrics without data should match .XML diff --git a/server/subsonic/api.go b/server/subsonic/api.go index 78e21e4df..cc4b4d7e2 100644 --- a/server/subsonic/api.go +++ b/server/subsonic/api.go @@ -146,6 +146,7 @@ func (api *Router) routes() http.Handler { withThrottle := r.With(middleware.ThrottleBacklog(maxRequests, consts.RequestThrottleBacklogLimit, consts.RequestThrottleBacklogTimeout)) h(withThrottle, "getAvatar", c.GetAvatar) h(withThrottle, "getCoverArt", c.GetCoverArt) + h(withThrottle, "getLyrics", c.GetLyrics) }) r.Group(func(r chi.Router) { c := initStreamController(api) @@ -155,7 +156,6 @@ func (api *Router) routes() http.Handler { }) // Not Implemented (yet?) - h501(r, "getLyrics") h501(r, "jukeboxControl") h501(r, "getAlbumInfo", "getAlbumInfo2") h501(r, "getShares", "createShare", "updateShare", "deleteShare") diff --git a/server/subsonic/filter/filters.go b/server/subsonic/filter/filters.go index da6e339d1..8f05c42c4 100644 --- a/server/subsonic/filter/filters.go +++ b/server/subsonic/filter/filters.go @@ -111,3 +111,11 @@ func SongsByRandom(genre string, fromYear, toYear int) Options { func Starred() Options { return Options{Sort: "starred_at", Order: "desc", Filters: squirrel.Eq{"starred": true}} } + +func SongsWithLyrics(artist, title string) Options { + return Options{ + Sort: "updated_at", + Order: "desc", + Filters: squirrel.And{squirrel.Eq{"artist": artist, "title": title}, squirrel.NotEq{"lyrics": ""}}, + } +} diff --git a/server/subsonic/media_retrieval.go b/server/subsonic/media_retrieval.go index 55f9c2407..30a6bf3a6 100644 --- a/server/subsonic/media_retrieval.go +++ b/server/subsonic/media_retrieval.go @@ -3,6 +3,7 @@ package subsonic import ( "io" "net/http" + "regexp" "github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/consts" @@ -10,6 +11,7 @@ import ( "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/resources" + "github.com/navidrome/navidrome/server/subsonic/filter" "github.com/navidrome/navidrome/server/subsonic/responses" "github.com/navidrome/navidrome/utils" "github.com/navidrome/navidrome/utils/gravatar" @@ -78,3 +80,42 @@ func (c *MediaRetrievalController) GetCoverArt(w http.ResponseWriter, r *http.Re return nil, err } + +const TIMESTAMP_REGEX string = `(\[([0-9]{1,2}:)?([0-9]{1,2}:)([0-9]{1,2})(\.[0-9]{1,2})?\])` + +func isSynced(rawLyrics string) bool { + r := regexp.MustCompile(TIMESTAMP_REGEX) + // Eg: [04:02:50.85] + // [02:50.85] + // [02:50] + return r.MatchString(rawLyrics) +} + +func (c *MediaRetrievalController) GetLyrics(w http.ResponseWriter, r *http.Request) (*responses.Subsonic, error) { + artist := utils.ParamString(r, "artist") + title := utils.ParamString(r, "title") + response := newResponse() + lyrics := responses.Lyrics{} + response.Lyrics = &lyrics + media_files, err := c.ds.MediaFile(r.Context()).GetAll(filter.SongsWithLyrics(artist, title)) + + if err != nil { + return nil, err + } + + if len(media_files) == 0 { + return response, nil + } + + lyrics.Artist = artist + lyrics.Title = title + + if isSynced(media_files[0].Lyrics) { + r := regexp.MustCompile(TIMESTAMP_REGEX) + lyrics.Value = r.ReplaceAllString(media_files[0].Lyrics, "") + } else { + lyrics.Value = media_files[0].Lyrics + } + + return response, nil +} diff --git a/server/subsonic/media_retrieval_test.go b/server/subsonic/media_retrieval_test.go index 705e52d18..b98075991 100644 --- a/server/subsonic/media_retrieval_test.go +++ b/server/subsonic/media_retrieval_test.go @@ -7,6 +7,7 @@ import ( "io" "net/http/httptest" + "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/tests" . "github.com/onsi/ginkgo" @@ -15,12 +16,17 @@ import ( var _ = Describe("MediaRetrievalController", func() { var controller *MediaRetrievalController + var ds model.DataStore + mockRepo := &mockedMediaFile{} var artwork *fakeArtwork var w *httptest.ResponseRecorder BeforeEach(func() { + ds = &tests.MockDataStore{ + MockedMediaFile: mockRepo, + } artwork = &fakeArtwork{} - controller = NewMediaRetrievalController(artwork, &tests.MockDataStore{}) + controller = NewMediaRetrievalController(artwork, ds) w = httptest.NewRecorder() }) @@ -60,6 +66,41 @@ var _ = Describe("MediaRetrievalController", func() { Expect(err).To(MatchError("weird error")) }) }) + + Describe("GetLyrics", func() { + It("should return data for given artist & title", 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: "[00:18.80]We're no strangers to love\n[00:22.80]You know the rules and so do I", + }, + }) + response, err := controller.GetLyrics(w, 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("We're no strangers to love\nYou know the rules and so do I")) + }) + It("should return empty subsonic response if the record corresponding to the given artist & title is not found", func() { + r := newGetRequest("artist=Dheeraj", "title=Rinkiya+Ke+Papa") + mockRepo.SetData(model.MediaFiles{}) + response, err := controller.GetLyrics(w, 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("")) + + }) + }) }) type fakeArtwork struct { @@ -77,3 +118,36 @@ func (c *fakeArtwork) Get(ctx context.Context, id string, size int) (io.ReadClos c.recvSize = size return io.NopCloser(bytes.NewReader([]byte(c.data))), nil } + +var _ = Describe("isSynced", func() { + It("returns false if lyrics contain no timestamps", func() { + Expect(isSynced("Just in case my car goes off the highway")).To(Equal(false)) + Expect(isSynced("[02.50] Just in case my car goes off the highway")).To(Equal(false)) + }) + It("returns false if lyrics is an empty string", func() { + Expect(isSynced("")).To(Equal(false)) + }) + It("returns true if lyrics contain timestamps", func() { + Expect(isSynced(`NF Real Music + [00:00] ksdjjs + [00:00.85] JUST LIKE YOU + [00:00.85] Just in case my car goes off the highway`)).To(Equal(true)) + Expect(isSynced("[04:02:50.85] Never gonna give you up")).To(Equal(true)) + Expect(isSynced("[02:50.85] Never gonna give you up")).To(Equal(true)) + Expect(isSynced("[02:50] Never gonna give you up")).To(Equal(true)) + }) + +}) + +type mockedMediaFile struct { + model.MediaFileRepository + data model.MediaFiles +} + +func (m *mockedMediaFile) SetData(mfs model.MediaFiles) { + m.data = mfs +} + +func (m *mockedMediaFile) GetAll(options ...model.QueryOptions) (model.MediaFiles, error) { + return m.data, nil +} diff --git a/server/subsonic/responses/.snapshots/responses-snapshotMatcher-Match-Responses Lyrics with data should match .JSON b/server/subsonic/responses/.snapshots/responses-snapshotMatcher-Match-Responses Lyrics with data should match .JSON new file mode 100644 index 000000000..bd15ee945 --- /dev/null +++ b/server/subsonic/responses/.snapshots/responses-snapshotMatcher-Match-Responses Lyrics with data should match .JSON @@ -0,0 +1 @@ +{"status":"ok","version":"1.8.0","type":"navidrome","serverVersion":"v0.0.0","lyrics":{"artist":"Rick Astley","title":"Never Gonna Give You Up","value":"Never gonna give you up\n\t\t\t\tNever gonna let you down\n\t\t\t\tNever gonna run around and desert you\n\t\t\t\tNever gonna say goodbye"}} diff --git a/server/subsonic/responses/.snapshots/responses-snapshotMatcher-Match-Responses Lyrics with data should match .XML b/server/subsonic/responses/.snapshots/responses-snapshotMatcher-Match-Responses Lyrics with data should match .XML new file mode 100644 index 000000000..92f3a0012 --- /dev/null +++ b/server/subsonic/responses/.snapshots/responses-snapshotMatcher-Match-Responses Lyrics with data should match .XML @@ -0,0 +1 @@ +Never gonna give you up Never gonna let you down Never gonna run around and desert you Never gonna say goodbye diff --git a/server/subsonic/responses/.snapshots/responses-snapshotMatcher-Match-Responses Lyrics without data should match .JSON b/server/subsonic/responses/.snapshots/responses-snapshotMatcher-Match-Responses Lyrics without data should match .JSON new file mode 100644 index 000000000..5bb964d93 --- /dev/null +++ b/server/subsonic/responses/.snapshots/responses-snapshotMatcher-Match-Responses Lyrics without data should match .JSON @@ -0,0 +1 @@ +{"status":"ok","version":"1.8.0","type":"navidrome","serverVersion":"v0.0.0","lyrics":{"value":""}} diff --git a/server/subsonic/responses/.snapshots/responses-snapshotMatcher-Match-Responses Lyrics without data should match .XML b/server/subsonic/responses/.snapshots/responses-snapshotMatcher-Match-Responses Lyrics without data should match .XML new file mode 100644 index 000000000..3a2ae01a4 --- /dev/null +++ b/server/subsonic/responses/.snapshots/responses-snapshotMatcher-Match-Responses Lyrics without data should match .XML @@ -0,0 +1 @@ + diff --git a/server/subsonic/responses/responses.go b/server/subsonic/responses/responses.go index c88c02845..3b9461f1c 100644 --- a/server/subsonic/responses/responses.go +++ b/server/subsonic/responses/responses.go @@ -46,6 +46,7 @@ type Subsonic struct { PlayQueue *PlayQueue `xml:"playQueue,omitempty" json:"playQueue,omitempty"` Bookmarks *Bookmarks `xml:"bookmarks,omitempty" json:"bookmarks,omitempty"` ScanStatus *ScanStatus `xml:"scanStatus,omitempty" json:"scanStatus,omitempty"` + Lyrics *Lyrics `xml:"lyrics,omitempty" json:"lyrics,omitempty"` } type JsonWrapper struct { @@ -346,3 +347,9 @@ type ScanStatus struct { FolderCount int64 `xml:"folderCount,attr" json:"folderCount"` LastScan *time.Time `xml:"lastScan,attr,omitempty" json:"lastScan,omitempty"` } + +type Lyrics struct { + Artist string `xml:"artist,omitempty,attr" json:"artist,omitempty"` + Title string `xml:"title,omitempty,attr" json:"title,omitempty"` + Value string `xml:",chardata" json:"value"` +} diff --git a/server/subsonic/responses/responses_test.go b/server/subsonic/responses/responses_test.go index 4ac52d513..c442ad93c 100644 --- a/server/subsonic/responses/responses_test.go +++ b/server/subsonic/responses/responses_test.go @@ -561,4 +561,37 @@ var _ = Describe("Responses", func() { }) }) }) + + Describe("Lyrics", func() { + BeforeEach(func() { + response.Lyrics = &Lyrics{} + }) + + Context("without data", func() { + It("should match .XML", func() { + Expect(xml.Marshal(response)).To(MatchSnapshot()) + }) + It("should match .JSON", func() { + Expect(json.Marshal(response)).To(MatchSnapshot()) + }) + }) + + Context("with data", func() { + BeforeEach(func() { + response.Lyrics.Artist = "Rick Astley" + response.Lyrics.Title = "Never Gonna Give You Up" + response.Lyrics.Value = `Never gonna give you up + Never gonna let you down + Never gonna run around and desert you + Never gonna say goodbye` + }) + It("should match .XML", func() { + Expect(xml.Marshal(response)).To(MatchSnapshot()) + }) + It("should match .JSON", func() { + Expect(json.Marshal(response)).To(MatchSnapshot()) + }) + + }) + }) })