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)