fix(jellyfin): emit SortName for artists and albums, honoring PreferSortTags

Finamp's A-Z fast scroll re-derives each item's sort key client-side from
SortName, paging until it reaches the tapped letter. We only emitted SortName
for songs, so Finamp fell back to reconstructing a key from the display name,
which diverges from the order_* key the list is actually sorted by (curly
quotes, non-English articles). The scan then believed it had passed the
letter and scrolled back to the top instead of loading further pages.

Emit SortName for artists and albums using the same key the persistence
layer sorts by, via a sortName helper that mirrors Subsonic's PreferSortTags
handling: order_* names by default, sort tags when the config is enabled.
The song mapper now honors the config too, instead of always preferring the
sort tag. Also parse Fields in the /Artists* handlers, which ignored the
parameter entirely, so no field-gated data could ever be returned there.
This commit is contained in:
Deluan 2026-08-11 09:14:17 -04:00
parent 95b8d9dd04
commit c4126fa674
4 changed files with 81 additions and 5 deletions

View File

@ -35,6 +35,7 @@ func (api *Router) listArtistsByRole(w http.ResponseWriter, r *http.Request, rol
scopeIDs: scopeIDs,
genreIds: decodedQueryIDs(r, "genreids"),
search: searchTerm(p),
fields: dto.ParseFields(p.Strings("fields")...),
}
if q.search != "" {
opts.Max = clampLimit(opts.Max, defaultSearchLimit, maxSearchLimit)

View File

@ -10,6 +10,15 @@ import (
"github.com/navidrome/navidrome/utils/slice"
)
// sortName must match the persistence ORDER BY key (see setSortMappings): Finamp's A-Z jump
// scans SortName client-side, and any mismatch with the server's sort order scrolls to the top.
func sortName(sortTag, orderName, displayName string) string {
if conf.Server.PreferSortTags {
return cmp.Or(sortTag, orderName, displayName)
}
return cmp.Or(orderName, displayName)
}
// Jellyfin wire times are ticks: 100ns units, i.e. 10,000 per millisecond.
const ticksPerMillis = 10_000
@ -152,7 +161,7 @@ func SongToBaseItem(mf model.MediaFile, fields Fields) BaseItemDto {
item.MediaSources = []MediaSourceInfo{MediaSourceFromMediaFile(mf)}
}
if fields.Has("SortName") {
item.SortName = cmp.Or(mf.SortTitle, mf.OrderTitle, mf.Title)
item.SortName = sortName(mf.SortTitle, mf.OrderTitle, mf.Title)
}
// Real Jellyfin splits Artists/ArtistItems per track artist (AlbumArtists stays a single credit).
// Participants holds the per-artist list; fall back to the flattened display fields when absent.
@ -281,6 +290,9 @@ func AlbumToBaseItem(al model.Album, fields Fields) BaseItemDto {
// The album's own ReplayGain gain (dB at the RG2 -18 LUFS reference) — same
// convention as tracks; clients read it off the album item as NormalizationGain.
item.NormalizationGain = al.RGAlbumGain
if fields.Has("SortName") {
item.SortName = sortName(al.SortAlbumName, al.OrderAlbumName, al.Name)
}
return item
}
@ -302,6 +314,9 @@ func ArtistToBaseItem(ar model.Artist, fields Fields) BaseItemDto {
if tag != "" {
item.ImageTags = map[string]string{"Primary": tag}
}
if fields.Has("SortName") {
item.SortName = sortName(ar.SortArtistName, ar.OrderArtistName, ar.Name)
}
return item
}

View File

@ -46,10 +46,9 @@ var _ = Describe("mappers", func() {
mf := model.MediaFile{ID: "s1", Title: "Song", Size: 2_500_000, Suffix: "mp3", Duration: 60,
SortTitle: "sort song", Lyrics: `[{"line":[{"value":"la"}]}]`}
It("omits MediaSources and SortName when Fields does not ask for them", func() {
It("omits MediaSources when Fields does not ask for them", func() {
item := SongToBaseItem(mf, nil)
Expect(item.MediaSources).To(BeNil())
Expect(item.SortName).To(BeEmpty())
})
It("includes MediaSources only when Fields=MediaSources", func() {
@ -58,8 +57,50 @@ var _ = Describe("mappers", func() {
Expect(item.MediaSources[0].Size).To(Equal(int64(2_500_000)))
})
It("includes SortName (from the sort title) only when Fields=SortName", func() {
Expect(SongToBaseItem(mf, ParseFields("SortName")).SortName).To(Equal("sort song"))
// SortName must match the server sort order — see the sortName helper.
Describe("SortName", func() {
song := model.MediaFile{ID: "s1", Title: "The Song", SortTitle: "Song, The", OrderTitle: "song"}
ar := model.Artist{ID: "art-1", Name: "The B-52's", SortArtistName: "B-52's, The", OrderArtistName: "b-52's"}
al := model.Album{ID: "alb-1", Name: "The Wall", SortAlbumName: "Wall, The", OrderAlbumName: "wall"}
BeforeEach(func() {
DeferCleanup(configtest.SetupConfig())
})
It("is omitted unless Fields=SortName", func() {
Expect(SongToBaseItem(song, nil).SortName).To(BeEmpty())
Expect(ArtistToBaseItem(ar, nil).SortName).To(BeEmpty())
Expect(AlbumToBaseItem(al, nil).SortName).To(BeEmpty())
})
It("uses the order names by default, ignoring sort tags", func() {
Expect(SongToBaseItem(song, ParseFields("SortName")).SortName).To(Equal("song"))
Expect(ArtistToBaseItem(ar, ParseFields("SortName")).SortName).To(Equal("b-52's"))
Expect(AlbumToBaseItem(al, ParseFields("SortName")).SortName).To(Equal("wall"))
})
Context("with PreferSortTags", func() {
BeforeEach(func() {
conf.Server.PreferSortTags = true
})
It("prefers the sort tags", func() {
Expect(SongToBaseItem(song, ParseFields("SortName")).SortName).To(Equal("Song, The"))
Expect(ArtistToBaseItem(ar, ParseFields("SortName")).SortName).To(Equal("B-52's, The"))
Expect(AlbumToBaseItem(al, ParseFields("SortName")).SortName).To(Equal("Wall, The"))
})
It("falls back to the order name when there is no sort tag", func() {
Expect(ArtistToBaseItem(model.Artist{ID: "a", Name: "The X", OrderArtistName: "x"},
ParseFields("SortName")).SortName).To(Equal("x"))
})
})
It("falls back to the display name when order name and sort tag are empty", func() {
Expect(SongToBaseItem(model.MediaFile{ID: "s", Title: "T"}, ParseFields("SortName")).SortName).To(Equal("T"))
Expect(ArtistToBaseItem(model.Artist{ID: "a", Name: "N"}, ParseFields("SortName")).SortName).To(Equal("N"))
Expect(AlbumToBaseItem(model.Album{ID: "al", Name: "A"}, ParseFields("SortName")).SortName).To(Equal("A"))
})
})
It("sets HasLyrics from the media file's lyrics", func() {

View File

@ -2,6 +2,7 @@ package e2e
import (
"net/http"
"slices"
"sort"
"time"
@ -79,6 +80,24 @@ var _ = Describe("Browsing", func() {
Expect(names(q.Items)).To(ConsistOf("The Beatles", "Led Zeppelin", "Miles Davis", "Solo Artist"))
})
// Finamp's A-Z jump scans SortName client-side, so it must follow the response order.
It("returns artists' SortName matching the server sort order when Fields=SortName", func() {
plain := queryResult(get("/Artists/AlbumArtists?Recursive=true&SortBy=SortName"))
for _, it := range plain.Items {
Expect(it.SortName).To(BeEmpty())
}
q := queryResult(get("/Artists/AlbumArtists?Recursive=true&SortBy=SortName&Fields=SortName"))
Expect(q.Items).ToNot(BeEmpty())
sortNames := make([]string, 0, len(q.Items))
for _, it := range q.Items {
Expect(it.SortName).ToNot(BeEmpty())
sortNames = append(sortNames, it.SortName)
}
Expect(slices.IsSorted(sortNames)).To(BeTrue(), "SortName values must follow the response order: %v", sortNames)
// "The Beatles" must be filed under B, exposing the article-stripped key to clients.
Expect(sortNames).To(ContainElement("beatles"))
})
It("lists all genres", func() {
q := queryResult(get("/Items?IncludeItemTypes=MusicGenre&Recursive=true"))
Expect(q.TotalRecordCount).To(Equal(3))