From 81e6dc88b0511ad8d4ee1cb7624753598ec6a5a1 Mon Sep 17 00:00:00 2001 From: Deluan Date: Mon, 6 Jul 2026 09:04:14 -0400 Subject: [PATCH] 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. --- server/jellyfin/dto/dto.go | 23 +++++++++++++---------- server/jellyfin/dto/mappers.go | 12 ++++++++++++ server/jellyfin/dto/mappers_test.go | 9 +++++++++ server/jellyfin/e2e/browsing_test.go | 9 +++++++++ 4 files changed, 43 insertions(+), 10 deletions(-) diff --git a/server/jellyfin/dto/dto.go b/server/jellyfin/dto/dto.go index b7815957d..6240b0b26 100644 --- a/server/jellyfin/dto/dto.go +++ b/server/jellyfin/dto/dto.go @@ -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"` diff --git a/server/jellyfin/dto/mappers.go b/server/jellyfin/dto/mappers.go index a28c1d2b7..99dd6bb2f 100644 --- a/server/jellyfin/dto/mappers.go +++ b/server/jellyfin/dto/mappers.go @@ -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{}, diff --git a/server/jellyfin/dto/mappers_test.go b/server/jellyfin/dto/mappers_test.go index f8e4210d6..a71088283 100644 --- a/server/jellyfin/dto/mappers_test.go +++ b/server/jellyfin/dto/mappers_test.go @@ -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) diff --git a/server/jellyfin/e2e/browsing_test.go b/server/jellyfin/e2e/browsing_test.go index 4bfa17bd3..920464af8 100644 --- a/server/jellyfin/e2e/browsing_test.go +++ b/server/jellyfin/e2e/browsing_test.go @@ -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)