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

View File

@@ -35,7 +35,31 @@ var _ = Describe("MediaRepository", 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() {

View File

@@ -38,6 +38,9 @@ func mf(mf model.MediaFile) model.MediaFile {
model.Participant{Artist: model.Artist{ID: mf.ArtistID, Name: mf.Artist}},
},
}
if mf.Lyrics == "" {
mf.Lyrics = "[]"
}
return mf
}
@@ -78,11 +81,22 @@ var (
Path: p("/kraft/radio/antenna.mp3"),
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,
songComeTogether,
songRadioactivity,
songAntenna,
songAntennaWithLyrics,
songAntenna2,
}
)

View File

@@ -86,6 +86,10 @@ func (r *sqlRepository) registerModel(instance any, filters map[string]filterFun
// which gives precedence to sort tags.
// Ex: order_title => (coalesce(nullif(sort_title,”),order_title) collate nocase)
// 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) {
tn := r.tableName
if len(tableName) > 0 {

View File

@@ -136,6 +136,10 @@ var _ = Describe("sqlRepository", func() {
})
Describe("buildSortOrder", func() {
BeforeEach(func() {
r.sortMappings = map[string]string{}
})
Context("single field", func() {
It("sorts by specified field", func() {
sql := r.buildSortOrder("name", "desc")
@@ -163,6 +167,14 @@ var _ = Describe("sqlRepository", func() {
sql := r.buildSortOrder("name desc, age, status asc", "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() {
It("handles functions with multiple params", func() {