mirror of
https://github.com/navidrome/navidrome.git
synced 2026-08-31 07:30:32 +00:00
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:
parent
95b8d9dd04
commit
c4126fa674
@ -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)
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
|
||||
@ -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() {
|
||||
|
||||
@ -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))
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user