mirror of
https://github.com/navidrome/navidrome.git
synced 2026-08-01 07:21:17 +00:00
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:
parent
d732e5419a
commit
09b647c9d0
@ -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
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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"))
|
||||
})
|
||||
})
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user