fix(jellyfin): match Jellyfin's item payloads so strict clients can sync (#6151)

* fix(jellyfin): match Jellyfin's item payloads so strict clients can sync

Manet (iOS/macOS) aborted its whole library sync on the first item that was
missing a key its decoder requires, leaving the library empty (#6147). Every
gap was a field real Jellyfin always sends:

- dates now use .NET's round-trip layout with 7 fractional digits, which Manet
  requires and plain RFC3339 failed
- playlists carry SortName/DateCreated, and every item carries MediaType,
  ImageTags, ChannelId and, when Fields asks, Genres/GenreItems/Tags
- albums carry Artists and LocationType; songs always carry HasLyrics
- the library view is a full CollectionFolder (ChildCount, DateCreated,
  SortName, Path, LocationType, UserData), read from the library rows rather
  than the user projection, which has no counts
- IncludeItemTypes matches case-insensitively and returns nothing for Jellyfin
  kinds Navidrome has none of, instead of falling back to every album

Verified against a real Jellyfin 10.10.7 server and a live Manet client.

* refactor(jellyfin): fold the repeated empty-list defaults into one helper

The three mappers each initialised Genres/GenreItems/Tags the same way, and the
e2e suite grew three near-identical specs walking every item type. Both now go
through a single helper and one table.

* refactor(jellyfin): fill the always-present item fields at the serialization edge

The defaults real Jellyfin puts on every item were spread across three mappers,
so item types nobody had tested yet (playlists, genres, the library view) still
shipped payloads a strict client rejects. stampItem now takes the request's
Fields and fills them for every item, which is provably the only path to JSON.

Also drops the hand-copied BaseItemKind list: only an absent IncludeItemTypes
defaults to albums now, so any type Navidrome does not serve returns nothing,
as it would from Jellyfin. The synthetic playlists folder matches
case-insensitively like the rest, /Items/{libraryId} reads the full library row
instead of the count-less user projection, and PremiereDate and LastPlayedDate
go through jellyfinDate rather than spelling the layout out again.
This commit is contained in:
Deluan Quintão 2026-09-16 07:28:32 -04:00 • committed by GitHub
commit 18205366c8
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
11 changed files with 233 additions and 86 deletions

View file

@ -213,8 +213,7 @@ func (api *Router) ok(w http.ResponseWriter, r *http.Request, payload any) {
api.writeItems(w, r, materialized(p))
return
case dto.BaseItemDto:
p.ServerId = api.serverID(r.Context())
payload = p
payload = stampItem(p, api.serverID(r.Context()), requestFields(r))
}
w.Header().Set("Content-Type", "application/json; charset=utf-8")
if err := json.NewEncoder(w).Encode(payload); err != nil {

View file

@ -49,20 +49,23 @@ type BaseItemDto struct {
// PlaylistItemId identifies an entry within a playlist listing (GET /Playlists/{id}/Items),
// distinct from Id so a song appearing more than once can be removed by occurrence
// (DELETE .../Items?EntryIds=...) rather than by song id.
PlaylistItemId string `json:"PlaylistItemId,omitempty"`
Type string `json:"Type"`
IsFolder bool `json:"IsFolder"`
MediaType string `json:"MediaType,omitempty"`
CollectionType string `json:"CollectionType,omitempty"`
LocationType string `json:"LocationType,omitempty"`
HasLyrics bool `json:"HasLyrics,omitempty"`
SortName string `json:"SortName,omitempty"`
Path string `json:"Path,omitempty"`
ParentId string `json:"ParentId,omitempty"`
RunTimeTicks int64 `json:"RunTimeTicks,omitempty"`
IndexNumber *int `json:"IndexNumber,omitempty"`
ParentIndexNumber *int `json:"ParentIndexNumber,omitempty"`
ProductionYear *int `json:"ProductionYear,omitempty"`
PlaylistItemId string `json:"PlaylistItemId,omitempty"`
Type string `json:"Type"`
IsFolder bool `json:"IsFolder"`
MediaType string `json:"MediaType,omitempty"`
CollectionType string `json:"CollectionType,omitempty"`
LocationType string `json:"LocationType,omitempty"`
HasLyrics *bool `json:"HasLyrics,omitempty"`
// ChannelId is always null for music, but Jellyfin emits it on every item and clients may require it.
ChannelId *string `json:"ChannelId"`
Tags []string `json:"Tags,omitzero"`
SortName string `json:"SortName,omitempty"`
Path string `json:"Path,omitempty"`
ParentId string `json:"ParentId,omitempty"`
RunTimeTicks int64 `json:"RunTimeTicks,omitempty"`
IndexNumber *int `json:"IndexNumber,omitempty"`
ParentIndexNumber *int `json:"ParentIndexNumber,omitempty"`
ProductionYear *int `json:"ProductionYear,omitempty"`
// PremiereDate is the ISO 8601 release date; Finamp sorts "Latest Releases" by it client-side.
PremiereDate *string `json:"PremiereDate,omitempty"`
// DateCreated is the ISO 8601 date the item was added to the library; clients show it as
@ -73,17 +76,17 @@ type BaseItemDto struct {
AlbumArtist string `json:"AlbumArtist,omitempty"`
AlbumArtists []NameGuidPair `json:"AlbumArtists,omitempty"`
AlbumPrimaryImageTag string `json:"AlbumPrimaryImageTag,omitempty"`
Artists []string `json:"Artists,omitempty"`
Artists []string `json:"Artists,omitzero"`
ArtistItems []NameGuidPair `json:"ArtistItems,omitempty"`
Genres []string `json:"Genres,omitempty"`
GenreItems []NameGuidPair `json:"GenreItems,omitempty"`
Genres []string `json:"Genres,omitzero"`
GenreItems []NameGuidPair `json:"GenreItems,omitzero"`
Studios []NameGuidPair `json:"Studios,omitempty"`
NormalizationGain *float64 `json:"NormalizationGain,omitempty"`
AlbumNormalizationGain *float64 `json:"AlbumNormalizationGain,omitempty"`
ChildCount *int `json:"ChildCount,omitempty"`
SongCount *int `json:"SongCount,omitempty"`
AlbumCount *int `json:"AlbumCount,omitempty"`
ImageTags map[string]string `json:"ImageTags,omitempty"`
ImageTags map[string]string `json:"ImageTags"`
// ImageBlurHashes is keyed by image type (e.g. "Primary") then image tag. Finamp uses it as a
// de-dup key for image downloads (and a placeholder); absent, it warns the server isn't
// calculating blurhashes.

View file

@ -46,17 +46,24 @@ func premiereDate(date string, year int) *string {
}
d = fmt.Sprintf("%04d-01-01", year)
}
s := d + "T00:00:00Z"
parsed, err := time.Parse(time.DateOnly, d)
if err != nil {
return nil
}
s := jellyfinDate(&parsed)
return &s
}
// jellyfinDate formats t as the ISO 8601 string clients expect, or "" for the zero time so the
// Dates use .NET's round-trip layout, 7 fractional digits and all: Manet rejects plain RFC3339.
const jellyfinDateLayout = "2006-01-02T15:04:05.0000000Z07:00"
// jellyfinDate formats t as the date string clients expect, or "" for the zero time so the
// field is omitted rather than sent as a meaningless epoch.
func jellyfinDate(t *time.Time) string {
if t == nil || t.IsZero() {
return ""
}
return t.UTC().Format(time.RFC3339)
return t.UTC().Format(jellyfinDateLayout)
}
// channelLayout maps a channel count to the label Jellyfin clients expect on a MediaStream.
@ -127,8 +134,7 @@ func UserData(a model.Annotations, itemID string) *UserItemDataDto {
r := float64(a.Rating) * 2 // Navidrome 0-5 -> Jellyfin 0-10
d.Rating = &r
}
if a.PlayDate != nil {
s := a.PlayDate.UTC().Format(time.RFC3339)
if s := jellyfinDate(a.PlayDate); s != "" {
d.LastPlayedDate = &s
}
return d
@ -146,7 +152,7 @@ func SongToBaseItem(mf model.MediaFile, fields Fields) BaseItemDto {
MediaType: "Audio",
IsFolder: false,
LocationType: "FileSystem",
HasLyrics: mf.HasEmbeddedLyrics(),
HasLyrics: new(mf.HasEmbeddedLyrics()),
ParentId: albumID,
Album: mf.Album,
AlbumId: albumID,
@ -251,6 +257,7 @@ func AlbumToBaseItem(al model.Album, fields Fields) BaseItemDto {
Id: EncodeID(al.ID),
Type: "MusicAlbum",
IsFolder: true,
LocationType: "FileSystem",
ParentId: EncodeID(al.AlbumArtistID),
AlbumArtist: al.AlbumArtist,
Album: al.Name,
@ -266,6 +273,10 @@ func AlbumToBaseItem(al model.Album, fields Fields) BaseItemDto {
if tag != "" {
item.ImageTags = map[string]string{"Primary": tag}
}
item.Artists = []string{}
if al.AlbumArtist != "" {
item.Artists = append(item.Artists, al.AlbumArtist)
}
if al.AlbumArtistID != "" {
item.AlbumArtists = []NameGuidPair{{Name: al.AlbumArtist, Id: EncodeID(al.AlbumArtistID)}}
item.ArtistItems = item.AlbumArtists
@ -321,6 +332,26 @@ func ArtistToBaseItem(ar model.Artist, fields Fields) BaseItemDto {
return item
}
// LibraryToBaseItem maps a library to the CollectionFolder item clients browse as a top-level node.
// Manet keeps no library, and so syncs nothing, unless it carries the fields Jellyfin sends here.
func LibraryToBaseItem(lib model.Library) BaseItemDto {
id := EncodeLibraryID(lib.ID)
return BaseItemDto{
Id: id,
Name: lib.Name,
SortName: lib.Name,
Type: "CollectionFolder",
CollectionType: "music",
IsFolder: true,
Path: lib.Path,
LocationType: "FileSystem",
DateCreated: jellyfinDate(&lib.CreatedAt),
ChildCount: new(lib.TotalAlbums),
UserData: &UserItemDataDto{Key: id, ItemId: id},
BackdropImageTags: []string{},
}
}
func GenreToBaseItem(g model.Genre) BaseItemDto {
return BaseItemDto{
Name: g.Name,
@ -354,6 +385,7 @@ func PlaylistToBaseItem(p model.Playlist, fields Fields) BaseItemDto {
MediaType: "Audio",
ChildCount: new(p.SongCount),
RunTimeTicks: TicksFromSeconds(p.Duration),
DateCreated: jellyfinDate(&p.CreatedAt),
ImageBlurHashes: blurs,
PrimaryImageAspectRatio: ratio,
BackdropImageTags: []string{},
@ -362,6 +394,10 @@ func PlaylistToBaseItem(p model.Playlist, fields Fields) BaseItemDto {
if tag != "" {
item.ImageTags = map[string]string{"Primary": tag}
}
// Playlists have no sort tag; the repository orders them by name.
if fields.Has("SortName") {
item.SortName = p.Name
}
return item
}

View file

@ -104,10 +104,10 @@ var _ = Describe("mappers", func() {
})
It("sets HasLyrics from the media file's lyrics", func() {
Expect(SongToBaseItem(mf, nil).HasLyrics).To(BeTrue())
Expect(SongToBaseItem(model.MediaFile{ID: testID("s2"), Title: "No Lyrics"}, nil).HasLyrics).To(BeFalse())
Expect(*SongToBaseItem(mf, nil).HasLyrics).To(BeTrue())
Expect(*SongToBaseItem(model.MediaFile{ID: testID("s2"), Title: "No Lyrics"}, nil).HasLyrics).To(BeFalse())
// "[]" is the no-lyrics sentinel, not a truthy value.
Expect(SongToBaseItem(model.MediaFile{ID: testID("s3"), Title: "Empty Lyrics", Lyrics: "[]"}, nil).HasLyrics).To(BeFalse())
Expect(*SongToBaseItem(model.MediaFile{ID: testID("s3"), Title: "Empty Lyrics", Lyrics: "[]"}, nil).HasLyrics).To(BeFalse())
})
})
@ -120,7 +120,7 @@ var _ = Describe("mappers", func() {
It("sets DateCreated from the media file's CreatedAt", func() {
mf := model.MediaFile{ID: testID("s1"), Title: "Song", CreatedAt: time.Date(2024, 1, 15, 10, 30, 0, 0, time.UTC)}
Expect(SongToBaseItem(mf, nil).DateCreated).To(Equal("2024-01-15T10:30:00Z"))
Expect(SongToBaseItem(mf, nil).DateCreated).To(Equal("2024-01-15T10:30:00.0000000Z"))
})
It("omits DateCreated when CreatedAt is the zero time", func() {
@ -282,7 +282,7 @@ var _ = Describe("mappers", func() {
mf.PlayDate = &playDate
item := SongToBaseItem(mf, nil)
Expect(item.UserData.LastPlayedDate).NotTo(BeNil())
Expect(*item.UserData.LastPlayedDate).To(Equal(playDate.Format(time.RFC3339)))
Expect(*item.UserData.LastPlayedDate).To(Equal("2023-05-17T12:30:00.0000000Z"))
})
It("maps an album to a MusicAlbum folder item", func() {
@ -299,6 +299,9 @@ var _ = Describe("mappers", func() {
Expect(*item.ChildCount).To(Equal(10))
Expect(item.ImageTags).To(HaveKeyWithValue("Primary", testID("alb-1")))
Expect(item.ImageBlurHashes).To(BeNil())
// Jellyfin sends both on every album; Manet stops its sync on an album without them.
Expect(item.Artists).To(Equal([]string{"AA"}))
Expect(item.LocationType).To(Equal("FileSystem"))
Expect(item.Genres).To(Equal([]string{"genre 1", "genre 2"}))
Expect(item.GenreItems).To(Equal([]NameGuidPair{{Id: EncodeID(testID("1")), Name: "genre 1"}, {Id: EncodeID(testID("2")), Name: "genre 2"}}))
})
@ -426,22 +429,22 @@ var _ = Describe("mappers", func() {
It("serializes a full date", func() {
mf := model.MediaFile{ID: testID("s1"), Title: "Song", Date: "2007-02-01", Year: 2007}
item := SongToBaseItem(mf, nil)
Expect(*item.PremiereDate).To(Equal("2007-02-01T00:00:00Z"))
Expect(*item.PremiereDate).To(Equal("2007-02-01T00:00:00.0000000Z"))
})
It("pads a year-only date so clients can parse it", func() {
mf := model.MediaFile{ID: testID("s1"), Title: "Song", Date: "2007", Year: 2007}
Expect(*SongToBaseItem(mf, nil).PremiereDate).To(Equal("2007-01-01T00:00:00Z"))
Expect(*SongToBaseItem(mf, nil).PremiereDate).To(Equal("2007-01-01T00:00:00.0000000Z"))
})
It("pads a year-month date", func() {
mf := model.MediaFile{ID: testID("s1"), Title: "Song", Date: "2007-02"}
Expect(*SongToBaseItem(mf, nil).PremiereDate).To(Equal("2007-02-01T00:00:00Z"))
Expect(*SongToBaseItem(mf, nil).PremiereDate).To(Equal("2007-02-01T00:00:00.0000000Z"))
})
It("falls back to the year when no date tag exists", func() {
mf := model.MediaFile{ID: testID("s1"), Title: "Song", Year: 1999}
Expect(*SongToBaseItem(mf, nil).PremiereDate).To(Equal("1999-01-01T00:00:00Z"))
Expect(*SongToBaseItem(mf, nil).PremiereDate).To(Equal("1999-01-01T00:00:00.0000000Z"))
})
It("is omitted when the track has no date at all", func() {
@ -449,8 +452,8 @@ var _ = Describe("mappers", func() {
})
It("is set on albums from their date, falling back to MaxYear", func() {
Expect(*AlbumToBaseItem(model.Album{ID: testID("a1"), Date: "2013-09-06"}, nil).PremiereDate).To(Equal("2013-09-06T00:00:00Z"))
Expect(*AlbumToBaseItem(model.Album{ID: testID("a2"), MaxYear: 2013}, nil).PremiereDate).To(Equal("2013-01-01T00:00:00Z"))
Expect(*AlbumToBaseItem(model.Album{ID: testID("a1"), Date: "2013-09-06"}, nil).PremiereDate).To(Equal("2013-09-06T00:00:00.0000000Z"))
Expect(*AlbumToBaseItem(model.Album{ID: testID("a2"), MaxYear: 2013}, nil).PremiereDate).To(Equal("2013-01-01T00:00:00.0000000Z"))
Expect(AlbumToBaseItem(model.Album{ID: testID("a3")}, nil).PremiereDate).To(BeNil())
})
})
@ -475,6 +478,13 @@ var _ = Describe("mappers", func() {
Expect(item.ImageBlurHashes).To(BeNil())
})
It("emits DateCreated and SortName on playlists, like albums and artists", func() {
p := model.Playlist{ID: testID("pl-1"), Name: "Chill", CreatedAt: time.Date(2026, 7, 1, 12, 0, 0, 0, time.UTC)}
Expect(PlaylistToBaseItem(p, nil).DateCreated).To(Equal("2026-07-01T12:00:00.0000000Z"))
Expect(PlaylistToBaseItem(p, nil).SortName).To(BeEmpty())
Expect(PlaylistToBaseItem(p, ParseFields("SortName")).SortName).To(Equal("Chill"))
})
It("changes the playlist image tag when the cover content changes", func() {
p := model.Playlist{ID: testID("pl-1"), Name: "Chill"}
p.ImageHash = "1111111111111111"

View file

@ -1,6 +1,7 @@
package e2e
import (
"encoding/json"
"net/http"
"slices"
"sort"
@ -110,10 +111,46 @@ var _ = Describe("Browsing", func() {
Expect(q.Items).To(BeEmpty())
})
It("defaults to albums when IncludeItemTypes is unrecognized", func() {
q := queryResult(get("/Items?IncludeItemTypes=Nonsense&Recursive=true"))
Expect(q.TotalRecordCount).To(Equal(5))
// Manet syncs collections as Boxset, and took albums coming back instead as a sync failure.
DescribeTable("returns nothing for a type it does not serve",
func(itemType string) {
q := queryResult(get("/Items?IncludeItemTypes=" + itemType + "&Recursive=true"))
Expect(q.TotalRecordCount).To(Equal(0))
Expect(q.Items).To(BeEmpty())
},
Entry("a Jellyfin kind Navidrome has none of", "Boxset"),
Entry("a name Jellyfin does not know either", "Nonsense"),
)
// A strict client (Manet) fails its whole sync on the first item missing any of these.
DescribeTable("sends the keys Jellyfin puts on every item",
func(itemType string, fields string, nonNull ...string) {
var body struct{ Items []map[string]json.RawMessage }
res := get("/Items?IncludeItemTypes=" + itemType + "&Fields=" + fields + "&Recursive=true")
Expect(json.Unmarshal(res.Body.Bytes(), &body)).To(Succeed())
Expect(body.Items).ToNot(BeEmpty())
for _, it := range body.Items {
Expect(it).To(HaveKey("ChannelId"), "ChannelId is null, but always present")
for _, k := range nonNull {
Expect(it).To(HaveKey(k))
Expect(string(it[k])).ToNot(Equal("null"), k)
}
}
},
Entry("songs", "Audio", "Genres,Tags", "ImageTags", "HasLyrics", "Genres", "GenreItems", "Tags"),
Entry("albums", "MusicAlbum", "Genres", "ImageTags", "Genres", "GenreItems"),
Entry("artists", "MusicArtist", "Genres", "ImageTags", "Genres", "GenreItems"),
)
It("sends MediaType Unknown on items without one, as Jellyfin always emits it", func() {
q := queryResult(get("/Items?IncludeItemTypes=MusicAlbum&Recursive=true"))
Expect(q.Items).ToNot(BeEmpty())
for _, it := range q.Items {
Expect(it.MediaType).To(Equal("Unknown"))
}
Expect(queryResult(get("/Items?IncludeItemTypes=Audio&Recursive=true")).Items[0].MediaType).To(Equal("Audio"))
})
})
Describe("ParentId browsing", func() {

View file

@ -58,14 +58,14 @@ var _ = Describe("Lyrics", func() {
})
Describe("HasLyrics badge", func() {
It("is true for a track with embedded lyrics and omitted/false otherwise", func() {
It("is true for a track with embedded lyrics and false otherwise", func() {
var stairway dto.BaseItemDto
parseInto(get("/Items/"+enc(songID("Stairway To Heaven"))), &stairway)
Expect(stairway.HasLyrics).To(BeTrue())
Expect(*stairway.HasLyrics).To(BeTrue())
var soWhat dto.BaseItemDto
parseInto(get("/Items/"+enc(songID("So What"))), &soWhat)
Expect(soWhat.HasLyrics).To(BeFalse())
Expect(*soWhat.HasLyrics).To(BeFalse())
})
})
})

View file

@ -219,11 +219,40 @@ func (api *Router) writeItemsArray(w http.ResponseWriter, r *http.Request, res i
api.streamResult(w, r, res, streamItemsArray)
}
// streamResult stamps every item's ServerId (constant per request, so it's set here rather than in
// each mapper). The cursor opens before the first byte, so a failed open is still a clean 500.
// stampItem fills in what real Jellyfin puts on every item it returns, so a client that requires a
// key never meets an item without it: ServerId, MediaType, ImageTags, and the Fields-gated lists.
func stampItem(it dto.BaseItemDto, serverID string, fields dto.Fields) dto.BaseItemDto {
it.ServerId = serverID
if it.MediaType == "" {
it.MediaType = "Unknown"
}
if it.ImageTags == nil {
it.ImageTags = map[string]string{}
}
if fields.Has("Genres") {
if it.Genres == nil {
it.Genres = []string{}
}
if it.GenreItems == nil {
it.GenreItems = []dto.NameGuidPair{}
}
}
if fields.Has("Tags") && it.Tags == nil {
it.Tags = []string{}
}
return it
}
// requestFields parses the Fields param, which gates what stampItem and the mappers attach.
func requestFields(r *http.Request) dto.Fields {
return dto.ParseFields(req.Params(r).Strings("fields")...)
}
// streamResult stamps every item (see stampItem). The cursor opens before the first byte, so a
// failed open is still a clean 500.
func (api *Router) streamResult(w http.ResponseWriter, r *http.Request, res itemsResult,
write func(io.Writer, iter.Seq2[dto.BaseItemDto, error]) error) {
sid := api.serverID(r.Context())
sid, fields := api.serverID(r.Context()), requestFields(r)
seq, err := res.seq()
if err != nil {
api.internalError(w, r, err)
@ -235,8 +264,7 @@ func (api *Router) streamResult(w http.ResponseWriter, r *http.Request, res item
yield(dto.BaseItemDto{}, err)
return
}
it.ServerId = sid
if !yield(it, nil) {
if !yield(stampItem(it, sid, fields), nil) {
return
}
}
@ -380,7 +408,7 @@ func (api *Router) queryItems(ctx context.Context, r *http.Request) (itemsResult
case len(q.ids) > 0:
return materialized(api.itemsByIDs(ctx, q.ids, q.fields)), nil
// A ManualPlaylistsFolder query asks for the synthetic "playlists library" container, not real items.
case strings.Contains(q.rawTypes, "ManualPlaylistsFolder"):
case strings.Contains(strings.ToLower(q.rawTypes), "manualplaylistsfolder"):
return materialized(result([]dto.BaseItemDto{playlistsFolder()}, 1, 0)), nil
}
if repo, ok := api.playlistTracksRepo(ctx, q); ok {
@ -559,23 +587,26 @@ func parseYears(r *http.Request) []int {
return years
}
// parseTypes returns the recognized entries in IncludeItemTypes in order, defaulting to
// {"MusicAlbum"} when none are recognized (so ParentId=<artistId> browses that artist's albums).
// supportedTypes maps lowercased IncludeItemTypes names (Jellyfin binds them case-insensitively)
// to the item types Navidrome serves.
var supportedTypes = map[string]string{
"audio": "Audio", "musicartist": "MusicArtist", "musicalbum": "MusicAlbum", "musicgenre": "MusicGenre", "playlist": "Playlist",
}
// parseTypes returns the supported entries in IncludeItemTypes in order. Only an absent param
// defaults to albums, so ParentId=<artistId> still browses that artist's albums.
func parseTypes(types string) []string {
if strings.TrimSpace(types) == "" {
return []string{"MusicAlbum"}
}
var recognized []string
for t := range strings.SplitSeq(types, ",") {
t = strings.TrimSpace(t)
switch t {
case "Audio", "MusicArtist", "MusicAlbum", "MusicGenre", "Playlist":
recognized = append(recognized, t)
if name, ok := supportedTypes[strings.ToLower(strings.TrimSpace(t))]; ok {
recognized = append(recognized, name)
}
}
// Dedupe: a repeated type would duplicate items in the merge and spawn a redundant query.
recognized = slice.Unique(recognized)
if len(recognized) == 0 {
return []string{"MusicAlbum"}
}
return recognized
return slice.Unique(recognized)
}
// paginate applies StartIndex/Limit to an in-memory item list, for the multi-type merge path only
@ -828,14 +859,8 @@ func (api *Router) resolveItemByID(ctx context.Context, id string, fields dto.Fi
// Finamp resolves a /UserViews entry (Id=library id) by fetching it as a plain item; without this
// the home screen and library tabs 404.
if libID, err := strconv.Atoi(id); err == nil && u.HasLibraryAccess(libID) {
for _, lib := range u.Libraries {
if lib.ID == libID {
return libraryView(lib), true
}
}
// Admin bypass: Libraries is empty but all access is granted, so fetch the real library.
if lib, err := api.ds.Library(ctx).Get(libID); err == nil {
return libraryView(*lib), true
return dto.LibraryToBaseItem(*lib), true
}
}
if al, err := api.ds.Album(ctx).Get(id); err == nil {

View file

@ -981,6 +981,7 @@ var _ = Describe("Items", func() {
It("resolves a library-view id (from /UserViews) as a CollectionFolder item", func() {
w := httptest.NewRecorder()
libs := model.Libraries{{ID: 1, Name: "Music Library"}}
ds.Library(context.Background()).(*tests.MockLibraryRepo).SetData(libs)
r := httptest.NewRequest("GET", "/Items/"+dto.EncodeLibraryID(1), nil).WithContext(ctxUserWithLibraries(libs))
r = withChiURLParam(r, "itemId", dto.EncodeLibraryID(1))
invoke(api.getItem, w, r)
@ -1124,6 +1125,20 @@ var _ = Describe("Items", func() {
It("dedupes repeated types, preserving first-seen order", func() {
Expect(parseTypes("Audio,MusicAlbum,Audio")).To(Equal([]string{"Audio", "MusicAlbum"}))
})
It("matches type names case-insensitively, like Jellyfin's enum binding", func() {
Expect(parseTypes("musicalbum, AUDIO")).To(Equal([]string{"MusicAlbum", "Audio"}))
})
It("returns no types for real Jellyfin kinds Navidrome has none of", func() {
Expect(parseTypes("Boxset")).To(BeEmpty())
Expect(parseTypes("BoxSet,Movie")).To(BeEmpty())
})
It("defaults to albums only when IncludeItemTypes is absent", func() {
Expect(parseTypes("")).To(Equal([]string{"MusicAlbum"}))
Expect(parseTypes("Nonsense")).To(BeEmpty())
})
})
Describe("decodeFilterParam", func() {

View file

@ -7,7 +7,6 @@ import (
"github.com/Masterminds/squirrel"
"github.com/go-chi/chi/v5"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/model/request"
"github.com/navidrome/navidrome/server/jellyfin/dto"
"github.com/navidrome/navidrome/utils/req"
@ -74,16 +73,3 @@ func libraryScopeFilter(scope []int) squirrel.Sqlizer {
}
return squirrel.Eq{"library_tag.library_id": scope}
}
// libraryView builds the CollectionFolder BaseItemDto representing a library as a top-level node.
// Shared by getUserViews and getItem, since Finamp fetches a UserView's id as a plain item.
func libraryView(lib model.Library) dto.BaseItemDto {
return dto.BaseItemDto{
Id: dto.EncodeLibraryID(lib.ID),
Name: lib.Name,
Type: "CollectionFolder",
CollectionType: "music",
IsFolder: true,
BackdropImageTags: []string{},
}
}

View file

@ -13,10 +13,20 @@ import (
// getUserViews returns one CollectionFolder view per accessible library, so clients browse each
// library as its own top-level view rather than one aggregate.
func (api *Router) getUserViews(w http.ResponseWriter, r *http.Request) {
u, _ := request.UserFrom(r.Context())
views := make([]dto.BaseItemDto, 0, len(u.Libraries))
for _, lib := range u.Libraries {
views = append(views, libraryView(lib))
ctx := r.Context()
u, _ := request.UserFrom(ctx)
// u.Libraries comes from a projection without counts or stats, and clients hide a library that
// looks empty, so the rows are re-read in full here.
libs, err := api.ds.Library(ctx).GetAll()
if err != nil {
api.internalError(w, r, err)
return
}
views := make([]dto.BaseItemDto, 0, len(libs))
for _, lib := range libs {
if u.HasLibraryAccess(lib.ID) {
views = append(views, dto.LibraryToBaseItem(lib))
}
}
api.ok(w, r, dto.QueryResult{Items: views, TotalRecordCount: len(views), StartIndex: 0})
}

View file

@ -5,6 +5,7 @@ import (
"encoding/json"
"net/http"
"net/http/httptest"
"time"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/conf/configtest"
@ -18,8 +19,14 @@ import (
var _ = Describe("Users", func() {
var api *Router
// The repo holds the full rows; the user carries the id/name-only copy its projection returns.
authedWithLibraries := func(r *http.Request, libs model.Libraries) *http.Request {
ctx := request.WithUser(context.Background(), model.User{ID: testID("u1"), UserName: "alice", Libraries: libs})
api.ds.Library(context.Background()).(*tests.MockLibraryRepo).SetData(libs)
stripped := make(model.Libraries, len(libs))
for i, lib := range libs {
stripped[i] = model.Library{ID: lib.ID, Name: lib.Name}
}
ctx := request.WithUser(context.Background(), model.User{ID: testID("u1"), UserName: "alice", Libraries: stripped})
return r.WithContext(ctx)
}
BeforeEach(func() { api = &Router{ds: &tests.MockDataStore{}} })
@ -45,6 +52,25 @@ var _ = Describe("Users", func() {
Expect(res.Items[1].Name).To(Equal("Podcasts"))
})
// Manet keeps no library, and so syncs no artists/albums/tracks, when these are missing.
It("describes the library like Jellyfin's CollectionFolder", func() {
created := time.Date(2026, 7, 1, 12, 0, 0, 0, time.UTC)
libs := model.Libraries{{ID: 1, Name: "Music", Path: "/music", TotalAlbums: 42, CreatedAt: created}}
w := httptest.NewRecorder()
api.getUserViews(w, authedWithLibraries(httptest.NewRequest("GET", "/UserViews", nil), libs))
var res dto.QueryResult
Expect(json.Unmarshal(w.Body.Bytes(), &res)).To(Succeed())
item := res.Items[0]
Expect(*item.ChildCount).To(Equal(42))
Expect(item.DateCreated).To(Equal("2026-07-01T12:00:00.0000000Z"))
Expect(item.SortName).To(Equal("Music"))
Expect(item.Path).To(Equal("/music"))
Expect(item.LocationType).To(Equal("FileSystem"))
Expect(item.UserData).ToNot(BeNil())
Expect(item.UserData.ItemId).To(Equal(dto.EncodeLibraryID(1)))
})
It("returns a single view for a user with one library", func() {
libs := model.Libraries{{ID: 1, Name: "Music"}}
w := httptest.NewRecorder()