diff --git a/server/jellyfin/dto/dto.go b/server/jellyfin/dto/dto.go index 9b2c35c69..5a2a4c2b1 100644 --- a/server/jellyfin/dto/dto.go +++ b/server/jellyfin/dto/dto.go @@ -81,12 +81,15 @@ type BaseItemDto struct { // 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. - ImageBlurHashes map[string]map[string]string `json:"ImageBlurHashes,omitempty"` - BackdropImageTags []string `json:"BackdropImageTags"` - UserData *UserItemDataDto `json:"UserData,omitempty"` - MediaSources []MediaSourceInfo `json:"MediaSources,omitempty"` - Container string `json:"Container,omitempty"` - CanDownload bool `json:"CanDownload"` + ImageBlurHashes map[string]map[string]string `json:"ImageBlurHashes,omitempty"` + // PrimaryImageAspectRatio is width/height of the Primary image, attached only when the request's + // Fields asks for it; omitted rather than guessed, since a wrong ratio mis-shapes a placeholder. + PrimaryImageAspectRatio *float64 `json:"PrimaryImageAspectRatio,omitempty"` + BackdropImageTags []string `json:"BackdropImageTags"` + UserData *UserItemDataDto `json:"UserData,omitempty"` + MediaSources []MediaSourceInfo `json:"MediaSources,omitempty"` + Container string `json:"Container,omitempty"` + CanDownload bool `json:"CanDownload"` } // PlaylistUserPermissions is the response shape for GET /Playlists/{id}/Users(/{userId}), which diff --git a/server/jellyfin/dto/mappers.go b/server/jellyfin/dto/mappers.go index 1beadac11..08f12760c 100644 --- a/server/jellyfin/dto/mappers.go +++ b/server/jellyfin/dto/mappers.go @@ -195,9 +195,10 @@ func SongToBaseItem(mf model.MediaFile, fields Fields) BaseItemDto { } // A track's own cover wins in Finamp's precedence (ImageTags.Primary before AlbumId). if mf.ImageHash != "" && mf.ImageHash != mf.AlbumImage.ImageHash { - tag, blurs := primaryImageTag(mf.ItemImage, mf.ID) + tag, blurs, ratio := primaryImage(mf.ItemImage, mf.ID, fields) item.ImageTags = map[string]string{"Primary": tag} item.ImageBlurHashes = blurs + item.PrimaryImageAspectRatio = ratio } else if embeddedArtPending(mf) { // Nothing enqueues media files, so an unresolved track only resolves when someone asks // for its image. Advertising the id here is what makes a Jellyfin client ask; the @@ -205,9 +206,10 @@ func SongToBaseItem(mf model.MediaFile, fields Fields) BaseItemDto { // there is no resolved image to have one yet, and a fake would be cached forever. item.ImageTags = map[string]string{"Primary": mf.ID} } else if mf.AlbumID != "" { - if tag, blurs := primaryImageTag(mf.AlbumImage, mf.AlbumID); tag != "" { + if tag, blurs, ratio := primaryImage(mf.AlbumImage, mf.AlbumID, fields); tag != "" { item.AlbumPrimaryImageTag = tag item.ImageBlurHashes = blurs + item.PrimaryImageAspectRatio = ratio } } return item @@ -220,39 +222,41 @@ func embeddedArtPending(mf model.MediaFile) bool { mf.ImageHash == "" && !mf.ItemImage.ImageAbsent } -// primaryImageTag never synthesizes a blurhash: Finamp keys its cover cache on the value, -// so a fake one pins a stale cover forever (#5798). -func primaryImageTag(img model.ItemImage, fallback string) (string, map[string]map[string]string) { +// primaryImage derives all a mapper advertises about one image, so Primary is chosen once. It never +// fakes a blurhash: Finamp keys its cover cache on the value, pinning a stale cover forever (#5798). +func primaryImage(img model.ItemImage, fallback string, fields Fields) (tag string, blurs map[string]map[string]string, ratio *float64) { if img.ImageAbsent { - return "", nil + return "", nil, nil } - tag := img.ImageHash - if tag == "" { - tag = fallback + tag = cmp.Or(img.ImageHash, fallback) + if img.BlurHash != "" { + blurs = map[string]map[string]string{"Primary": {tag: img.BlurHash}} } - if img.BlurHash == "" { - return tag, nil + // Dimensions are unknown while an item is unresolved; omit rather than guess a ratio. + if fields.Has("PrimaryImageAspectRatio") && img.ImageWidth > 0 && img.ImageHeight > 0 { + ratio = new(float64(img.ImageWidth) / float64(img.ImageHeight)) } - return tag, map[string]map[string]string{"Primary": {tag: img.BlurHash}} + return tag, blurs, ratio } func AlbumToBaseItem(al model.Album, fields Fields) BaseItemDto { - tag, blurs := primaryImageTag(al.ItemImage, al.ID) + tag, blurs, ratio := primaryImage(al.ItemImage, al.ID, fields) item := BaseItemDto{ - Name: al.Name, - Id: EncodeID(al.ID), - Type: "MusicAlbum", - IsFolder: true, - ParentId: EncodeID(al.AlbumArtistID), - AlbumArtist: al.AlbumArtist, - Album: al.Name, - ChildCount: new(al.SongCount), - SongCount: new(al.SongCount), - RunTimeTicks: TicksFromSeconds(al.Duration), - DateCreated: jellyfinDate(&al.CreatedAt), - ImageBlurHashes: blurs, - BackdropImageTags: []string{}, - UserData: UserData(al.Annotations, al.ID), + Name: al.Name, + Id: EncodeID(al.ID), + Type: "MusicAlbum", + IsFolder: true, + ParentId: EncodeID(al.AlbumArtistID), + AlbumArtist: al.AlbumArtist, + Album: al.Name, + ChildCount: new(al.SongCount), + SongCount: new(al.SongCount), + RunTimeTicks: TicksFromSeconds(al.Duration), + DateCreated: jellyfinDate(&al.CreatedAt), + ImageBlurHashes: blurs, + PrimaryImageAspectRatio: ratio, + BackdropImageTags: []string{}, + UserData: UserData(al.Annotations, al.ID), } if tag != "" { item.ImageTags = map[string]string{"Primary": tag} @@ -285,19 +289,20 @@ func AlbumToBaseItem(al model.Album, fields Fields) BaseItemDto { return item } -func ArtistToBaseItem(ar model.Artist) BaseItemDto { - tag, blurs := primaryImageTag(ar.ItemImage, ar.ID) +func ArtistToBaseItem(ar model.Artist, fields Fields) BaseItemDto { + tag, blurs, ratio := primaryImage(ar.ItemImage, ar.ID, fields) item := BaseItemDto{ - Name: ar.Name, - Id: EncodeID(ar.ID), - Type: "MusicArtist", - IsFolder: true, - AlbumCount: new(ar.AlbumCount), - SongCount: new(ar.SongCount), - DateCreated: jellyfinDate(ar.CreatedAt), - ImageBlurHashes: blurs, - BackdropImageTags: []string{}, - UserData: UserData(ar.Annotations, ar.ID), + Name: ar.Name, + Id: EncodeID(ar.ID), + Type: "MusicArtist", + IsFolder: true, + AlbumCount: new(ar.AlbumCount), + SongCount: new(ar.SongCount), + DateCreated: jellyfinDate(ar.CreatedAt), + ImageBlurHashes: blurs, + PrimaryImageAspectRatio: ratio, + BackdropImageTags: []string{}, + UserData: UserData(ar.Annotations, ar.ID), } if tag != "" { item.ImageTags = map[string]string{"Primary": tag} @@ -325,22 +330,23 @@ func StudioToBaseItem(t model.Tag) BaseItemDto { } // PlaylistToBaseItem maps a playlist to a Playlist BaseItemDto. -func PlaylistToBaseItem(p model.Playlist) BaseItemDto { - tag, blurs := primaryImageTag(p.ItemImage, p.ID) +func PlaylistToBaseItem(p model.Playlist, fields Fields) BaseItemDto { + tag, blurs, ratio := primaryImage(p.ItemImage, p.ID, fields) item := BaseItemDto{ Name: p.Name, Id: EncodeID(p.ID), Type: "Playlist", // Synthetic path: Jellify only surfaces playlists whose Path contains "data" (real Jellyfin // stores them under its data folder), so without this its Playlists tab hides them all. - Path: "/data/playlists/" + p.ID, - IsFolder: true, - MediaType: "Audio", - ChildCount: new(p.SongCount), - RunTimeTicks: TicksFromSeconds(p.Duration), - ImageBlurHashes: blurs, - BackdropImageTags: []string{}, - UserData: UserData(p.Annotations, p.ID), + Path: "/data/playlists/" + p.ID, + IsFolder: true, + MediaType: "Audio", + ChildCount: new(p.SongCount), + RunTimeTicks: TicksFromSeconds(p.Duration), + ImageBlurHashes: blurs, + PrimaryImageAspectRatio: ratio, + BackdropImageTags: []string{}, + UserData: UserData(p.Annotations, p.ID), } if tag != "" { item.ImageTags = map[string]string{"Primary": tag} diff --git a/server/jellyfin/dto/mappers_test.go b/server/jellyfin/dto/mappers_test.go index 1251918e1..e7234a203 100644 --- a/server/jellyfin/dto/mappers_test.go +++ b/server/jellyfin/dto/mappers_test.go @@ -290,9 +290,76 @@ var _ = Describe("mappers", func() { Expect(string(b)).ToNot(ContainSubstring("NormalizationGain")) }) + // Real Jellyfin only attaches it when the client asks (DtoService.ContainsField), and derives + // it from the image's real dimensions. + Describe("PrimaryImageAspectRatio", func() { + nonSquare := func() model.Album { + al := model.Album{ID: "al1", Name: "Album"} + al.ImageHash, al.ImageWidth, al.ImageHeight = "abc", 1200, 800 + return al + } + + It("is omitted unless the request asks for it", func() { + Expect(AlbumToBaseItem(nonSquare(), nil).PrimaryImageAspectRatio).To(BeNil()) + b, err := json.Marshal(AlbumToBaseItem(nonSquare(), nil)) + Expect(err).ToNot(HaveOccurred()) + Expect(string(b)).ToNot(ContainSubstring("PrimaryImageAspectRatio")) + }) + + It("carries the real ratio when asked", func() { + item := AlbumToBaseItem(nonSquare(), ParseFields("PrimaryImageAspectRatio")) + Expect(*item.PrimaryImageAspectRatio).To(BeNumerically("~", 1.5, 0.0001)) + }) + + It("is omitted when the dimensions are unknown, rather than guessing square", func() { + al := model.Album{ID: "al1", Name: "Album"} + al.ImageHash = "abc" + item := AlbumToBaseItem(al, ParseFields("PrimaryImageAspectRatio")) + Expect(item.PrimaryImageAspectRatio).To(BeNil()) + }) + + It("is omitted when the item has no image at all", func() { + al := model.Album{ID: "al1", Name: "Album"} + al.ImageAbsent = true + al.ImageWidth, al.ImageHeight = 1200, 800 + item := AlbumToBaseItem(al, ParseFields("PrimaryImageAspectRatio")) + Expect(item.PrimaryImageAspectRatio).To(BeNil()) + }) + + It("carries the ratio for an artist", func() { + ar := model.Artist{ID: "ar1", Name: "Artist"} + ar.ImageHash, ar.ImageWidth, ar.ImageHeight = "abc", 1000, 500 + Expect(*ArtistToBaseItem(ar, ParseFields("PrimaryImageAspectRatio")).PrimaryImageAspectRatio). + To(BeNumerically("~", 2.0, 0.0001)) + }) + + It("carries the ratio for a playlist", func() { + pl := model.Playlist{ID: "pl1", Name: "Playlist"} + pl.ImageHash, pl.ImageWidth, pl.ImageHeight = "abc", 400, 800 + Expect(*PlaylistToBaseItem(pl, ParseFields("PrimaryImageAspectRatio")).PrimaryImageAspectRatio). + To(BeNumerically("~", 0.5, 0.0001)) + }) + + It("carries the ratio for a song with its own art", func() { + mf := model.MediaFile{ID: "mf1", Title: "Song"} + mf.ImageHash, mf.ImageWidth, mf.ImageHeight = "abc", 300, 600 + Expect(*SongToBaseItem(mf, ParseFields("PrimaryImageAspectRatio")).PrimaryImageAspectRatio). + To(BeNumerically("~", 0.5, 0.0001)) + }) + + // A track without its own art shows the album's, so the ratio has to describe that image. + It("uses the album's dimensions for a track falling back to album art", func() { + mf := model.MediaFile{ID: "mf1", Title: "Song", AlbumID: "al1"} + mf.AlbumImage.ImageHash, mf.AlbumImage.ImageWidth, mf.AlbumImage.ImageHeight = "abc", 1200, 800 + item := SongToBaseItem(mf, ParseFields("PrimaryImageAspectRatio")) + Expect(item.AlbumPrimaryImageTag).To(Equal("abc")) + Expect(*item.PrimaryImageAspectRatio).To(BeNumerically("~", 1.5, 0.0001)) + }) + }) + It("maps an artist to a MusicArtist folder item", func() { ar := model.Artist{ID: "art-1", Name: "AA", AlbumCount: 2, SongCount: 20} - item := ArtistToBaseItem(ar) + item := ArtistToBaseItem(ar, nil) Expect(item.Type).To(Equal("MusicArtist")) Expect(item.IsFolder).To(BeTrue()) Expect(item.Id).To(Equal(EncodeID("art-1"))) @@ -354,7 +421,7 @@ var _ = Describe("mappers", func() { ID: "pl-1", Name: "Chill", SongCount: 7, Duration: 120, Annotations: model.Annotations{Starred: true, Rating: 4, PlayCount: 2}, } - item := PlaylistToBaseItem(p) + item := PlaylistToBaseItem(p, nil) Expect(item.Type).To(Equal("Playlist")) Expect(item.IsFolder).To(BeTrue()) Expect(item.Id).To(Equal(EncodeID("pl-1"))) @@ -372,9 +439,9 @@ var _ = Describe("mappers", func() { It("changes the playlist image tag when the cover content changes", func() { p := model.Playlist{ID: "pl-1", Name: "Chill"} p.ImageHash = "1111111111111111" - before := PlaylistToBaseItem(p) + before := PlaylistToBaseItem(p, nil) p.ImageHash = "2222222222222222" - after := PlaylistToBaseItem(p) + after := PlaylistToBaseItem(p, nil) Expect(before.ImageTags["Primary"]).To(Equal("1111111111111111")) Expect(after.ImageTags["Primary"]).To(Equal("2222222222222222")) @@ -383,9 +450,9 @@ var _ = Describe("mappers", func() { It("keeps the playlist image tag stable across a metadata-only edit", func() { p := model.Playlist{ID: "pl-1", UpdatedAt: time.Date(2026, 7, 1, 0, 0, 0, 0, time.UTC)} p.ImageHash = "1111111111111111" - before := PlaylistToBaseItem(p) + before := PlaylistToBaseItem(p, nil) p.UpdatedAt = time.Date(2026, 7, 2, 0, 0, 0, 0, time.UTC) - after := PlaylistToBaseItem(p) + after := PlaylistToBaseItem(p, nil) Expect(after.ImageTags["Primary"]).To(Equal(before.ImageTags["Primary"])) }) @@ -479,7 +546,7 @@ var _ = Describe("mappers", func() { ar.ImageHash = "fedcba9876543210" ar.BlurHash = "L6PZfSi_.AyE" - item := ArtistToBaseItem(ar) + item := ArtistToBaseItem(ar, nil) Expect(item.ImageTags).To(HaveKeyWithValue("Primary", "fedcba9876543210")) Expect(item.ImageBlurHashes["Primary"]).To(HaveKeyWithValue("fedcba9876543210", "L6PZfSi_.AyE")) }) @@ -518,7 +585,7 @@ var _ = Describe("mappers", func() { pl := model.Playlist{ID: "pl-1", Name: "Playlist"} pl.ImageHash = "abcdef0123456789" - item := PlaylistToBaseItem(pl) + item := PlaylistToBaseItem(pl, nil) Expect(item.ImageTags).To(HaveKeyWithValue("Primary", "abcdef0123456789")) }) }) diff --git a/server/jellyfin/items.go b/server/jellyfin/items.go index f7050249f..631530b6d 100644 --- a/server/jellyfin/items.go +++ b/server/jellyfin/items.go @@ -598,6 +598,7 @@ func (api *Router) listSongs(ctx context.Context, opts model.QueryOptions, q ite // genreIds isn't applied to search — a name lookup, like role (see below). func (api *Router) listArtists(ctx context.Context, opts model.QueryOptions, q itemsQuery, role model.Role) (itemsResult, error) { repo := api.ds.Artist(ctx) + toItem := func(ar model.Artist) dto.BaseItemDto { return dto.ArtistToBaseItem(ar, q.fields) } // Artist Search does its own library scoping: it consumes a sole Eq{"library_id": ...} filter as a // search scope (artists have no library_id column). A compound or join-based filter @@ -613,7 +614,7 @@ func (api *Router) listArtists(ctx context.Context, opts model.QueryOptions, q i if err != nil { return itemsResult{}, err } - return materialized(result(slice.Map(artists, dto.ArtistToBaseItem), total, opts.Offset)), nil + return materialized(result(slice.Map(artists, toItem), total, opts.Offset)), nil } if q.favOnly { @@ -629,7 +630,7 @@ func (api *Router) listArtists(ctx context.Context, opts model.QueryOptions, q i total, _ := repo.CountAll(model.QueryOptions{Filters: opts.Filters}) open := streamCursor(func() (func(func(model.Artist, error) bool), error) { return repo.GetCursor(opts) - }, dto.ArtistToBaseItem) + }, toItem) return streamed(open, int(total), opts.Offset), nil } @@ -663,7 +664,7 @@ func (api *Router) listPlaylists(ctx context.Context, opts model.QueryOptions, q } open := streamCursor(func() (func(func(model.Playlist, error) bool), error) { return repo.GetCursor(opts) - }, dto.PlaylistToBaseItem) + }, func(p model.Playlist) dto.BaseItemDto { return dto.PlaylistToBaseItem(p, q.fields) }) return streamed(open, int(total), opts.Offset), nil } @@ -698,7 +699,7 @@ func (api *Router) resolveItemByID(ctx context.Context, id string, fields dto.Fi if ar, err := api.ds.Artist(ctx).Get(id); err == nil { // TODO: an artist spans multiple libraries (library_artist), so there's no single // LibraryID to gate here; artist access relies on list-time scoping and persistence. - return dto.ArtistToBaseItem(*ar), true + return dto.ArtistToBaseItem(*ar, fields), true } if mf, err := api.ds.MediaFile(ctx).Get(id); err == nil { if !u.HasLibraryAccess(mf.LibraryID) { @@ -708,7 +709,7 @@ func (api *Router) resolveItemByID(ctx context.Context, id string, fields dto.Fi } // api.playlists.Get enforces ownership/visibility, so a non-owned or missing id falls through. if pl, err := api.playlists.Get(ctx, id); err == nil { - return dto.PlaylistToBaseItem(*pl), true + return dto.PlaylistToBaseItem(*pl, fields), true } return dto.BaseItemDto{}, false } diff --git a/server/jellyfin/similar.go b/server/jellyfin/similar.go index 661fb95cc..2698073bf 100644 --- a/server/jellyfin/similar.go +++ b/server/jellyfin/similar.go @@ -138,7 +138,7 @@ func (api *Router) similarArtists(ctx context.Context, id string, limit int) dto return result(nil, 0, 0) } present := slice.Filter(artist.SimilarArtists, func(a model.Artist) bool { return a.ID != "" }) - items := slice.Map(present, dto.ArtistToBaseItem) + items := slice.Map(present, func(a model.Artist) dto.BaseItemDto { return dto.ArtistToBaseItem(a, nil) }) return result(items, len(items), 0) }