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 <lalwanidheeraj1234@gmail.com>

* remove timestamps frorom the the lyrics if they are synced, fix filters & clean up code

Signed-off-by: Dheeraj Lalwani <lalwanidheeraj1234@gmail.com>

* add snapshot tests for the 'Lyrics' response & add some clean up

Signed-off-by: Dheeraj Lalwani <lalwanidheeraj1234@gmail.com>

* add tests for 'GetLyrics' function

Signed-off-by: Dheeraj Lalwani <lalwanidheeraj1234@gmail.com>

* update the snapshot test & the test for 'GetLyrics' function

Signed-off-by: Dheeraj Lalwani <lalwanidheeraj1234@gmail.com>
This commit is contained in:
Dheeraj Lalwani 2021-10-20 02:03:06 +05:30 committed by GitHub
parent 3214783ce9
commit 5621551dd0
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
10 changed files with 169 additions and 2 deletions

View File

@ -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")

View File

@ -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": ""}},
}
}

View File

@ -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
}

View File

@ -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
}

View File

@ -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"}}

View File

@ -0,0 +1 @@
<subsonic-response xmlns="http://subsonic.org/restapi" status="ok" version="1.8.0" type="navidrome" serverVersion="v0.0.0"><lyrics artist="Rick Astley" title="Never Gonna Give You Up">Never gonna give you up&#xA;&#x9;&#x9;&#x9;&#x9;Never gonna let you down&#xA;&#x9;&#x9;&#x9;&#x9;Never gonna run around and desert you&#xA;&#x9;&#x9;&#x9;&#x9;Never gonna say goodbye</lyrics></subsonic-response>

View File

@ -0,0 +1 @@
{"status":"ok","version":"1.8.0","type":"navidrome","serverVersion":"v0.0.0","lyrics":{"value":""}}

View File

@ -0,0 +1 @@
<subsonic-response xmlns="http://subsonic.org/restapi" status="ok" version="1.8.0" type="navidrome" serverVersion="v0.0.0"><lyrics></lyrics></subsonic-response>

View File

@ -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"`
}

View File

@ -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())
})
})
})
})