From adf943fc87af120ee209c71f166f6d6d30023428 Mon Sep 17 00:00:00 2001 From: Deluan Date: Mon, 6 Jul 2026 08:52:14 -0400 Subject: [PATCH] fix(jellyfin): sort album tracks by track number when client sends IndexNumber SortBy Finamp's album view requests SortBy=ParentIndexNumber,IndexNumber,SortName (disc, track, name), but applySort didn't recognize ParentIndexNumber or IndexNumber and fell through to SortName, sorting tracks alphabetically by title. Map both keys to the album (disc+track) sort. The reversed-title fixture lets the e2e tell track order from title order. --- server/jellyfin/e2e/browsing_test.go | 9 ++++++++- server/jellyfin/e2e/e2e_suite_test.go | 8 +++++--- server/jellyfin/items.go | 21 +++++++++++++-------- server/jellyfin/items_test.go | 12 +++++++++++- 4 files changed, 37 insertions(+), 13 deletions(-) diff --git a/server/jellyfin/e2e/browsing_test.go b/server/jellyfin/e2e/browsing_test.go index bb67623fe..4bfa17bd3 100644 --- a/server/jellyfin/e2e/browsing_test.go +++ b/server/jellyfin/e2e/browsing_test.go @@ -79,10 +79,17 @@ var _ = Describe("Browsing", func() { It("browses an album's tracks in track order by default", func() { q := queryResult(get("/Items?IncludeItemTypes=Audio&ParentId=" + enc(albumID("Abbey Road")))) Expect(q.TotalRecordCount).To(Equal(2)) - Expect(names(q.Items)).To(Equal([]string{"Come Together", "Something"})) + // Track order (Something=1, Come Together=2) differs from alphabetical title order, + // proving the sort is by track number, not name. + Expect(names(q.Items)).To(Equal([]string{"Something", "Come Together"})) Expect(*q.Items[0].IndexNumber).To(Equal(1)) Expect(*q.Items[1].IndexNumber).To(Equal(2)) }) + + It("respects Finamp's explicit ParentIndexNumber/IndexNumber SortBy on an album", func() { + q := queryResult(get("/Items?IncludeItemTypes=Audio&ParentId=" + enc(albumID("Abbey Road")) + "&SortBy=ParentIndexNumber,IndexNumber,SortName")) + Expect(names(q.Items)).To(Equal([]string{"Something", "Come Together"})) + }) }) // Finamp's artist screen sends ParentId= (scoping) plus AlbumArtistIds/ArtistIds diff --git a/server/jellyfin/e2e/e2e_suite_test.go b/server/jellyfin/e2e/e2e_suite_test.go index 66e3553f8..20f3ca255 100644 --- a/server/jellyfin/e2e/e2e_suite_test.go +++ b/server/jellyfin/e2e/e2e_suite_test.go @@ -11,7 +11,7 @@ // // # Seeded library (see buildTestFS) // -// Rock/The Beatles/Abbey Road/ 01 Come Together (1969), 02 Something (1969) +// Rock/The Beatles/Abbey Road/ 01 Something (1969), 02 Come Together (1969) // Rock/The Beatles/Help!/ 01 Help! (1965) // Rock/Led Zeppelin/IV/ 01 Stairway To Heaven (1971) // Jazz/Miles Davis/Kind of Blue/01 So What (1959) @@ -120,8 +120,10 @@ func buildTestFS() storagetest.FakeFS { singles := template(_t{"albumartist": "Solo Artist", "artist": "Solo Artist", "album": "Singles", "year": 2020, "genre": "Pop"}) return createFS(fstest.MapFS{ - "Rock/The Beatles/Abbey Road/01 - Come Together.mp3": abbeyRoad(track(1, "Come Together")), - "Rock/The Beatles/Abbey Road/02 - Something.mp3": abbeyRoad(track(2, "Something")), + // Track numbers are deliberately reversed vs. alphabetical title order (Something=1, + // Come Together=2) so tests can tell track-order sorting apart from title sorting. + "Rock/The Beatles/Abbey Road/01 - Something.mp3": abbeyRoad(track(1, "Something")), + "Rock/The Beatles/Abbey Road/02 - Come Together.mp3": abbeyRoad(track(2, "Come Together")), "Rock/The Beatles/Help!/01 - Help.mp3": help(track(1, "Help!")), "Rock/Led Zeppelin/IV/01 - Stairway To Heaven.mp3": ledZepIV(track(1, "Stairway To Heaven")), "Jazz/Miles Davis/Kind of Blue/01 - So What.mp3": kindOfBlue(track(1, "So What")), diff --git a/server/jellyfin/items.go b/server/jellyfin/items.go index 9b379dee3..5e5b5fc02 100644 --- a/server/jellyfin/items.go +++ b/server/jellyfin/items.go @@ -410,14 +410,19 @@ func applySort(opts *model.QueryOptions, itemType, sortBy, order string) { var sortColumnsByType = map[string]map[string]string{ "Audio": { "sortname": "title", "name": "title", - "album": "album", - "artist": "artist", - "albumartist": "album_artist", - "datecreated": "recently_added", - "playcount": "play_count", - "dateplayed": "play_date", - "communityrating": "rating", - "random": "random", + "album": "album", + // Finamp's album view sorts by ParentIndexNumber,IndexNumber (disc, track). Navidrome's + // "album" sort key is order_album_name, album_id, disc_number, track_number, ..., which is + // exactly disc+track order within an album, so map both here. + "indexnumber": "album", + "parentindexnumber": "album", + "artist": "artist", + "albumartist": "album_artist", + "datecreated": "recently_added", + "playcount": "play_count", + "dateplayed": "play_date", + "communityrating": "rating", + "random": "random", }, "MusicArtist": { "sortname": "name", "name": "name", diff --git a/server/jellyfin/items_test.go b/server/jellyfin/items_test.go index 5ddcffb5f..0768a1bbf 100644 --- a/server/jellyfin/items_test.go +++ b/server/jellyfin/items_test.go @@ -288,12 +288,22 @@ var _ = Describe("Items", func() { mfRepo := ds.MediaFile(context.Background()).(*tests.MockMediaFileRepo) mfRepo.SetData(model.MediaFiles{{ID: "s1", Title: "Song"}}) w := httptest.NewRecorder() - r := httptest.NewRequest("GET", "/Items?IncludeItemTypes=Audio&SortBy=ParentIndexNumber,IndexNumber,SortName", nil).WithContext(ctxUser()) + r := httptest.NewRequest("GET", "/Items?IncludeItemTypes=Audio&SortBy=Unknown1,Unknown2,SortName", nil).WithContext(ctxUser()) api.getItems(w, r) Expect(w.Code).To(Equal(http.StatusOK)) Expect(mfRepo.Options.Sort).To(Equal("title")) }) + It("maps Finamp's album view SortBy (ParentIndexNumber,IndexNumber) to disc+track order", func() { + mfRepo := ds.MediaFile(context.Background()).(*tests.MockMediaFileRepo) + mfRepo.SetData(model.MediaFiles{{ID: "s1", Title: "Song"}}) + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", "/Items?IncludeItemTypes=Audio&SortBy=ParentIndexNumber,IndexNumber,SortName", nil).WithContext(ctxUser()) + api.getItems(w, r) + Expect(w.Code).To(Equal(http.StatusOK)) + Expect(mfRepo.Options.Sort).To(Equal("album")) + }) + It("leaves Sort at the repo default when no SortBy key is recognized", func() { albumRepo := ds.Album(context.Background()).(*tests.MockAlbumRepo) albumRepo.SetData(model.Albums{{ID: "a1", Name: "One"}})