fix(jellyfin): split Artists/ArtistItems per track artist (#5810)

* fix(jellyfin): split Artists/ArtistItems per track artist

The Jellyfin API returned a single ArtistItems entry built from the flattened
display artist, so a multi-artist track (e.g. "De La Soul feat. Redman") lost
its individual artists and dropped every artist id but the first — while the
same track's OpenSubsonic response correctly split them. Real Jellyfin splits
Artists/ArtistItems one entry per track artist.

Build both from Participants[RoleArtist], which is already loaded on the search
and browse paths, falling back to the flattened display fields when absent.
AlbumArtists stays a single credit, matching real Jellyfin.

* fix(jellyfin): omit empty Artists in the fallback path

When a track has no participants and no display artist, the fallback set
Artists to a single-element slice holding an empty string. Only populate it
when the display artist is non-empty, so untagged tracks omit the field
instead of sending [""].
This commit is contained in:
Deluan Quintão
2026-07-18 18:16:52 -04:00
committed by GitHub
parent 9c7cf7d734
commit 09ac342f5a
2 changed files with 41 additions and 6 deletions

View File

@@ -6,6 +6,7 @@ import (
"time"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/utils/slice"
)
// Jellyfin wire times are ticks: 100ns units, i.e. 10,000 per millisecond.
@@ -139,7 +140,6 @@ func SongToBaseItem(mf model.MediaFile, fields Fields) BaseItemDto {
Album: mf.Album,
AlbumId: EncodeID(mf.AlbumID),
AlbumArtist: mf.AlbumArtist,
Artists: []string{mf.Artist},
RunTimeTicks: TicksFromSeconds(mf.Duration),
DateCreated: jellyfinDate(&mf.CreatedAt),
Container: mf.Suffix,
@@ -153,11 +153,20 @@ func SongToBaseItem(mf model.MediaFile, fields Fields) BaseItemDto {
if fields.Has("SortName") {
item.SortName = cmp.Or(mf.SortTitle, mf.OrderTitle, mf.Title)
}
// Finamp's Now Playing screen reads ArtistItems for the displayed artist (falling back to "Unknown
// Artist" if absent), even though Artists carries the same name. ArtistItems is the track artist;
// AlbumArtists the album artist.
if mf.ArtistID != "" {
item.ArtistItems = []NameGuidPair{{Name: mf.Artist, Id: EncodeID(mf.ArtistID)}}
// 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.
if artists := mf.Participants[model.RoleArtist]; len(artists) > 0 {
item.Artists = slice.Map(artists, func(p model.Participant) string { return p.Name })
item.ArtistItems = slice.Map(artists, func(p model.Participant) NameGuidPair {
return NameGuidPair{Name: p.Name, Id: EncodeID(p.ID)}
})
} else {
if mf.Artist != "" {
item.Artists = []string{mf.Artist}
}
if mf.ArtistID != "" {
item.ArtistItems = []NameGuidPair{{Name: mf.Artist, Id: EncodeID(mf.ArtistID)}}
}
}
if mf.AlbumArtistID != "" {
item.AlbumArtists = []NameGuidPair{{Name: mf.AlbumArtist, Id: EncodeID(mf.AlbumArtistID)}}

View File

@@ -99,6 +99,32 @@ var _ = Describe("mappers", func() {
Expect(SongToBaseItem(model.MediaFile{ID: "s1", Title: "Song", Artist: "X"}, nil).ArtistItems).To(BeNil())
})
It("omits Artists when the track has no artist name or participants", func() {
Expect(SongToBaseItem(model.MediaFile{ID: "s1", Title: "Song"}, nil).Artists).To(BeNil())
})
It("splits Artists and ArtistItems per track artist from Participants", func() {
mf := model.MediaFile{
ID: "s1", Title: "Oooh",
Artist: "De La Soul feat. Redman", ArtistID: "ar-delasoul",
AlbumArtist: "De La Soul", AlbumArtistID: "ar-delasoul",
}
mf.Participants = model.Participants{
model.RoleArtist: model.ParticipantList{
{Artist: model.Artist{ID: "ar-delasoul", Name: "De La Soul"}},
{Artist: model.Artist{ID: "ar-redman", Name: "Redman"}},
},
}
item := SongToBaseItem(mf, nil)
Expect(item.Artists).To(Equal([]string{"De La Soul", "Redman"}))
Expect(item.ArtistItems).To(Equal([]NameGuidPair{
{Name: "De La Soul", Id: EncodeID("ar-delasoul")},
{Name: "Redman", Id: EncodeID("ar-redman")},
}))
// AlbumArtists stays single, matching real Jellyfin.
Expect(item.AlbumArtists).To(Equal([]NameGuidPair{{Name: "De La Soul", Id: EncodeID("ar-delasoul")}}))
})
It("builds a MediaSourceInfo from a media file", func() {
mf := model.MediaFile{ID: "s1", Size: 5242880, Suffix: "mp3", BitRate: 320, Duration: 100}
src := MediaSourceFromMediaFile(mf)