fix(subsonic): Sort songs by presence of lyrics for getLyrics (#4237)

* fix(subsonic): Sort songs by presence of lyrics for `getLyrics`

The current implementation of `getLyrics` fetches any songs matching the artist and title.
However, this misses a case where there may be multiple matches for the same artist/song, and one has lyrics while the other doesn't.
Resolve this by adding a custom SQL dynamic column that checks for the presence of lyrics.

* add options to selectMediaFile, update test

* more robust testing of GetAllByLyrics

* fix(subsonic): refactor GetAllByLyrics to GetAll with lyrics sorting

Signed-off-by: Deluan <deluan@navidrome.org>

* use has_lyrics, and properly support multiple sort parts

* better handle complicated internal sorts

* just use a simpler filter

* add note to setSortMappings

* remove custom sort mapping, improve test with different updatedat

* refactor tests and mock

Signed-off-by: Deluan <deluan@navidrome.org>

* default order when not specified is `asc`

Signed-off-by: Deluan <deluan@navidrome.org>

---------

Signed-off-by: Deluan <deluan@navidrome.org>
Co-authored-by: Deluan <deluan@navidrome.org>
This commit is contained in:
Kendall Garner
2025-06-16 16:04:41 +00:00
committed by GitHub
parent 873905bdf6
commit 8d594671c4
7 changed files with 106 additions and 11 deletions
+25 -1
View File
@@ -35,7 +35,31 @@ var _ = Describe("MediaRepository", func() {
}) })
It("counts the number of mediafiles in the DB", func() { It("counts the number of mediafiles in the DB", func() {
Expect(mr.CountAll()).To(Equal(int64(4))) Expect(mr.CountAll()).To(Equal(int64(6)))
})
It("returns songs ordered by lyrics with a specific title/artist", func() {
// attempt to mimic filters.SongsByArtistTitleWithLyricsFirst, except we want all items
results, err := mr.GetAll(model.QueryOptions{
Sort: "lyrics, updated_at",
Order: "desc",
Filters: squirrel.And{
squirrel.Eq{"title": "Antenna"},
squirrel.Or{
Exists("json_tree(participants, '$.albumartist')", squirrel.Eq{"value": "Kraftwerk"}),
Exists("json_tree(participants, '$.artist')", squirrel.Eq{"value": "Kraftwerk"}),
},
},
})
Expect(err).To(BeNil())
Expect(results).To(HaveLen(3))
Expect(results[0].Lyrics).To(Equal(`[{"lang":"xxx","line":[{"value":"This is a set of lyrics"}],"synced":false}]`))
for _, item := range results[1:] {
Expect(item.Lyrics).To(Equal("[]"))
Expect(item.Title).To(Equal("Antenna"))
Expect(item.Participants[model.RoleArtist][0].Name).To(Equal("Kraftwerk"))
}
}) })
It("checks existence of mediafiles in the DB", func() { It("checks existence of mediafiles in the DB", func() {
+15 -1
View File
@@ -38,6 +38,9 @@ func mf(mf model.MediaFile) model.MediaFile {
model.Participant{Artist: model.Artist{ID: mf.ArtistID, Name: mf.Artist}}, model.Participant{Artist: model.Artist{ID: mf.ArtistID, Name: mf.Artist}},
}, },
} }
if mf.Lyrics == "" {
mf.Lyrics = "[]"
}
return mf return mf
} }
@@ -78,11 +81,22 @@ var (
Path: p("/kraft/radio/antenna.mp3"), Path: p("/kraft/radio/antenna.mp3"),
RGAlbumGain: 1.0, RGAlbumPeak: 2.0, RGTrackGain: 3.0, RGTrackPeak: 4.0, RGAlbumGain: 1.0, RGAlbumPeak: 2.0, RGTrackGain: 3.0, RGTrackPeak: 4.0,
}) })
testSongs = model.MediaFiles{ songAntennaWithLyrics = mf(model.MediaFile{
ID: "1005",
Title: "Antenna",
ArtistID: "2",
Artist: "Kraftwerk",
AlbumID: "103",
Lyrics: `[{"lang":"xxx","line":[{"value":"This is a set of lyrics"}],"synced":false}]`,
})
songAntenna2 = mf(model.MediaFile{ID: "1006", Title: "Antenna", ArtistID: "2", Artist: "Kraftwerk", AlbumID: "103"})
testSongs = model.MediaFiles{
songDayInALife, songDayInALife,
songComeTogether, songComeTogether,
songRadioactivity, songRadioactivity,
songAntenna, songAntenna,
songAntennaWithLyrics,
songAntenna2,
} }
) )
+4
View File
@@ -86,6 +86,10 @@ func (r *sqlRepository) registerModel(instance any, filters map[string]filterFun
// which gives precedence to sort tags. // which gives precedence to sort tags.
// Ex: order_title => (coalesce(nullif(sort_title,”),order_title) collate nocase) // Ex: order_title => (coalesce(nullif(sort_title,”),order_title) collate nocase)
// To avoid performance issues, indexes should be created for these sort expressions // To avoid performance issues, indexes should be created for these sort expressions
//
// NOTE: if an individual item has spaces, it should be wrapped in parentheses. For example,
// you should write "(lyrics != '[]')". This prevents the item being split unexpectedly.
// Without parentheses, "lyrics != '[]'" would be mapped as simply "lyrics"
func (r *sqlRepository) setSortMappings(mappings map[string]string, tableName ...string) { func (r *sqlRepository) setSortMappings(mappings map[string]string, tableName ...string) {
tn := r.tableName tn := r.tableName
if len(tableName) > 0 { if len(tableName) > 0 {
+12
View File
@@ -136,6 +136,10 @@ var _ = Describe("sqlRepository", func() {
}) })
Describe("buildSortOrder", func() { Describe("buildSortOrder", func() {
BeforeEach(func() {
r.sortMappings = map[string]string{}
})
Context("single field", func() { Context("single field", func() {
It("sorts by specified field", func() { It("sorts by specified field", func() {
sql := r.buildSortOrder("name", "desc") sql := r.buildSortOrder("name", "desc")
@@ -163,6 +167,14 @@ var _ = Describe("sqlRepository", func() {
sql := r.buildSortOrder("name desc, age, status asc", "desc") sql := r.buildSortOrder("name desc, age, status asc", "desc")
Expect(sql).To(Equal("name asc, age desc, status desc")) Expect(sql).To(Equal("name asc, age desc, status desc"))
}) })
It("handles spaces in mapped field", func() {
r.sortMappings = map[string]string{
"has_lyrics": "(lyrics != '[]'), updated_at",
}
sql := r.buildSortOrder("has_lyrics", "desc")
Expect(sql).To(Equal("(lyrics != '[]') desc, updated_at desc"))
})
}) })
Context("function fields", func() { Context("function fields", func() {
It("handles functions with multiple params", func() { It("handles functions with multiple params", func() {
+2 -2
View File
@@ -108,9 +108,9 @@ func SongsByRandom(genre string, fromYear, toYear int) Options {
return addDefaultFilters(options) return addDefaultFilters(options)
} }
func SongWithArtistTitle(artist, title string) Options { func SongsByArtistTitleWithLyricsFirst(artist, title string) Options {
return addDefaultFilters(Options{ return addDefaultFilters(Options{
Sort: "updated_at", Sort: "lyrics, updated_at",
Order: "desc", Order: "desc",
Max: 1, Max: 1,
Filters: And{ Filters: And{
+1 -1
View File
@@ -98,7 +98,7 @@ func (api *Router) GetLyrics(r *http.Request) (*responses.Subsonic, error) {
response := newResponse() response := newResponse()
lyricsResponse := responses.Lyrics{} lyricsResponse := responses.Lyrics{}
response.Lyrics = &lyricsResponse response.Lyrics = &lyricsResponse
mediaFiles, err := api.ds.MediaFile(r.Context()).GetAll(filter.SongWithArtistTitle(artist, title)) mediaFiles, err := api.ds.MediaFile(r.Context()).GetAll(filter.SongsByArtistTitleWithLyricsFirst(artist, title))
if err != nil { if err != nil {
return nil, err return nil, err
+47 -6
View File
@@ -2,11 +2,13 @@ package subsonic
import ( import (
"bytes" "bytes"
"cmp"
"context" "context"
"encoding/json" "encoding/json"
"errors" "errors"
"io" "io"
"net/http/httptest" "net/http/httptest"
"slices"
"time" "time"
"github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/conf"
@@ -84,12 +86,28 @@ var _ = Describe("MediaRetrievalController", func() {
}) })
Expect(err).ToNot(HaveOccurred()) Expect(err).ToNot(HaveOccurred())
baseTime := time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC)
mockRepo.SetData(model.MediaFiles{ mockRepo.SetData(model.MediaFiles{
{ {
ID: "1", ID: "2",
Artist: "Rick Astley", Artist: "Rick Astley",
Title: "Never Gonna Give You Up", Title: "Never Gonna Give You Up",
Lyrics: string(lyricsJson), Lyrics: "[]",
UpdatedAt: baseTime.Add(2 * time.Hour), // No lyrics, newer
},
{
ID: "1",
Artist: "Rick Astley",
Title: "Never Gonna Give You Up",
Lyrics: string(lyricsJson),
UpdatedAt: baseTime.Add(1 * time.Hour), // Has lyrics, older
},
{
ID: "3",
Artist: "Rick Astley",
Title: "Never Gonna Give You Up",
Lyrics: "[]",
UpdatedAt: baseTime.Add(3 * time.Hour), // No lyrics, newest
}, },
}) })
response, err := router.GetLyrics(r) response, err := router.GetLyrics(r)
@@ -122,6 +140,12 @@ var _ = Describe("MediaRetrievalController", func() {
Artist: "Rick Astley", Artist: "Rick Astley",
Title: "Never Gonna Give You Up", Title: "Never Gonna Give You Up",
}, },
{
Path: "tests/fixtures/test.mp3",
ID: "2",
Artist: "Rick Astley",
Title: "Never Gonna Give You Up",
},
}) })
response, err := router.GetLyrics(r) response, err := router.GetLyrics(r)
Expect(err).To(BeNil()) Expect(err).To(BeNil())
@@ -295,8 +319,25 @@ func (m *mockedMediaFile) SetData(mfs model.MediaFiles) {
m.data = mfs m.data = mfs
} }
func (m *mockedMediaFile) GetAll(...model.QueryOptions) (model.MediaFiles, error) { func (m *mockedMediaFile) GetAll(opts ...model.QueryOptions) (model.MediaFiles, error) {
return m.data, nil if len(opts) == 0 || opts[0].Sort != "lyrics, updated_at" {
return m.data, nil
}
// Hardcoded support for lyrics sorting
result := slices.Clone(m.data)
// Sort by presence of lyrics, then by updated_at. Respect the order specified in opts.
slices.SortFunc(result, func(a, b model.MediaFile) int {
diff := cmp.Or(
cmp.Compare(a.Lyrics, b.Lyrics),
cmp.Compare(a.UpdatedAt.Unix(), b.UpdatedAt.Unix()),
)
if opts[0].Order == "desc" {
return -diff
}
return diff
})
return result, nil
} }
func (m *mockedMediaFile) Get(id string) (*model.MediaFile, error) { func (m *mockedMediaFile) Get(id string) (*model.MediaFile, error) {