feat(jellyfin): emit DateCreated (Date Added) on items

BaseItemDto had no DateCreated, so clients showed 'No Date Added' and had
nothing to sort 'Recently Added' by. Emit it as ISO 8601 from each entity's
CreatedAt (the same field the recently_added sort uses) for songs, albums
and artists.
This commit is contained in:
Deluan 2026-07-06 09:04:14 -04:00
commit 81e6dc88b0
4 changed files with 43 additions and 10 deletions

View file

@ -43,16 +43,19 @@ type BaseItemDto struct {
// PlaylistItemId identifies this entry within a playlist listing (GET /Playlists/{id}/Items).
// It's only set there, and is distinct from Id so a song appearing more than once in the same
// playlist 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"`
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"`
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"`
// DateCreated is the ISO 8601 date the item was added to the library; clients show it as
// "Date Added" and sort "Recently Added" by it.
DateCreated string `json:"DateCreated,omitempty"`
Album string `json:"Album,omitempty"`
AlbumId string `json:"AlbumId,omitempty"`
AlbumArtist string `json:"AlbumArtist,omitempty"`

View file

@ -8,6 +8,15 @@ import (
func TicksFromSeconds(sec float32) int64 { return int64(float64(sec) * 1e7) }
// jellyfinDate formats a time as the ISO 8601 string Jellyfin clients expect for DateCreated, 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)
}
// channelLayout maps a channel count to the label Jellyfin clients expect on a MediaStream.
func channelLayout(n int) string {
switch n {
@ -92,6 +101,7 @@ func SongToBaseItem(mf model.MediaFile) BaseItemDto {
AlbumArtist: mf.AlbumArtist,
Artists: []string{mf.Artist},
RunTimeTicks: TicksFromSeconds(mf.Duration),
DateCreated: jellyfinDate(&mf.CreatedAt),
Container: mf.Suffix,
CanDownload: true,
BackdropImageTags: []string{},
@ -134,6 +144,7 @@ func AlbumToBaseItem(al model.Album) BaseItemDto {
ChildCount: new(al.SongCount),
SongCount: new(al.SongCount),
RunTimeTicks: TicksFromSeconds(al.Duration),
DateCreated: jellyfinDate(&al.CreatedAt),
ImageTags: map[string]string{"Primary": al.ID},
ImageBlurHashes: map[string]map[string]string{"Primary": {al.ID: blurHash(al.ID)}},
BackdropImageTags: []string{},
@ -162,6 +173,7 @@ func ArtistToBaseItem(ar model.Artist) BaseItemDto {
IsFolder: true,
AlbumCount: new(ar.AlbumCount),
SongCount: new(ar.SongCount),
DateCreated: jellyfinDate(ar.CreatedAt),
ImageTags: map[string]string{"Primary": ar.ID},
ImageBlurHashes: map[string]map[string]string{"Primary": {ar.ID: blurHash(ar.ID)}},
BackdropImageTags: []string{},

View file

@ -44,6 +44,15 @@ var _ = Describe("mappers", func() {
Expect(item.ImageBlurHashes).To(BeNil())
})
It("sets DateCreated from the media file's CreatedAt", func() {
mf := model.MediaFile{ID: "s1", Title: "Song", CreatedAt: time.Date(2024, 1, 15, 10, 30, 0, 0, time.UTC)}
Expect(SongToBaseItem(mf).DateCreated).To(Equal("2024-01-15T10:30:00Z"))
})
It("omits DateCreated when CreatedAt is the zero time", func() {
Expect(SongToBaseItem(model.MediaFile{ID: "s1", Title: "Song"}).DateCreated).To(BeEmpty())
})
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)

View file

@ -2,6 +2,7 @@ package e2e
import (
"net/http"
"time"
"github.com/navidrome/navidrome/server/jellyfin/dto"
. "github.com/onsi/ginkgo/v2"
@ -165,6 +166,14 @@ var _ = Describe("Browsing", func() {
Expect(item.Type).To(Equal("Audio"))
})
It("includes a parseable DateCreated (Date Added) on a song", func() {
var item dto.BaseItemDto
parseInto(get("/Items/"+enc(songID("So What"))), &item)
Expect(item.DateCreated).ToNot(BeEmpty())
_, err := time.Parse(time.RFC3339, item.DateCreated)
Expect(err).ToNot(HaveOccurred())
})
It("resolves an artist", func() {
var item dto.BaseItemDto
parseInto(get("/Items/"+enc(artistID("Miles Davis"))), &item)