feat:(server): support reading lyrics from filesystem (#2897)

* simplified lyrics handling

* address initial feedback

* add some trace and error logging

* allow fallback lyrics

* update nit

* restore artist/title filter only
This commit is contained in:
Kendall Garner
2025-04-30 12:10:19 +00:00
committed by GitHub
parent 0d1f2bcc8a
commit ec9f9aa243
14 changed files with 391 additions and 13 deletions

View File

@@ -93,6 +93,7 @@ type configOptions struct {
PID pidOptions
Inspect inspectOptions
Subsonic subsonicOptions
LyricsPriority string
Agents string
LastFM lastfmOptions
@@ -528,6 +529,8 @@ func init() {
viper.SetDefault("inspect.backloglimit", consts.RequestThrottleBacklogLimit)
viper.SetDefault("inspect.backlogtimeout", consts.RequestThrottleBacklogTimeout)
viper.SetDefault("lyricspriority", ".lrc,.txt,embedded")
// DevFlags. These are used to enable/disable debugging and incomplete features
viper.SetDefault("devlogsourceline", false)
viper.SetDefault("devenableprofiler", false)

37
core/lyrics/lyrics.go Normal file
View File

@@ -0,0 +1,37 @@
package lyrics
import (
"context"
"strings"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model"
)
func GetLyrics(ctx context.Context, mf *model.MediaFile) (model.LyricList, error) {
var lyricsList model.LyricList
var err error
for pattern := range strings.SplitSeq(strings.ToLower(conf.Server.LyricsPriority), ",") {
pattern = strings.TrimSpace(pattern)
switch {
case pattern == "embedded":
lyricsList, err = fromEmbedded(ctx, mf)
case strings.HasPrefix(pattern, "."):
lyricsList, err = fromExternalFile(ctx, mf, pattern)
default:
log.Error(ctx, "Invalid lyric pattern", "pattern", pattern)
}
if err != nil {
log.Error(ctx, "error parsing lyrics", "source", pattern, err)
}
if len(lyricsList) > 0 {
return lyricsList, nil
}
}
return nil, nil
}

View File

@@ -0,0 +1,17 @@
package lyrics_test
import (
"testing"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/tests"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
func TestLyrics(t *testing.T) {
tests.Init(t, false)
log.SetLevel(log.LevelFatal)
RegisterFailHandler(Fail)
RunSpecs(t, "Lyrics Suite")
}

124
core/lyrics/lyrics_test.go Normal file
View File

@@ -0,0 +1,124 @@
package lyrics_test
import (
"context"
"encoding/json"
"os"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/conf/configtest"
"github.com/navidrome/navidrome/core/lyrics"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/utils"
"github.com/navidrome/navidrome/utils/gg"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
var _ = Describe("sources", func() {
var mf model.MediaFile
var ctx context.Context
const badLyrics = "This is a set of lyrics\nThat is not good"
unsynced, _ := model.ToLyrics("xxx", badLyrics)
embeddedLyrics := model.LyricList{*unsynced}
syncedLyrics := model.LyricList{
model.Lyrics{
DisplayArtist: "Rick Astley",
DisplayTitle: "That one song",
Lang: "eng",
Line: []model.Line{
{
Start: gg.P(int64(18800)),
Value: "We're no strangers to love",
},
{
Start: gg.P(int64(22801)),
Value: "You know the rules and so do I",
},
},
Offset: gg.P(int64(-100)),
Synced: true,
},
}
unsyncedLyrics := model.LyricList{
model.Lyrics{
Lang: "xxx",
Line: []model.Line{
{
Value: "We're no strangers to love",
},
{
Value: "You know the rules and so do I",
},
},
Synced: false,
},
}
BeforeEach(func() {
DeferCleanup(configtest.SetupConfig())
lyricsJson, _ := json.Marshal(embeddedLyrics)
mf = model.MediaFile{
Lyrics: string(lyricsJson),
Path: "tests/fixtures/test.mp3",
}
ctx = context.Background()
})
DescribeTable("Lyrics Priority", func(priority string, expected model.LyricList) {
conf.Server.LyricsPriority = priority
list, err := lyrics.GetLyrics(ctx, &mf)
Expect(err).To(BeNil())
Expect(list).To(Equal(expected))
},
Entry("embedded > lrc > txt", "embedded,.lrc,.txt", embeddedLyrics),
Entry("lrc > embedded > txt", ".lrc,embedded,.txt", syncedLyrics),
Entry("txt > lrc > embedded", ".txt,.lrc,embedded", unsyncedLyrics))
Context("Errors", func() {
var RegularUserContext = XContext
var isRegularUser = os.Getuid() != 0
if isRegularUser {
RegularUserContext = Context
}
RegularUserContext("run without root permissions", func() {
var accessForbiddenFile string
BeforeEach(func() {
accessForbiddenFile = utils.TempFileName("access_forbidden-", ".mp3")
f, err := os.OpenFile(accessForbiddenFile, os.O_WRONLY|os.O_CREATE, 0222)
Expect(err).ToNot(HaveOccurred())
mf.Path = accessForbiddenFile
DeferCleanup(func() {
Expect(f.Close()).To(Succeed())
Expect(os.Remove(accessForbiddenFile)).To(Succeed())
})
})
It("should fallback to embedded if an error happens when parsing file", func() {
conf.Server.LyricsPriority = ".mp3,embedded"
list, err := lyrics.GetLyrics(ctx, &mf)
Expect(err).To(BeNil())
Expect(list).To(Equal(embeddedLyrics))
})
It("should return nothing if error happens when trying to parse file", func() {
conf.Server.LyricsPriority = ".mp3"
list, err := lyrics.GetLyrics(ctx, &mf)
Expect(err).To(BeNil())
Expect(list).To(BeEmpty())
})
})
})
})

51
core/lyrics/sources.go Normal file
View File

@@ -0,0 +1,51 @@
package lyrics
import (
"context"
"errors"
"os"
"path"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model"
)
func fromEmbedded(ctx context.Context, mf *model.MediaFile) (model.LyricList, error) {
if mf.Lyrics != "" {
log.Trace(ctx, "embedded lyrics found in file", "title", mf.Title)
return mf.StructuredLyrics()
}
log.Trace(ctx, "no embedded lyrics for file", "path", mf.Title)
return nil, nil
}
func fromExternalFile(ctx context.Context, mf *model.MediaFile, suffix string) (model.LyricList, error) {
basePath := mf.AbsolutePath()
ext := path.Ext(basePath)
externalLyric := basePath[0:len(basePath)-len(ext)] + suffix
contents, err := os.ReadFile(externalLyric)
if errors.Is(err, os.ErrNotExist) {
log.Trace(ctx, "no lyrics found at path", "path", externalLyric)
return nil, nil
} else if err != nil {
return nil, err
}
lyrics, err := model.ToLyrics("xxx", string(contents))
if err != nil {
log.Error(ctx, "error parsing lyric external file", "path", externalLyric, err)
return nil, err
} else if lyrics == nil {
log.Trace(ctx, "empty lyrics from external file", "path", externalLyric)
return nil, nil
}
log.Trace(ctx, "retrieved lyrics from external file", "path", externalLyric)
return model.LyricList{*lyrics}, nil
}

112
core/lyrics/sources_test.go Normal file
View File

@@ -0,0 +1,112 @@
package lyrics
import (
"context"
"encoding/json"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/utils/gg"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
var _ = Describe("sources", func() {
ctx := context.Background()
Describe("fromEmbedded", func() {
It("should return nothing for a media file with no lyrics", func() {
mf := model.MediaFile{}
lyrics, err := fromEmbedded(ctx, &mf)
Expect(err).To(BeNil())
Expect(lyrics).To(HaveLen(0))
})
It("should return lyrics for a media file with well-formatted lyrics", 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"
synced, _ := model.ToLyrics("eng", syncedLyrics)
unsynced, _ := model.ToLyrics("xxx", unsyncedLyrics)
expectedList := model.LyricList{*synced, *unsynced}
lyricsJson, err := json.Marshal(expectedList)
Expect(err).ToNot(HaveOccurred())
mf := model.MediaFile{
Lyrics: string(lyricsJson),
}
lyrics, err := fromEmbedded(ctx, &mf)
Expect(err).To(BeNil())
Expect(lyrics).ToNot(BeNil())
Expect(lyrics).To(Equal(expectedList))
})
It("should return an error if somehow the JSON is bad", func() {
mf := model.MediaFile{Lyrics: "["}
lyrics, err := fromEmbedded(ctx, &mf)
Expect(lyrics).To(HaveLen(0))
Expect(err).ToNot(BeNil())
})
})
Describe("fromExternalFile", func() {
It("should return nil for lyrics that don't exist", func() {
mf := model.MediaFile{Path: "tests/fixtures/01 Invisible (RED) Edit Version.mp3"}
lyrics, err := fromExternalFile(ctx, &mf, ".lrc")
Expect(err).To(BeNil())
Expect(lyrics).To(HaveLen(0))
})
It("should return synchronized lyrics from a file", func() {
mf := model.MediaFile{Path: "tests/fixtures/test.mp3"}
lyrics, err := fromExternalFile(ctx, &mf, ".lrc")
Expect(err).To(BeNil())
Expect(lyrics).To(Equal(model.LyricList{
model.Lyrics{
DisplayArtist: "Rick Astley",
DisplayTitle: "That one song",
Lang: "eng",
Line: []model.Line{
{
Start: gg.P(int64(18800)),
Value: "We're no strangers to love",
},
{
Start: gg.P(int64(22801)),
Value: "You know the rules and so do I",
},
},
Offset: gg.P(int64(-100)),
Synced: true,
},
}))
})
It("should return unsynchronized lyrics from a file", func() {
mf := model.MediaFile{Path: "tests/fixtures/test.mp3"}
lyrics, err := fromExternalFile(ctx, &mf, ".txt")
Expect(err).To(BeNil())
Expect(lyrics).To(Equal(model.LyricList{
model.Lyrics{
Lang: "xxx",
Line: []model.Line{
{
Value: "We're no strangers to love",
},
{
Value: "You know the rules and so do I",
},
},
Synced: false,
},
}))
})
})
})

View File

@@ -32,7 +32,7 @@ var (
// Should either be at the beginning of file, or beginning of line
syncRegex = regexp.MustCompile(`(^|\n)\s*` + timeRegexString)
timeRegex = regexp.MustCompile(timeRegexString)
lrcIdRegex = regexp.MustCompile(`\[(ar|ti|offset):([^]]+)]`)
lrcIdRegex = regexp.MustCompile(`\[(ar|ti|offset|lang):([^]]+)]`)
)
func (l Lyrics) IsEmpty() bool {
@@ -72,6 +72,8 @@ func ToLyrics(language, text string) (*Lyrics, error) {
switch idTag[1] {
case "ar":
artist = str.SanitizeText(strings.TrimSpace(idTag[2]))
case "lang":
language = str.SanitizeText(strings.TrimSpace(idTag[2]))
case "offset":
{
off, err := strconv.ParseInt(strings.TrimSpace(idTag[2]), 10, 64)

View File

@@ -9,8 +9,9 @@ import (
var _ = Describe("ToLyrics", func() {
It("should parse tags with spaces", func() {
num := int64(1551)
lyrics, err := ToLyrics("xxx", "[offset: 1551 ]\n[ti: A title ]\n[ar: An artist ]\n[00:00.00]Hi there")
lyrics, err := ToLyrics("xxx", "[lang: eng ]\n[offset: 1551 ]\n[ti: A title ]\n[ar: An artist ]\n[00:00.00]Hi there")
Expect(err).ToNot(HaveOccurred())
Expect(lyrics.Lang).To(Equal("eng"))
Expect(lyrics.Synced).To(BeTrue())
Expect(lyrics.DisplayArtist).To(Equal("An artist"))
Expect(lyrics.DisplayTitle).To(Equal("A title"))

View File

@@ -4,11 +4,13 @@ import (
"testing"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/tests"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
func TestSubsonicApi(t *testing.T) {
tests.Init(t, false)
log.SetLevel(log.LevelFatal)
RegisterFailHandler(Fail)
RunSpecs(t, "Subsonic API Suite")

View File

@@ -108,12 +108,12 @@ func SongsByRandom(genre string, fromYear, toYear int) Options {
return addDefaultFilters(options)
}
func SongWithLyrics(artist, title string) Options {
func SongWithArtistTitle(artist, title string) Options {
return addDefaultFilters(Options{
Sort: "updated_at",
Order: "desc",
Max: 1,
Filters: And{Eq{"artist": artist, "title": title}, NotEq{"lyrics": ""}},
Filters: And{Eq{"artist": artist, "title": title}},
})
}

View File

@@ -9,6 +9,7 @@ import (
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/consts"
"github.com/navidrome/navidrome/core/lyrics"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/resources"
@@ -95,9 +96,9 @@ func (api *Router) GetLyrics(r *http.Request) (*responses.Subsonic, error) {
artist, _ := p.String("artist")
title, _ := p.String("title")
response := newResponse()
lyrics := responses.Lyrics{}
response.Lyrics = &lyrics
mediaFiles, err := api.ds.MediaFile(r.Context()).GetAll(filter.SongWithLyrics(artist, title))
lyricsResponse := responses.Lyrics{}
response.Lyrics = &lyricsResponse
mediaFiles, err := api.ds.MediaFile(r.Context()).GetAll(filter.SongWithArtistTitle(artist, title))
if err != nil {
return nil, err
@@ -107,7 +108,7 @@ func (api *Router) GetLyrics(r *http.Request) (*responses.Subsonic, error) {
return response, nil
}
structuredLyrics, err := mediaFiles[0].StructuredLyrics()
structuredLyrics, err := lyrics.GetLyrics(r.Context(), &mediaFiles[0])
if err != nil {
return nil, err
}
@@ -116,15 +117,15 @@ func (api *Router) GetLyrics(r *http.Request) (*responses.Subsonic, error) {
return response, nil
}
lyrics.Artist = artist
lyrics.Title = title
lyricsResponse.Artist = artist
lyricsResponse.Title = title
lyricsText := ""
for _, line := range structuredLyrics[0].Line {
lyricsText += line.Value + "\n"
}
lyrics.Value = lyricsText
lyricsResponse.Value = lyricsText
return response, nil
}
@@ -140,13 +141,13 @@ func (api *Router) GetLyricsBySongId(r *http.Request) (*responses.Subsonic, erro
return nil, err
}
lyrics, err := mediaFile.StructuredLyrics()
structuredLyrics, err := lyrics.GetLyrics(r.Context(), mediaFile)
if err != nil {
return nil, err
}
response := newResponse()
response.LyricsList = buildLyricsList(mediaFile, lyrics)
response.LyricsList = buildLyricsList(mediaFile, structuredLyrics)
return response, nil
}

View File

@@ -9,6 +9,8 @@ import (
"net/http/httptest"
"time"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/conf/configtest"
"github.com/navidrome/navidrome/core/artwork"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model"
@@ -32,6 +34,8 @@ var _ = Describe("MediaRetrievalController", func() {
artwork = &fakeArtwork{}
router = New(ds, artwork, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil)
w = httptest.NewRecorder()
DeferCleanup(configtest.SetupConfig())
conf.Server.LyricsPriority = "embedded,.lrc"
})
Describe("GetCoverArt", func() {
@@ -109,6 +113,22 @@ var _ = Describe("MediaRetrievalController", func() {
Expect(response.Lyrics.Title).To(Equal(""))
Expect(response.Lyrics.Value).To(Equal(""))
})
It("should return lyric file when finding mediafile with no embedded lyrics but present on filesystem", func() {
r := newGetRequest("artist=Rick+Astley", "title=Never+Gonna+Give+You+Up")
mockRepo.SetData(model.MediaFiles{
{
Path: "tests/fixtures/test.mp3",
ID: "1",
Artist: "Rick Astley",
Title: "Never Gonna Give You Up",
},
})
response, err := router.GetLyrics(r)
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\n"))
})
})
Describe("getLyricsBySongId", func() {

6
tests/fixtures/test.lrc vendored Normal file
View File

@@ -0,0 +1,6 @@
[ar:Rick Astley]
[ti:That one song]
[offset:-100]
[lang:eng]
[00:18.80]We're no strangers to love
[00:22.801]You know the rules and so do I

2
tests/fixtures/test.txt vendored Normal file
View File

@@ -0,0 +1,2 @@
We're no strangers to love
You know the rules and so do I