🏡 index : quine-global/quintodrome.git

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)))
		})

		// SortName must match the server sort order — see the sortName helper.
		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())
			// "[]" is the no-lyrics sentinel, not a truthy value.
			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"))},
		}))
		// AlbumArtists stays single, matching real Jellyfin.
		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())
		// Substring check covers both keys (AlbumNormalizationGain contains NormalizationGain).
		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())
		// Jellyfin sends both on every album; Manet stops its sync on an album without them.
		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`))
		// Real Jellyfin never sets AlbumNormalizationGain on an album item.
		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))
		})

		// 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: 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() {
		// Finamp re-sorts "Latest Releases" client-side by PremiereDate; absent values sort arbitrarily.
		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"]))
	})

	// Nothing enqueues media files, so a track's own art only resolves when a client requests it;
	// advertising just the album image would leave that cover unreachable.
	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)))
	})
})