package dto
import (
"encoding/json"
"time"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/conf/configtest"
"github.com/navidrome/navidrome/model"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
var _ = Describe("mappers", func() {
It("maps a song to an Audio BaseItemDto", func() {
mf := model.MediaFile{
ID: testID("song-1"), Title: "Song", Album: "Alb", AlbumID: testID("alb-1"),
Artist: "Art", AlbumArtist: "AA", TrackNumber: 3, DiscNumber: 1,
Year: 1999, Duration: 60, Size: 2_500_000,
Genres: []model.Genre{{ID: testID("1"), Name: "genre 1"}, {ID: testID("2"), Name: "genre 2"}},
PlayCount: 2,
Starred: true,
}
item := SongToBaseItem(mf, nil)
Expect(item.Type).To(Equal("Audio"))
Expect(item.MediaType).To(Equal("Audio"))
Expect(item.IsFolder).To(BeFalse())
Expect(item.LocationType).To(Equal("FileSystem"))
Expect(item.Id).To(Equal(EncodeID(testID("song-1"))))
Expect(item.AlbumId).To(Equal(EncodeID(testID("alb-1"))))
Expect(item.ParentId).To(Equal(EncodeID(testID("alb-1"))))
Expect(item.RunTimeTicks).To(Equal(int64(600_000_000)))
Expect(*item.IndexNumber).To(Equal(3))
Expect(item.UserData.IsFavorite).To(BeTrue())
Expect(item.UserData.PlayCount).To(Equal(2))
Expect(item.UserData.Played).To(BeTrue())
Expect(item.UserData.Key).To(Equal(EncodeID(testID("song-1"))))
Expect(item.UserData.ItemId).To(Equal(EncodeID(testID("song-1"))))
Expect(item.AlbumPrimaryImageTag).To(Equal(testID("alb-1")))
Expect(item.ImageBlurHashes).To(BeNil())
Expect(item.Genres).To(Equal([]string{"genre 1", "genre 2"}))
Expect(item.GenreItems).To(Equal([]NameGuidPair{{Id: EncodeID(testID("1")), Name: "genre 1"}, {Id: EncodeID(testID("2")), Name: "genre 2"}}))
})
Describe("Fields gating (matches real Jellyfin)", func() {
mf := model.MediaFile{ID: testID("s1"), Title: "Song", Size: 2_500_000, Suffix: "mp3", Duration: 60,
SortTitle: "sort song", Lyrics: `[{"line":[{"value":"la"}]}]`}
It("omits MediaSources when Fields does not ask for them", func() {
item := SongToBaseItem(mf, nil)
Expect(item.MediaSources).To(BeNil())
})
It("includes MediaSources only when Fields=MediaSources", func() {
item := SongToBaseItem(mf, ParseFields("ChildCount,MediaSources,SortName"))
Expect(item.MediaSources).To(HaveLen(1))
Expect(item.MediaSources[0].Size).To(Equal(int64(2_500_000)))
})
Describe("SortName", func() {
song := model.MediaFile{ID: testID("s1"), Title: "The Song", SortTitle: "Song, The", OrderTitle: "song"}
ar := model.Artist{ID: testID("art-1"), Name: "The B-52's", SortArtistName: "B-52's, The", OrderArtistName: "b-52's"}
al := model.Album{ID: testID("alb-1"), Name: "The Wall", SortAlbumName: "Wall, The", OrderAlbumName: "wall"}
BeforeEach(func() {
DeferCleanup(configtest.SetupConfig())
})
It("is omitted unless Fields=SortName", func() {
Expect(SongToBaseItem(song, nil).SortName).To(BeEmpty())
Expect(ArtistToBaseItem(ar, nil).SortName).To(BeEmpty())
Expect(AlbumToBaseItem(al, nil).SortName).To(BeEmpty())
})
It("uses the order names by default, ignoring sort tags", func() {
Expect(SongToBaseItem(song, ParseFields("SortName")).SortName).To(Equal("song"))
Expect(ArtistToBaseItem(ar, ParseFields("SortName")).SortName).To(Equal("b-52's"))
Expect(AlbumToBaseItem(al, ParseFields("SortName")).SortName).To(Equal("wall"))
})
Context("with PreferSortTags", func() {
BeforeEach(func() {
conf.Server.PreferSortTags = true
})
It("prefers the sort tags", func() {
Expect(SongToBaseItem(song, ParseFields("SortName")).SortName).To(Equal("Song, The"))
Expect(ArtistToBaseItem(ar, ParseFields("SortName")).SortName).To(Equal("B-52's, The"))
Expect(AlbumToBaseItem(al, ParseFields("SortName")).SortName).To(Equal("Wall, The"))
})
It("falls back to the order name when there is no sort tag", func() {
Expect(ArtistToBaseItem(model.Artist{ID: testID("a"), Name: "The X", OrderArtistName: "x"},
ParseFields("SortName")).SortName).To(Equal("x"))
})
})
It("falls back to the display name when order name and sort tag are empty", func() {
Expect(SongToBaseItem(model.MediaFile{ID: testID("s"), Title: "T"}, ParseFields("SortName")).SortName).To(Equal("T"))
Expect(ArtistToBaseItem(model.Artist{ID: testID("a"), Name: "N"}, ParseFields("SortName")).SortName).To(Equal("N"))
Expect(AlbumToBaseItem(model.Album{ID: testID("al"), Name: "A"}, ParseFields("SortName")).SortName).To(Equal("A"))
})
})
It("sets HasLyrics from the media file's lyrics", func() {
Expect(*SongToBaseItem(mf, nil).HasLyrics).To(BeTrue())
Expect(*SongToBaseItem(model.MediaFile{ID: testID("s2"), Title: "No Lyrics"}, nil).HasLyrics).To(BeFalse())
Expect(*SongToBaseItem(model.MediaFile{ID: testID("s3"), Title: "Empty Lyrics", Lyrics: "[]"}, nil).HasLyrics).To(BeFalse())
})
})
It("omits ImageBlurHashes when a song has no album", func() {
mf := model.MediaFile{ID: testID("song-noalbum"), Title: "Song", Duration: 60}
item := SongToBaseItem(mf, nil)
Expect(item.AlbumPrimaryImageTag).To(BeEmpty())
Expect(item.ImageBlurHashes).To(BeNil())
})
It("sets DateCreated from the media file's CreatedAt", func() {
mf := model.MediaFile{ID: testID("s1"), Title: "Song", CreatedAt: time.Date(2024, 1, 15, 10, 30, 0, 0, time.UTC)}
Expect(SongToBaseItem(mf, nil).DateCreated).To(Equal("2024-01-15T10:30:00.0000000Z"))
})
It("omits DateCreated when CreatedAt is the zero time", func() {
Expect(SongToBaseItem(model.MediaFile{ID: testID("s1"), Title: "Song"}, nil).DateCreated).To(BeEmpty())
})
It("sets ArtistItems and AlbumArtists (encoded ids) from the track and album artist", func() {
mf := model.MediaFile{
ID: testID("s1"), Title: "Song",
Artist: "The Band", ArtistID: testID("ar-1"),
AlbumArtist: "Various", AlbumArtistID: testID("ar-2"),
}
item := SongToBaseItem(mf, nil)
Expect(item.ArtistItems).To(Equal([]NameGuidPair{{Name: "The Band", Id: EncodeID(testID("ar-1"))}}))
Expect(item.AlbumArtists).To(Equal([]NameGuidPair{{Name: "Various", Id: EncodeID(testID("ar-2"))}}))
})
It("omits ArtistItems when the track has no artist id", func() {
Expect(SongToBaseItem(model.MediaFile{ID: testID("s1"), Title: "Song", Artist: "X"}, nil).ArtistItems).To(BeNil())
})
It("omits Artists when the track has no artist name or participants", func() {
Expect(SongToBaseItem(model.MediaFile{ID: testID("s1"), Title: "Song"}, nil).Artists).To(BeNil())
})
It("splits Artists and ArtistItems per track artist from Participants", func() {
mf := model.MediaFile{
ID: testID("s1"), Title: "Oooh",
Artist: "De La Soul feat. Redman", ArtistID: testID("ar-delasoul"),
AlbumArtist: "De La Soul", AlbumArtistID: testID("ar-delasoul"),
}
mf.Participants = model.Participants{
model.RoleArtist: model.ParticipantList{
{Artist: model.Artist{ID: testID("ar-delasoul"), Name: "De La Soul"}},
{Artist: model.Artist{ID: testID("ar-redman"), Name: "Redman"}},
},
}
item := SongToBaseItem(mf, nil)
Expect(item.Artists).To(Equal([]string{"De La Soul", "Redman"}))
Expect(item.ArtistItems).To(Equal([]NameGuidPair{
{Name: "De La Soul", Id: EncodeID(testID("ar-delasoul"))},
{Name: "Redman", Id: EncodeID(testID("ar-redman"))},
}))
Expect(item.AlbumArtists).To(Equal([]NameGuidPair{{Name: "De La Soul", Id: EncodeID(testID("ar-delasoul"))}}))
})
It("serializes normalization gains with Jellyfin's exact key casing", func() {
mf := model.MediaFile{ID: testID("s1"), Title: "Song",
RGTrackGain: new(-3.5), RGAlbumGain: new(-4.25)}
b, err := json.Marshal(SongToBaseItem(mf, nil))
Expect(err).ToNot(HaveOccurred())
Expect(string(b)).To(ContainSubstring(`"NormalizationGain":-3.5`))
Expect(string(b)).To(ContainSubstring(`"AlbumNormalizationGain":-4.25`))
})
It("omits normalization gains when the file has no ReplayGain tags", func() {
b, err := json.Marshal(SongToBaseItem(model.MediaFile{ID: testID("s1"), Title: "Song"}, nil))
Expect(err).ToNot(HaveOccurred())
Expect(string(b)).ToNot(ContainSubstring("NormalizationGain"))
})
It("builds a MediaSourceInfo from a media file", func() {
mf := model.MediaFile{ID: testID("s1"), Size: 5242880, Suffix: "mp3", BitRate: 320, Duration: 100}
src := MediaSourceFromMediaFile(mf)
Expect(src.Id).To(Equal(EncodeID(testID("s1"))))
Expect(src.Size).To(Equal(int64(5242880)))
Expect(src.Container).To(Equal("mp3"))
Expect(src.Bitrate).To(Equal(320_000))
Expect(src.RunTimeTicks).To(Equal(int64(1_000_000_000)))
Expect(src.Protocol).To(Equal("Http"))
Expect(src.SupportsDirectPlay).To(BeTrue())
})
It("populates MediaStreams with a single Audio stream so Finamp can size downloads", func() {
mf := model.MediaFile{
ID: testID("s1"), Size: 5242880, Suffix: "mp3", BitRate: 320, Duration: 100,
Channels: 2, SampleRate: 44100, Codec: "mp3",
}
src := MediaSourceFromMediaFile(mf)
Expect(src.MediaStreams).To(HaveLen(1))
stream := src.MediaStreams[0]
Expect(stream.Type).To(Equal("Audio"))
Expect(stream.Channels).To(Equal(2))
Expect(stream.SampleRate).To(Equal(44100))
Expect(stream.BitRate).To(Equal(320_000))
Expect(stream.Codec).To(Equal("mp3"))
Expect(stream.ChannelLayout).To(Equal("stereo"))
})
It("serializes all Finamp-required MediaSourceInfo bools and arrays, never as null", func() {
mf := model.MediaFile{ID: testID("s1"), Size: 5242880, Suffix: "mp3", BitRate: 320, Duration: 100}
src := MediaSourceFromMediaFile(mf)
b, err := json.Marshal(src)
Expect(err).ToNot(HaveOccurred())
j := string(b)
Expect(j).To(ContainSubstring(`"SupportsProbing":true`))
Expect(j).To(ContainSubstring(`"IsInfiniteStream":false`))
Expect(j).To(ContainSubstring(`"RequiresOpening":false`))
Expect(j).To(ContainSubstring(`"MediaAttachments":[]`))
Expect(j).To(ContainSubstring(`"Formats":[]`))
})
It("serializes MediaStream's required non-nullable bools, never omitted", func() {
stream := MediaStream{Type: "Audio", Index: 0}
b, err := json.Marshal(stream)
Expect(err).ToNot(HaveOccurred())
j := string(b)
Expect(j).To(ContainSubstring(`"Type":"Audio"`))
Expect(j).To(ContainSubstring(`"IsDefault":false`))
Expect(j).To(ContainSubstring(`"IsInterlaced":false`))
Expect(j).To(ContainSubstring(`"IsForced":false`))
Expect(j).To(ContainSubstring(`"IsExternal":false`))
Expect(j).To(ContainSubstring(`"IsTextSubtitleStream":false`))
Expect(j).To(ContainSubstring(`"SupportsExternalStream":false`))
})
Describe("Lyric media stream advertising", func() {
It("adds a Lyric media stream when the file has embedded lyrics", func() {
mf := model.MediaFile{ID: testID("s1"), Lyrics: `[{"line":[{"value":"la"}]}]`}
src := MediaSourceFromMediaFile(mf)
Expect(src.MediaStreams).To(HaveLen(2))
Expect(src.MediaStreams[0].Type).To(Equal("Audio"))
Expect(src.MediaStreams[1].Type).To(Equal("Lyric"))
Expect(src.MediaStreams[1].Index).To(Equal(1))
Expect(src.MediaStreams[1].IsExternal).To(BeTrue())
})
It("emits only the Audio stream without lyrics", func() {
src := MediaSourceFromMediaFile(model.MediaFile{ID: testID("s1")})
Expect(src.MediaStreams).To(HaveLen(1))
Expect(src.MediaStreams[0].Type).To(Equal("Audio"))
})
It("emits only the Audio stream for the post-scan empty-lyrics sentinel", func() {
src := MediaSourceFromMediaFile(model.MediaFile{ID: testID("s1"), Lyrics: "[]"})
Expect(src.MediaStreams).To(HaveLen(1))
})
})
It("omits IndexNumber and ParentIndexNumber when track/disc numbers are untagged", func() {
mf := model.MediaFile{
ID: testID("song-2"), Title: "Song", Album: "Alb", AlbumID: testID("alb-1"),
Artist: "Art", AlbumArtist: "AA", TrackNumber: 0, DiscNumber: 0,
Duration: 60,
}
item := SongToBaseItem(mf, nil)
Expect(item.IndexNumber).To(BeNil())
Expect(item.ParentIndexNumber).To(BeNil())
})
It("maps PlayDate to UserData.LastPlayedDate", func() {
playDate := time.Date(2023, 5, 17, 12, 30, 0, 0, time.UTC)
mf := model.MediaFile{
ID: testID("song-3"), Title: "Song", Album: "Alb", AlbumID: testID("alb-1"),
Artist: "Art", AlbumArtist: "AA", Duration: 60,
}
mf.PlayDate = &playDate
item := SongToBaseItem(mf, nil)
Expect(item.UserData.LastPlayedDate).NotTo(BeNil())
Expect(*item.UserData.LastPlayedDate).To(Equal("2023-05-17T12:30:00.0000000Z"))
})
It("maps an album to a MusicAlbum folder item", func() {
al := model.Album{ID: testID("alb-1"), Name: "Alb", AlbumArtist: "AA", AlbumArtistID: testID("art-1"), MaxYear: 1999, SongCount: 10, Genres: []model.Genre{{ID: testID("1"), Name: "genre 1"}, {ID: testID("2"), Name: "genre 2"}}}
item := AlbumToBaseItem(al, nil)
Expect(item.Type).To(Equal("MusicAlbum"))
Expect(item.IsFolder).To(BeTrue())
Expect(item.Id).To(Equal(EncodeID(testID("alb-1"))))
Expect(item.ParentId).To(Equal(EncodeID(testID("art-1"))))
Expect(item.AlbumArtists).To(HaveLen(1))
Expect(item.AlbumArtists[0].Id).To(Equal(EncodeID(testID("art-1"))))
Expect(item.ArtistItems).To(Equal(item.AlbumArtists))
Expect(*item.ProductionYear).To(Equal(1999))
Expect(*item.ChildCount).To(Equal(10))
Expect(item.ImageTags).To(HaveKeyWithValue("Primary", testID("alb-1")))
Expect(item.ImageBlurHashes).To(BeNil())
Expect(item.Artists).To(Equal([]string{"AA"}))
Expect(item.LocationType).To(Equal("FileSystem"))
Expect(item.Genres).To(Equal([]string{"genre 1", "genre 2"}))
Expect(item.GenreItems).To(Equal([]NameGuidPair{{Id: EncodeID(testID("1")), Name: "genre 1"}, {Id: EncodeID(testID("2")), Name: "genre 2"}}))
})
It("populates album Studios from record-label tags only when Fields=Studios", func() {
al := model.Album{ID: testID("alb-2"), Name: "Alb2"}
al.Tags = model.Tags{model.TagRecordLabel: []string{"Columbia", "Legacy"}}
Expect(AlbumToBaseItem(al, nil).Studios).To(BeEmpty())
item := AlbumToBaseItem(al, ParseFields("Studios"))
Expect(item.Studios).To(Equal([]NameGuidPair{
{Name: "Columbia", Id: EncodeID(model.NewTag(model.TagRecordLabel, "Columbia").ID)},
{Name: "Legacy", Id: EncodeID(model.NewTag(model.TagRecordLabel, "Legacy").ID)},
}))
})
It("sets NormalizationGain on the album from its ReplayGain", func() {
al := model.Album{ID: testID("al1"), Name: "Album", RGAlbumGain: new(-6.0)}
b, err := json.Marshal(AlbumToBaseItem(al, nil))
Expect(err).ToNot(HaveOccurred())
Expect(string(b)).To(ContainSubstring(`"NormalizationGain":-6`))
Expect(string(b)).ToNot(ContainSubstring("AlbumNormalizationGain"))
})
It("omits NormalizationGain when the album has no ReplayGain", func() {
b, err := json.Marshal(AlbumToBaseItem(model.Album{ID: testID("al1"), Name: "Album"}, nil))
Expect(err).ToNot(HaveOccurred())
Expect(string(b)).ToNot(ContainSubstring("NormalizationGain"))
})
Describe("PrimaryImageAspectRatio", func() {
nonSquare := func() model.Album {
al := model.Album{ID: testID("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: testID("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: testID("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: testID("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: testID("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: testID("mf1"), Title: "Song"}
mf.ImageHash, mf.ImageWidth, mf.ImageHeight = "abc", 300, 600
Expect(*SongToBaseItem(mf, ParseFields("PrimaryImageAspectRatio")).PrimaryImageAspectRatio).
To(BeNumerically("~", 0.5, 0.0001))
})
It("uses the album's dimensions for a track falling back to album art", func() {
mf := model.MediaFile{ID: testID("mf1"), Title: "Song", AlbumID: testID("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: testID("art-1"), Name: "AA", AlbumCount: 2, SongCount: 20}
item := ArtistToBaseItem(ar, nil)
Expect(item.Type).To(Equal("MusicArtist"))
Expect(item.IsFolder).To(BeTrue())
Expect(item.Id).To(Equal(EncodeID(testID("art-1"))))
Expect(*item.AlbumCount).To(Equal(2))
})
It("maps a genre to a MusicGenre folder item", func() {
g := model.Genre{ID: testID("genre-1"), Name: "Rock"}
item := GenreToBaseItem(g)
Expect(item.Type).To(Equal("MusicGenre"))
Expect(item.IsFolder).To(BeTrue())
Expect(item.Id).To(Equal(EncodeID(testID("genre-1"))))
Expect(item.Name).To(Equal("Rock"))
})
It("maps a tag to a Studio BaseItemDto", func() {
item := StudioToBaseItem(model.Tag{ID: testID("t1"), TagValue: "Blue Note"})
Expect(item.Type).To(Equal("Studio"))
Expect(item.Name).To(Equal("Blue Note"))
Expect(item.Id).To(Equal(EncodeID(testID("t1"))))
})
Describe("premiereDate", func() {
It("serializes a full date", func() {
mf := model.MediaFile{ID: testID("s1"), Title: "Song", Date: "2007-02-01", Year: 2007}
item := SongToBaseItem(mf, nil)
Expect(*item.PremiereDate).To(Equal("2007-02-01T00:00:00.0000000Z"))
})
It("pads a year-only date so clients can parse it", func() {
mf := model.MediaFile{ID: testID("s1"), Title: "Song", Date: "2007", Year: 2007}
Expect(*SongToBaseItem(mf, nil).PremiereDate).To(Equal("2007-01-01T00:00:00.0000000Z"))
})
It("pads a year-month date", func() {
mf := model.MediaFile{ID: testID("s1"), Title: "Song", Date: "2007-02"}
Expect(*SongToBaseItem(mf, nil).PremiereDate).To(Equal("2007-02-01T00:00:00.0000000Z"))
})
It("falls back to the year when no date tag exists", func() {
mf := model.MediaFile{ID: testID("s1"), Title: "Song", Year: 1999}
Expect(*SongToBaseItem(mf, nil).PremiereDate).To(Equal("1999-01-01T00:00:00.0000000Z"))
})
It("is omitted when the track has no date at all", func() {
Expect(SongToBaseItem(model.MediaFile{ID: testID("s1"), Title: "Song"}, nil).PremiereDate).To(BeNil())
})
It("is set on albums from their date, falling back to MaxYear", func() {
Expect(*AlbumToBaseItem(model.Album{ID: testID("a1"), Date: "2013-09-06"}, nil).PremiereDate).To(Equal("2013-09-06T00:00:00.0000000Z"))
Expect(*AlbumToBaseItem(model.Album{ID: testID("a2"), MaxYear: 2013}, nil).PremiereDate).To(Equal("2013-01-01T00:00:00.0000000Z"))
Expect(AlbumToBaseItem(model.Album{ID: testID("a3")}, nil).PremiereDate).To(BeNil())
})
})
It("maps a playlist to a Playlist BaseItemDto", func() {
p := model.Playlist{
ID: testID("pl-1"), Name: "Chill", SongCount: 7, Duration: 120,
Annotations: model.Annotations{Starred: true, Rating: 4, PlayCount: 2},
}
item := PlaylistToBaseItem(p, nil)
Expect(item.Type).To(Equal("Playlist"))
Expect(item.IsFolder).To(BeTrue())
Expect(item.Id).To(Equal(EncodeID(testID("pl-1"))))
Expect(item.Name).To(Equal("Chill"))
Expect(item.MediaType).To(Equal("Audio"))
Expect(*item.ChildCount).To(Equal(7))
Expect(item.RunTimeTicks).To(Equal(int64(1_200_000_000)))
Expect(item.UserData.IsFavorite).To(BeTrue())
Expect(item.UserData.PlayCount).To(Equal(2))
Expect(*item.UserData.Rating).To(Equal(8.0))
Expect(item.ImageTags).To(HaveKeyWithValue("Primary", testID("pl-1")))
Expect(item.ImageBlurHashes).To(BeNil())
})
DescribeTable("UserData derives Likes from the rating like Jellyfin (rating >= 6.5)",
func(stars int, expected *bool) {
d := UserData(model.Annotations{Rating: stars}, testID("s1"))
Expect(d.Likes).To(Equal(expected))
},
Entry("unrated has no Likes", 0, nil),
Entry("1 star (Jellyfin 2) dislikes", 1, new(false)),
Entry("3 stars (Jellyfin 6) dislikes", 3, new(false)),
Entry("4 stars (Jellyfin 8) likes", 4, new(true)),
Entry("5 stars (Jellyfin 10) likes", 5, new(true)),
)
It("emits Rating as a whole number and omits Likes when unrated", func() {
rated, err := json.Marshal(UserData(model.Annotations{Rating: 4}, testID("s1")))
Expect(err).ToNot(HaveOccurred())
Expect(string(rated)).To(ContainSubstring(`"Rating":8,`))
Expect(string(rated)).To(ContainSubstring(`"Likes":true`))
unrated, err := json.Marshal(UserData(model.Annotations{}, testID("s1")))
Expect(err).ToNot(HaveOccurred())
Expect(string(unrated)).ToNot(ContainSubstring(`"Rating"`))
Expect(string(unrated)).ToNot(ContainSubstring(`"Likes"`))
})
It("emits DateCreated and SortName on playlists, like albums and artists", func() {
p := model.Playlist{ID: testID("pl-1"), Name: "Chill", CreatedAt: time.Date(2026, 7, 1, 12, 0, 0, 0, time.UTC)}
Expect(PlaylistToBaseItem(p, nil).DateCreated).To(Equal("2026-07-01T12:00:00.0000000Z"))
Expect(PlaylistToBaseItem(p, nil).SortName).To(BeEmpty())
Expect(PlaylistToBaseItem(p, ParseFields("SortName")).SortName).To(Equal("Chill"))
})
It("changes the playlist image tag when the cover content changes", func() {
p := model.Playlist{ID: testID("pl-1"), Name: "Chill"}
p.ImageHash = "1111111111111111"
before := PlaylistToBaseItem(p, nil)
p.ImageHash = "2222222222222222"
after := PlaylistToBaseItem(p, nil)
Expect(before.ImageTags["Primary"]).To(Equal("1111111111111111"))
Expect(after.ImageTags["Primary"]).To(Equal("2222222222222222"))
})
It("keeps the playlist image tag stable across a metadata-only edit", func() {
p := model.Playlist{ID: testID("pl-1"), UpdatedAt: time.Date(2026, 7, 1, 0, 0, 0, 0, time.UTC)}
p.ImageHash = "1111111111111111"
before := PlaylistToBaseItem(p, nil)
p.UpdatedAt = time.Date(2026, 7, 2, 0, 0, 0, 0, time.UTC)
after := PlaylistToBaseItem(p, nil)
Expect(after.ImageTags["Primary"]).To(Equal(before.ImageTags["Primary"]))
})
Describe("unresolved embedded art", func() {
BeforeEach(func() {
DeferCleanup(configtest.SetupConfig())
conf.Server.EnableMediaFileCoverArt = true
})
It("advertises the track id so the client triggers the read-through", func() {
mf := model.MediaFile{ID: testID("mf-1"), AlbumID: testID("alb-1"), HasCoverArt: true}
mf.AlbumImage.ImageHash = "0123456789abcdef"
item := SongToBaseItem(mf, nil)
Expect(item.ImageTags).To(HaveKeyWithValue("Primary", testID("mf-1")))
Expect(item.ImageBlurHashes).To(BeNil(), "no resolved image means no blurhash to send")
Expect(item.AlbumPrimaryImageTag).To(BeEmpty())
})
It("falls back to the album when the track has no art of its own", func() {
mf := model.MediaFile{ID: testID("mf-2"), AlbumID: testID("alb-1"), HasCoverArt: false}
mf.AlbumImage.ImageHash = "0123456789abcdef"
item := SongToBaseItem(mf, nil)
Expect(item.ImageTags).To(BeEmpty())
Expect(item.AlbumPrimaryImageTag).To(Equal("0123456789abcdef"))
})
It("falls back to the album once the track's art is known absent", func() {
mf := model.MediaFile{ID: testID("mf-3"), AlbumID: testID("alb-1"), HasCoverArt: true}
mf.ItemImage.ImageAbsent = true
mf.AlbumImage.ImageHash = "0123456789abcdef"
item := SongToBaseItem(mf, nil)
Expect(item.ImageTags).To(BeEmpty())
Expect(item.AlbumPrimaryImageTag).To(Equal("0123456789abcdef"))
})
It("falls back to the album when per-track art is disabled", func() {
conf.Server.EnableMediaFileCoverArt = false
mf := model.MediaFile{ID: testID("mf-4"), AlbumID: testID("alb-1"), HasCoverArt: true}
mf.AlbumImage.ImageHash = "0123456789abcdef"
item := SongToBaseItem(mf, nil)
Expect(item.ImageTags).To(BeEmpty())
Expect(item.AlbumPrimaryImageTag).To(Equal("0123456789abcdef"))
})
})
Describe("primary image tags", func() {
It("uses the content hash as the tag and emits the real blurhash", func() {
al := model.Album{ID: testID("alb-1"), Name: "Album"}
al.ImageHash = "0123456789abcdef"
al.BlurHash = "LEHV6nWB2yk8"
item := AlbumToBaseItem(al, nil)
Expect(item.ImageTags).To(HaveKeyWithValue("Primary", "0123456789abcdef"))
Expect(item.ImageBlurHashes["Primary"]).To(HaveKeyWithValue("0123456789abcdef", "LEHV6nWB2yk8"))
})
It("omits the blurhash entirely when none was computed", func() {
al := model.Album{ID: testID("alb-2"), Name: "Album"}
al.ImageHash = "0123456789abcdef"
item := AlbumToBaseItem(al, nil)
Expect(item.ImageTags).To(HaveKeyWithValue("Primary", "0123456789abcdef"))
Expect(item.ImageBlurHashes).To(BeNil(), "a synthesized blurhash pins stale covers in Finamp")
})
It("omits tags for known-absent artwork", func() {
al := model.Album{ID: testID("alb-3"), Name: "Album"}
al.ImageAbsent = true
item := AlbumToBaseItem(al, nil)
Expect(item.ImageTags).To(BeEmpty())
Expect(item.ImageBlurHashes).To(BeNil())
})
It("falls back to the entity id while artwork is still unresolved", func() {
item := AlbumToBaseItem(model.Album{ID: testID("alb-4"), Name: "Album"}, nil)
Expect(item.ImageTags).To(HaveKeyWithValue("Primary", testID("alb-4")))
Expect(item.ImageBlurHashes).To(BeNil())
})
It("versions an artist's tag by content hash", func() {
ar := model.Artist{ID: testID("art-1"), Name: "Artist"}
ar.ImageHash = "fedcba9876543210"
ar.BlurHash = "L6PZfSi_.AyE"
item := ArtistToBaseItem(ar, nil)
Expect(item.ImageTags).To(HaveKeyWithValue("Primary", "fedcba9876543210"))
Expect(item.ImageBlurHashes["Primary"]).To(HaveKeyWithValue("fedcba9876543210", "L6PZfSi_.AyE"))
})
})
Describe("song and playlist image tags", func() {
It("versions a song's album tag by the album's content hash", func() {
mf := model.MediaFile{ID: testID("song-1"), Title: "Song", AlbumID: testID("alb-1")}
mf.AlbumImage.ImageHash = "0123456789abcdef"
mf.AlbumImage.BlurHash = "LEHV6nWB2yk8"
item := SongToBaseItem(mf, nil)
Expect(item.AlbumPrimaryImageTag).To(Equal("0123456789abcdef"))
Expect(item.ImageBlurHashes["Primary"]).To(HaveKeyWithValue("0123456789abcdef", "LEHV6nWB2yk8"))
})
It("never synthesizes a song blurhash when the album has none", func() {
mf := model.MediaFile{ID: testID("song-2"), Title: "Song", AlbumID: testID("alb-2")}
mf.AlbumImage.ImageHash = "0123456789abcdef"
item := SongToBaseItem(mf, nil)
Expect(item.AlbumPrimaryImageTag).To(Equal("0123456789abcdef"))
Expect(item.ImageBlurHashes).To(BeNil())
})
It("omits a song's album tag when the album art is known absent", func() {
mf := model.MediaFile{ID: testID("song-3"), Title: "Song", AlbumID: testID("alb-3")}
mf.AlbumImage.ImageAbsent = true
item := SongToBaseItem(mf, nil)
Expect(item.AlbumPrimaryImageTag).To(BeEmpty())
Expect(item.ImageBlurHashes).To(BeNil())
})
It("versions a playlist tag by content hash instead of UpdatedAt", func() {
pl := model.Playlist{ID: testID("pl-1"), Name: "Playlist"}
pl.ImageHash = "abcdef0123456789"
item := PlaylistToBaseItem(pl, nil)
Expect(item.ImageTags).To(HaveKeyWithValue("Primary", "abcdef0123456789"))
})
})
Describe("per-song artwork", func() {
It("emits the track's own Primary tag when it has distinct art", func() {
mf := model.MediaFile{ID: testID("song-own"), Title: "Song", AlbumID: testID("alb-1")}
mf.ImageHash = "aaaaaaaaaaaaaaaa"
mf.BlurHash = "LTRACKblur"
mf.AlbumImage.ImageHash = "bbbbbbbbbbbbbbbb"
mf.AlbumImage.BlurHash = "LALBUMblur"
item := SongToBaseItem(mf, nil)
Expect(item.ImageTags).To(HaveKeyWithValue("Primary", "aaaaaaaaaaaaaaaa"))
Expect(item.ImageBlurHashes["Primary"]).To(HaveLen(1),
"exactly one Primary entry: Go sorts map keys, so a second entry could pair the wrong blurhash with imageId")
Expect(item.ImageBlurHashes["Primary"]).To(HaveKeyWithValue("aaaaaaaaaaaaaaaa", "LTRACKblur"))
})
It("falls back to the album tag when the track has no distinct art", func() {
mf := model.MediaFile{ID: testID("song-inherit"), Title: "Song", AlbumID: testID("alb-1")}
mf.ImageHash = "bbbbbbbbbbbbbbbb"
mf.BlurHash = "LALBUMblur"
mf.AlbumImage.ImageHash = "bbbbbbbbbbbbbbbb"
mf.AlbumImage.BlurHash = "LALBUMblur"
item := SongToBaseItem(mf, nil)
Expect(item.ImageTags).To(BeEmpty(), "an inherited cover is the album's image, not the track's")
Expect(item.AlbumPrimaryImageTag).To(Equal("bbbbbbbbbbbbbbbb"))
Expect(item.ImageBlurHashes["Primary"]).To(HaveLen(1))
Expect(item.ImageBlurHashes["Primary"]).To(HaveKeyWithValue("bbbbbbbbbbbbbbbb", "LALBUMblur"))
})
It("omits the track tag when its own art is known absent", func() {
mf := model.MediaFile{ID: testID("song-absent"), Title: "Song", AlbumID: testID("alb-1")}
mf.ImageAbsent = true
mf.AlbumImage.ImageAbsent = true
item := SongToBaseItem(mf, nil)
Expect(item.ImageTags).To(BeEmpty())
Expect(item.AlbumPrimaryImageTag).To(BeEmpty())
Expect(item.ImageBlurHashes).To(BeNil())
})
})
})
var _ = Describe("LyricDtoFromLyrics", func() {
ms := func(v int64) *int64 { return &v }
mf := model.MediaFile{ID: testID("s1"), Title: "Song", Artist: "Artist", Album: "Album", Duration: 100}
It("maps synced lyrics with tick conversion", func() {
l := model.Lyrics{
DisplayArtist: "Display Artist",
DisplayTitle: "Display Title",
Synced: true,
Offset: ms(-150),
Line: []model.Line{
{Start: ms(1000), Value: "line one"},
{Start: ms(2500), Value: "line two"},
},
}
d := LyricDtoFromLyrics(mf, l)
Expect(d.Metadata.Artist).To(Equal("Display Artist"))
Expect(d.Metadata.Title).To(Equal("Display Title"))
Expect(d.Metadata.Album).To(Equal("Album"))
Expect(d.Metadata.IsSynced).To(BeTrue())
Expect(*d.Metadata.Offset).To(Equal(int64(-1_500_000)))
Expect(d.Metadata.Length).To(Equal(TicksFromSeconds(100)))
Expect(d.Lyrics).To(HaveLen(2))
Expect(d.Lyrics[0].Text).To(Equal("line one"))
Expect(*d.Lyrics[0].Start).To(Equal(int64(10_000_000)))
Expect(*d.Lyrics[1].Start).To(Equal(int64(25_000_000)))
})
It("falls back to the media file's artist and title", func() {
d := LyricDtoFromLyrics(mf, model.Lyrics{Line: []model.Line{{Value: "x"}}})
Expect(d.Metadata.Artist).To(Equal("Artist"))
Expect(d.Metadata.Title).To(Equal("Song"))
})
It("drops start-less lines from synced lyrics", func() {
l := model.Lyrics{Synced: true, Line: []model.Line{
{Start: ms(0), Value: "kept"},
{Value: "dropped"},
}}
d := LyricDtoFromLyrics(mf, l)
Expect(d.Lyrics).To(HaveLen(1))
Expect(d.Lyrics[0].Text).To(Equal("kept"))
})
It("emits no Start on unsynced lyrics even when lines have one", func() {
l := model.Lyrics{Synced: false, Line: []model.Line{{Start: ms(1000), Value: "plain"}}}
d := LyricDtoFromLyrics(mf, l)
Expect(d.Lyrics).To(HaveLen(1))
Expect(d.Lyrics[0].Start).To(BeNil())
Expect(d.Metadata.IsSynced).To(BeFalse())
})
It("maps word cues", func() {
end := int64(1500)
l := model.Lyrics{Synced: true, Line: []model.Line{{
Start: ms(1000),
Value: "word cue",
Cue: []model.Cue{{Start: ms(1000), End: &end, Value: "word", ByteStart: 0, ByteEnd: 4}},
}}}
d := LyricDtoFromLyrics(mf, l)
Expect(d.Lyrics[0].Cues).To(HaveLen(1))
c := d.Lyrics[0].Cues[0]
Expect(c.Position).To(Equal(0))
Expect(c.EndPosition).To(Equal(4))
Expect(c.Start).To(Equal(int64(10_000_000)))
Expect(*c.End).To(Equal(int64(15_000_000)))
})
It("skips a start-less cue while keeping its sibling", func() {
l := model.Lyrics{Synced: true, Line: []model.Line{{
Start: ms(1000),
Value: "word cue",
Cue: []model.Cue{
{Start: nil, Value: "dropped", ByteStart: 0, ByteEnd: 7},
{Start: ms(1000), Value: "kept", ByteStart: 8, ByteEnd: 12},
},
}}}
d := LyricDtoFromLyrics(mf, l)
Expect(d.Lyrics[0].Cues).To(HaveLen(1))
c := d.Lyrics[0].Cues[0]
Expect(c.Position).To(Equal(8))
Expect(c.EndPosition).To(Equal(12))
Expect(c.Start).To(Equal(int64(10_000_000)))
})
})