Add OS Lyrics extension (#2656)

* draft commit

* time to fight pipeline

* round 2 changes

* remove unnecessary line

* fight taglib. again

* make taglib work again???

* add id3 tags

* taglib 1.12 vs 1.13

* use int instead for windows

* store as json now

* add migration, more tests

* support repeated line, multiline

* fix ms and support .m, .mm, .mmm

* address some concerns, make cpp a bit safer

* separate responses from model

* remove [:]

* Add trace log

* Try to unblock pipeline

* Fix merge errors

* Fix SIGSEGV error (proper handling of empty frames)

* Add fallback artist/title to structured lyrics

* Rename conflicting named vars

* Fix tests

* Do we still need ffmpeg in the pipeline?

* Revert "Do we still need ffmpeg in the pipeline?"

Yes we do.

This reverts commit 87df7f6df79bccee83f48c4b7a8118a7636a5e66.

* Does this passes now, with a newer ffmpeg version?

* Revert "Does this passes now, with a newer ffmpeg version?"

No, it does not :(

This reverts commit 372eb4b0ae05d9ffe98078e9bc4e56a9b2921f32.

* My OCD made me do it :P

---------

Co-authored-by: Deluan Quintão <deluan@navidrome.org>
This commit is contained in:
Kendall Garner
2023-12-28 01:20:29 +00:00
committed by GitHub
parent 130ab76c79
commit 814161d78d
37 changed files with 1215 additions and 71 deletions
+1
View File
@@ -142,6 +142,7 @@ func (api *Router) routes() http.Handler {
r.Group(func(r chi.Router) {
hr(r, "getAvatar", api.GetAvatar)
h(r, "getLyrics", api.GetLyrics)
h(r, "getLyricsBySongId", api.GetLyricsBySongId)
})
r.Group(func(r chi.Router) {
// configure request throttling
+42
View File
@@ -323,3 +323,45 @@ func buildAlbumID3(ctx context.Context, album model.Album) responses.AlbumID3 {
dir.OriginalReleaseDate = toItemDate(album.OriginalDate)
return dir
}
func buildStructuredLyric(mf *model.MediaFile, lyrics model.Lyrics) responses.StructuredLyric {
lines := make([]responses.Line, len(lyrics.Line))
for i, line := range lyrics.Line {
lines[i] = responses.Line{
Start: line.Start,
Value: line.Value,
}
}
structured := responses.StructuredLyric{
DisplayArtist: lyrics.DisplayArtist,
DisplayTitle: lyrics.DisplayTitle,
Lang: lyrics.Lang,
Line: lines,
Offset: lyrics.Offset,
Synced: lyrics.Synced,
}
if structured.DisplayArtist == "" {
structured.DisplayArtist = mf.Artist
}
if structured.DisplayTitle == "" {
structured.DisplayTitle = mf.Title
}
return structured
}
func buildLyricsList(mf *model.MediaFile, lyricsList model.LyricList) *responses.LyricsList {
lyricList := make(responses.StructuredLyrics, len(lyricsList))
for i, lyrics := range lyricsList {
lyricList[i] = buildStructuredLyric(mf, lyrics)
}
res := &responses.LyricsList{
StructuredLyrics: lyricList,
}
return res
}
+36 -16
View File
@@ -5,7 +5,6 @@ import (
"errors"
"io"
"net/http"
"regexp"
"time"
"github.com/navidrome/navidrome/conf"
@@ -90,16 +89,6 @@ func (api *Router) GetCoverArt(w http.ResponseWriter, r *http.Request) (*respons
return nil, err
}
const timeStampRegex 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(timeStampRegex)
// Eg: [04:02:50.85]
// [02:50.85]
// [02:50]
return r.MatchString(rawLyrics)
}
func (api *Router) GetLyrics(r *http.Request) (*responses.Subsonic, error) {
p := req.Params(r)
artist, _ := p.String("artist")
@@ -117,15 +106,46 @@ func (api *Router) GetLyrics(r *http.Request) (*responses.Subsonic, error) {
return response, nil
}
structuredLyrics, err := mediaFiles[0].StructuredLyrics()
if err != nil {
return nil, err
}
if len(structuredLyrics) == 0 {
return response, nil
}
lyrics.Artist = artist
lyrics.Title = title
if isSynced(mediaFiles[0].Lyrics) {
r := regexp.MustCompile(timeStampRegex)
lyrics.Value = r.ReplaceAllString(mediaFiles[0].Lyrics, "")
} else {
lyrics.Value = mediaFiles[0].Lyrics
lyricsText := ""
for _, line := range structuredLyrics[0].Line {
lyricsText += line.Value + "\n"
}
lyrics.Value = lyricsText
return response, nil
}
func (api *Router) GetLyricsBySongId(r *http.Request) (*responses.Subsonic, error) {
id, err := req.Params(r).String("id")
if err != nil {
return nil, err
}
mediaFile, err := api.ds.MediaFile(r.Context()).Get(id)
if err != nil {
return nil, err
}
lyrics, err := mediaFile.StructuredLyrics()
if err != nil {
return nil, err
}
response := newResponse()
response.LyricsList = buildLyricsList(mediaFile, lyrics)
return response, nil
}
+155 -22
View File
@@ -3,6 +3,7 @@ package subsonic
import (
"bytes"
"context"
"encoding/json"
"errors"
"io"
"net/http/httptest"
@@ -11,6 +12,7 @@ import (
"github.com/navidrome/navidrome/core/artwork"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/server/subsonic/responses"
"github.com/navidrome/navidrome/tests"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
@@ -72,12 +74,18 @@ var _ = Describe("MediaRetrievalController", func() {
Describe("GetLyrics", func() {
It("should return data for given artist & title", func() {
r := newGetRequest("artist=Rick+Astley", "title=Never+Gonna+Give+You+Up")
lyrics, _ := model.ToLyrics("eng", "[00:18.80]We're no strangers to love\n[00:22.80]You know the rules and so do I")
lyricsJson, err := json.Marshal(model.LyricList{
*lyrics,
})
Expect(err).ToNot(HaveOccurred())
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",
Lyrics: string(lyricsJson),
},
})
response, err := router.GetLyrics(r)
@@ -87,7 +95,7 @@ var _ = Describe("MediaRetrievalController", func() {
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"))
Expect(response.Lyrics.Value).To(Equal("We're no strangers to love\nYou know the rules and so do I\n"))
})
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")
@@ -100,7 +108,143 @@ var _ = Describe("MediaRetrievalController", func() {
Expect(response.Lyrics.Artist).To(Equal(""))
Expect(response.Lyrics.Title).To(Equal(""))
Expect(response.Lyrics.Value).To(Equal(""))
})
})
Describe("getLyricsBySongId", func() {
const syncedLyrics = "[00:18.80]We're no strangers to love\n[00:22.801]You know the rules and so do I"
const unsyncedLyrics = "We're no strangers to love\nYou know the rules and so do I"
const metadata = "[ar:Rick Astley]\n[ti:That one song]\n[offset:-100]"
var times = []int64{18800, 22801}
compareResponses := func(actual *responses.LyricsList, expected responses.LyricsList) {
Expect(actual).ToNot(BeNil())
Expect(actual.StructuredLyrics).To(HaveLen(len(expected.StructuredLyrics)))
for i, realLyric := range actual.StructuredLyrics {
expectedLyric := expected.StructuredLyrics[i]
Expect(realLyric.DisplayArtist).To(Equal(expectedLyric.DisplayArtist))
Expect(realLyric.DisplayTitle).To(Equal(expectedLyric.DisplayTitle))
Expect(realLyric.Lang).To(Equal(expectedLyric.Lang))
Expect(realLyric.Synced).To(Equal(expectedLyric.Synced))
if expectedLyric.Offset == nil {
Expect(realLyric.Offset).To(BeNil())
} else {
Expect(*realLyric.Offset).To(Equal(*expectedLyric.Offset))
}
Expect(realLyric.Line).To(HaveLen(len(expectedLyric.Line)))
for j, realLine := range realLyric.Line {
expectedLine := expectedLyric.Line[j]
Expect(realLine.Value).To(Equal(expectedLine.Value))
if expectedLine.Start == nil {
Expect(realLine.Start).To(BeNil())
} else {
Expect(*realLine.Start).To(Equal(*expectedLine.Start))
}
}
}
}
It("should return mixed lyrics", func() {
r := newGetRequest("id=1")
synced, _ := model.ToLyrics("eng", syncedLyrics)
unsynced, _ := model.ToLyrics("xxx", unsyncedLyrics)
lyricsJson, err := json.Marshal(model.LyricList{
*synced, *unsynced,
})
Expect(err).ToNot(HaveOccurred())
mockRepo.SetData(model.MediaFiles{
{
ID: "1",
Artist: "Rick Astley",
Title: "Never Gonna Give You Up",
Lyrics: string(lyricsJson),
},
})
response, err := router.GetLyricsBySongId(r)
Expect(err).ToNot(HaveOccurred())
compareResponses(response.LyricsList, responses.LyricsList{
StructuredLyrics: responses.StructuredLyrics{
{
Lang: "eng",
DisplayArtist: "Rick Astley",
DisplayTitle: "Never Gonna Give You Up",
Synced: true,
Line: []responses.Line{
{
Start: &times[0],
Value: "We're no strangers to love",
},
{
Start: &times[1],
Value: "You know the rules and so do I",
},
},
},
{
Lang: "xxx",
DisplayArtist: "Rick Astley",
DisplayTitle: "Never Gonna Give You Up",
Synced: false,
Line: []responses.Line{
{
Value: "We're no strangers to love",
},
{
Value: "You know the rules and so do I",
},
},
},
},
})
})
It("should parse lrc metadata", func() {
r := newGetRequest("id=1")
synced, _ := model.ToLyrics("eng", metadata+"\n"+syncedLyrics)
lyricsJson, err := json.Marshal(model.LyricList{
*synced,
})
Expect(err).ToNot(HaveOccurred())
mockRepo.SetData(model.MediaFiles{
{
ID: "1",
Artist: "Rick Astley",
Title: "Never Gonna Give You Up",
Lyrics: string(lyricsJson),
},
})
response, err := router.GetLyricsBySongId(r)
Expect(err).ToNot(HaveOccurred())
offset := int64(-100)
compareResponses(response.LyricsList, responses.LyricsList{
StructuredLyrics: responses.StructuredLyrics{
{
DisplayArtist: "Rick Astley",
DisplayTitle: "That one song",
Lang: "eng",
Synced: true,
Line: []responses.Line{
{
Start: &times[0],
Value: "We're no strangers to love",
},
{
Start: &times[1],
Value: "You know the rules and so do I",
},
},
Offset: &offset,
},
},
})
})
})
})
@@ -122,26 +266,6 @@ func (c *fakeArtwork) GetOrPlaceholder(_ context.Context, id string, size int) (
return io.NopCloser(bytes.NewReader([]byte(c.data))), time.Time{}, 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] First line
[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
@@ -154,3 +278,12 @@ func (m *mockedMediaFile) SetData(mfs model.MediaFiles) {
func (m *mockedMediaFile) GetAll(...model.QueryOptions) (model.MediaFiles, error) {
return m.data, nil
}
func (m *mockedMediaFile) Get(id string) (*model.MediaFile, error) {
for _, mf := range m.data {
if mf.ID == id {
return &mf, nil
}
}
return nil, model.ErrNotFound
}
+1
View File
@@ -11,6 +11,7 @@ func (api *Router) GetOpenSubsonicExtensions(_ *http.Request) (*responses.Subson
response.OpenSubsonicExtensions = &responses.OpenSubsonicExtensions{
{Name: "transcodeOffset", Versions: []int32{1}},
{Name: "formPost", Versions: []int32{1}},
{Name: "songLyrics", Versions: []int32{1}},
}
return response, nil
}
@@ -0,0 +1,43 @@
{
"status": "ok",
"version": "1.8.0",
"type": "navidrome",
"serverVersion": "v0.0.0",
"openSubsonic": true,
"lyricsList": {
"structuredLyrics": [
{
"displayArtist": "Rick Astley",
"displayTitle": "Never Gonna Give You Up",
"lang": "eng",
"line": [
{
"start": 18800,
"value": "We're no strangers to love"
},
{
"start": 22801,
"value": "You know the rules and so do I"
}
],
"offset": 100,
"synced": true
},
{
"displayArtist": "Rick Astley",
"displayTitle": "Never Gonna Give You Up",
"lang": "xxx",
"line": [
{
"value": "We're no strangers to love"
},
{
"value": "You know the rules and so do I"
}
],
"offset": 100,
"synced": false
}
]
}
}
@@ -0,0 +1,20 @@
<subsonic-response xmlns="http://subsonic.org/restapi" status="ok" version="1.8.0" type="navidrome" serverVersion="v0.0.0" openSubsonic="true">
<lyricsList>
<structuredLyrics displayArtist="Rick Astley" displayTitle="Never Gonna Give You Up" lang="eng" offset="100" synced="true">
<line start="18800">
<value>We&#39;re no strangers to love</value>
</line>
<line start="22801">
<value>You know the rules and so do I</value>
</line>
</structuredLyrics>
<structuredLyrics displayArtist="Rick Astley" displayTitle="Never Gonna Give You Up" lang="xxx" offset="100" synced="false">
<line>
<value>We&#39;re no strangers to love</value>
</line>
<line>
<value>You know the rules and so do I</value>
</line>
</structuredLyrics>
</lyricsList>
</subsonic-response>
@@ -0,0 +1,8 @@
{
"status": "ok",
"version": "1.8.0",
"type": "navidrome",
"serverVersion": "v0.0.0",
"openSubsonic": true,
"lyricsList": {}
}
@@ -0,0 +1,3 @@
<subsonic-response xmlns="http://subsonic.org/restapi" status="ok" version="1.8.0" type="navidrome" serverVersion="v0.0.0" openSubsonic="true">
<lyricsList></lyricsList>
</subsonic-response>
+21
View File
@@ -58,6 +58,7 @@ type Subsonic struct {
JukeboxPlaylist *JukeboxPlaylist `xml:"jukeboxPlaylist,omitempty" json:"jukeboxPlaylist,omitempty"`
OpenSubsonicExtensions *OpenSubsonicExtensions `xml:"openSubsonicExtensions,omitempty" json:"openSubsonicExtensions,omitempty"`
LyricsList *LyricsList `xml:"lyricsList,omitempty" json:"lyricsList,omitempty"`
}
type JsonWrapper struct {
@@ -446,6 +447,26 @@ type JukeboxPlaylist struct {
JukeboxStatus
Entry []Child `xml:"entry,omitempty" json:"entry,omitempty"`
}
type Line struct {
Start *int64 `xml:"start,attr,omitempty" json:"start,omitempty"`
Value string `xml:"value" json:"value"`
}
type StructuredLyric struct {
DisplayArtist string `xml:"displayArtist,attr,omitempty" json:"displayArtist,omitempty"`
DisplayTitle string `xml:"displayTitle,attr,omitempty" json:"displayTitle,omitempty"`
Lang string `xml:"lang,attr" json:"lang"`
Line []Line `xml:"line" json:"line"`
Offset *int64 `xml:"offset,attr,omitempty" json:"offset,omitempty"`
Synced bool `xml:"synced,attr" json:"synced"`
}
type StructuredLyrics []StructuredLyric
type LyricsList struct {
StructuredLyrics []StructuredLyric `xml:"structuredLyrics,omitempty" json:"structuredLyrics,omitempty"`
}
type OpenSubsonicExtension struct {
Name string `xml:"name,attr" json:"name"`
Versions []int32 `xml:"versions" json:"versions"`
@@ -796,4 +796,69 @@ var _ = Describe("Responses", func() {
})
})
})
Describe("LyricsList", func() {
BeforeEach(func() {
response.LyricsList = &LyricsList{}
})
Describe("without data", func() {
It("should match .XML", func() {
Expect(xml.MarshalIndent(response, "", " ")).To(MatchSnapshot())
})
It("should match .JSON", func() {
Expect(json.MarshalIndent(response, "", " ")).To(MatchSnapshot())
})
})
Describe("with data", func() {
BeforeEach(func() {
times := []int64{18800, 22801}
offset := int64(100)
response.LyricsList.StructuredLyrics = StructuredLyrics{
{
Lang: "eng",
DisplayArtist: "Rick Astley",
DisplayTitle: "Never Gonna Give You Up",
Offset: &offset,
Synced: true,
Line: []Line{
{
Start: &times[0],
Value: "We're no strangers to love",
},
{
Start: &times[1],
Value: "You know the rules and so do I",
},
},
},
{
Lang: "xxx",
DisplayArtist: "Rick Astley",
DisplayTitle: "Never Gonna Give You Up",
Offset: &offset,
Synced: false,
Line: []Line{
{
Value: "We're no strangers to love",
},
{
Value: "You know the rules and so do I",
},
},
},
}
})
It("should match .XML", func() {
Expect(xml.MarshalIndent(response, "", " ")).To(MatchSnapshot())
})
It("should match .JSON", func() {
Expect(json.MarshalIndent(response, "", " ")).To(MatchSnapshot())
})
})
})
})