feat(jellyfin): expose PrimaryImageAspectRatio

Real Jellyfin carries width/height of the Primary image on BaseItemDto
(MediaBrowser.Model/Dto/BaseItemDto.cs), attaching it only when the request's
Fields asks for it (DtoService.cs ContainsField). The dimensions are now on
model.ItemImage for the web UI's blurhash placeholder, so the adapter can
report the same thing for free.

Gated behind Fields to match, and omitted rather than defaulted when the item
has no image or unknown dimensions: real Jellyfin falls back to a per-type
default of 1 for music, but a wrong ratio mis-shapes a client's placeholder,
and we only lack dimensions when the artwork is genuinely unresolved.

primaryImageTag becomes primaryImage, returning the tag, blurhashes and ratio
together, so the choice of which image is Primary is made once per mapper
rather than the same ItemImage being threaded through two calls.

ArtistToBaseItem and PlaylistToBaseItem take Fields now, like the album and
song mappers already did.
This commit is contained in:
Deluan 2026-07-26 11:14:09 -04:00
parent d732e5419a
commit 09b647c9d0
5 changed files with 146 additions and 69 deletions

View File

@ -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

View File

@ -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}

View File

@ -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"))
})
})

View File

@ -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
}

View File

@ -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)
}