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:
@@ -146,6 +146,7 @@ func (api *Router) routes() http.Handler {
|
|||||||
withThrottle := r.With(middleware.ThrottleBacklog(maxRequests, consts.RequestThrottleBacklogLimit, consts.RequestThrottleBacklogTimeout))
|
withThrottle := r.With(middleware.ThrottleBacklog(maxRequests, consts.RequestThrottleBacklogLimit, consts.RequestThrottleBacklogTimeout))
|
||||||
h(withThrottle, "getAvatar", c.GetAvatar)
|
h(withThrottle, "getAvatar", c.GetAvatar)
|
||||||
h(withThrottle, "getCoverArt", c.GetCoverArt)
|
h(withThrottle, "getCoverArt", c.GetCoverArt)
|
||||||
|
h(withThrottle, "getLyrics", c.GetLyrics)
|
||||||
})
|
})
|
||||||
r.Group(func(r chi.Router) {
|
r.Group(func(r chi.Router) {
|
||||||
c := initStreamController(api)
|
c := initStreamController(api)
|
||||||
@@ -155,7 +156,6 @@ func (api *Router) routes() http.Handler {
|
|||||||
})
|
})
|
||||||
|
|
||||||
// Not Implemented (yet?)
|
// Not Implemented (yet?)
|
||||||
h501(r, "getLyrics")
|
|
||||||
h501(r, "jukeboxControl")
|
h501(r, "jukeboxControl")
|
||||||
h501(r, "getAlbumInfo", "getAlbumInfo2")
|
h501(r, "getAlbumInfo", "getAlbumInfo2")
|
||||||
h501(r, "getShares", "createShare", "updateShare", "deleteShare")
|
h501(r, "getShares", "createShare", "updateShare", "deleteShare")
|
||||||
|
|||||||
@@ -111,3 +111,11 @@ func SongsByRandom(genre string, fromYear, toYear int) Options {
|
|||||||
func Starred() Options {
|
func Starred() Options {
|
||||||
return Options{Sort: "starred_at", Order: "desc", Filters: squirrel.Eq{"starred": true}}
|
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": ""}},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package subsonic
|
|||||||
import (
|
import (
|
||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"regexp"
|
||||||
|
|
||||||
"github.com/navidrome/navidrome/conf"
|
"github.com/navidrome/navidrome/conf"
|
||||||
"github.com/navidrome/navidrome/consts"
|
"github.com/navidrome/navidrome/consts"
|
||||||
@@ -10,6 +11,7 @@ import (
|
|||||||
"github.com/navidrome/navidrome/log"
|
"github.com/navidrome/navidrome/log"
|
||||||
"github.com/navidrome/navidrome/model"
|
"github.com/navidrome/navidrome/model"
|
||||||
"github.com/navidrome/navidrome/resources"
|
"github.com/navidrome/navidrome/resources"
|
||||||
|
"github.com/navidrome/navidrome/server/subsonic/filter"
|
||||||
"github.com/navidrome/navidrome/server/subsonic/responses"
|
"github.com/navidrome/navidrome/server/subsonic/responses"
|
||||||
"github.com/navidrome/navidrome/utils"
|
"github.com/navidrome/navidrome/utils"
|
||||||
"github.com/navidrome/navidrome/utils/gravatar"
|
"github.com/navidrome/navidrome/utils/gravatar"
|
||||||
@@ -78,3 +80,42 @@ func (c *MediaRetrievalController) GetCoverArt(w http.ResponseWriter, r *http.Re
|
|||||||
|
|
||||||
return nil, err
|
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
|
||||||
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import (
|
|||||||
"io"
|
"io"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
|
|
||||||
|
"github.com/navidrome/navidrome/log"
|
||||||
"github.com/navidrome/navidrome/model"
|
"github.com/navidrome/navidrome/model"
|
||||||
"github.com/navidrome/navidrome/tests"
|
"github.com/navidrome/navidrome/tests"
|
||||||
. "github.com/onsi/ginkgo"
|
. "github.com/onsi/ginkgo"
|
||||||
@@ -15,12 +16,17 @@ import (
|
|||||||
|
|
||||||
var _ = Describe("MediaRetrievalController", func() {
|
var _ = Describe("MediaRetrievalController", func() {
|
||||||
var controller *MediaRetrievalController
|
var controller *MediaRetrievalController
|
||||||
|
var ds model.DataStore
|
||||||
|
mockRepo := &mockedMediaFile{}
|
||||||
var artwork *fakeArtwork
|
var artwork *fakeArtwork
|
||||||
var w *httptest.ResponseRecorder
|
var w *httptest.ResponseRecorder
|
||||||
|
|
||||||
BeforeEach(func() {
|
BeforeEach(func() {
|
||||||
|
ds = &tests.MockDataStore{
|
||||||
|
MockedMediaFile: mockRepo,
|
||||||
|
}
|
||||||
artwork = &fakeArtwork{}
|
artwork = &fakeArtwork{}
|
||||||
controller = NewMediaRetrievalController(artwork, &tests.MockDataStore{})
|
controller = NewMediaRetrievalController(artwork, ds)
|
||||||
w = httptest.NewRecorder()
|
w = httptest.NewRecorder()
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -60,6 +66,41 @@ var _ = Describe("MediaRetrievalController", func() {
|
|||||||
Expect(err).To(MatchError("weird error"))
|
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 {
|
type fakeArtwork struct {
|
||||||
@@ -77,3 +118,36 @@ func (c *fakeArtwork) Get(ctx context.Context, id string, size int) (io.ReadClos
|
|||||||
c.recvSize = size
|
c.recvSize = size
|
||||||
return io.NopCloser(bytes.NewReader([]byte(c.data))), nil
|
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
|
||||||
|
}
|
||||||
|
|||||||
+1
@@ -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"}}
|
||||||
+1
@@ -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
				Never gonna let you down
				Never gonna run around and desert you
				Never gonna say goodbye</lyrics></subsonic-response>
|
||||||
+1
@@ -0,0 +1 @@
|
|||||||
|
{"status":"ok","version":"1.8.0","type":"navidrome","serverVersion":"v0.0.0","lyrics":{"value":""}}
|
||||||
+1
@@ -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>
|
||||||
@@ -46,6 +46,7 @@ type Subsonic struct {
|
|||||||
PlayQueue *PlayQueue `xml:"playQueue,omitempty" json:"playQueue,omitempty"`
|
PlayQueue *PlayQueue `xml:"playQueue,omitempty" json:"playQueue,omitempty"`
|
||||||
Bookmarks *Bookmarks `xml:"bookmarks,omitempty" json:"bookmarks,omitempty"`
|
Bookmarks *Bookmarks `xml:"bookmarks,omitempty" json:"bookmarks,omitempty"`
|
||||||
ScanStatus *ScanStatus `xml:"scanStatus,omitempty" json:"scanStatus,omitempty"`
|
ScanStatus *ScanStatus `xml:"scanStatus,omitempty" json:"scanStatus,omitempty"`
|
||||||
|
Lyrics *Lyrics `xml:"lyrics,omitempty" json:"lyrics,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type JsonWrapper struct {
|
type JsonWrapper struct {
|
||||||
@@ -346,3 +347,9 @@ type ScanStatus struct {
|
|||||||
FolderCount int64 `xml:"folderCount,attr" json:"folderCount"`
|
FolderCount int64 `xml:"folderCount,attr" json:"folderCount"`
|
||||||
LastScan *time.Time `xml:"lastScan,attr,omitempty" json:"lastScan,omitempty"`
|
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"`
|
||||||
|
}
|
||||||
|
|||||||
@@ -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())
|
||||||
|
})
|
||||||
|
|
||||||
|
})
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user