🏡 index : ironmagma/quintodrome.git

package model_test

import (
	"time"

	"github.com/navidrome/navidrome/conf"
	"github.com/navidrome/navidrome/conf/configtest"
	. "github.com/navidrome/navidrome/model"
	"github.com/navidrome/navidrome/tests"
	. "github.com/onsi/ginkgo/v2"
	. "github.com/onsi/gomega"
)

var _ = Describe("MediaFiles", func() {
	var mfs MediaFiles

	Describe("ToAlbum", func() {
		Context("Simple attributes", func() {
			BeforeEach(func() {
				mfs = MediaFiles{
					{
						ID: "1", AlbumID: "AlbumID", Album: "Album", ArtistID: "ArtistID", Artist: "Artist", AlbumArtistID: "AlbumArtistID", AlbumArtist: "AlbumArtist",
						SortAlbumName: "SortAlbumName", SortArtistName: "SortArtistName", SortAlbumArtistName: "SortAlbumArtistName",
						OrderAlbumName: "OrderAlbumName", OrderAlbumArtistName: "OrderAlbumArtistName",
						MbzAlbumArtistID: "MbzAlbumArtistID", MbzAlbumType: "MbzAlbumType", MbzAlbumComment: "MbzAlbumComment",
						MbzReleaseGroupID: "MbzReleaseGroupID", Compilation: false, CatalogNum: "", Path: "music1/file1.mp3", FolderID: "Folder1",
					},
					{
						ID: "2", Album: "Album", ArtistID: "ArtistID", Artist: "Artist", AlbumArtistID: "AlbumArtistID", AlbumArtist: "AlbumArtist", AlbumID: "AlbumID",
						SortAlbumName: "SortAlbumName", SortArtistName: "SortArtistName", SortAlbumArtistName: "SortAlbumArtistName",
						OrderAlbumName: "OrderAlbumName", OrderArtistName: "OrderArtistName", OrderAlbumArtistName: "OrderAlbumArtistName",
						MbzAlbumArtistID: "MbzAlbumArtistID", MbzAlbumType: "MbzAlbumType", MbzAlbumComment: "MbzAlbumComment",
						MbzReleaseGroupID: "MbzReleaseGroupID",
						Compilation:       true, CatalogNum: "CatalogNum", HasCoverArt: true, Path: "music2/file2.mp3", FolderID: "Folder2",
					},
				}
			})

			It("sets the single values correctly", func() {
				album := mfs.ToAlbum()
				Expect(album.ID).To(Equal("AlbumID"))
				Expect(album.Name).To(Equal("Album"))
				Expect(album.AlbumArtist).To(Equal("AlbumArtist"))
				Expect(album.AlbumArtistID).To(Equal("AlbumArtistID"))
				Expect(album.SortAlbumName).To(Equal("SortAlbumName"))
				Expect(album.SortAlbumArtistName).To(Equal("SortAlbumArtistName"))
				Expect(album.OrderAlbumName).To(Equal("OrderAlbumName"))
				Expect(album.OrderAlbumArtistName).To(Equal("OrderAlbumArtistName"))
				Expect(album.MbzAlbumArtistID).To(Equal("MbzAlbumArtistID"))
				Expect(album.MbzAlbumType).To(Equal("MbzAlbumType"))
				Expect(album.MbzAlbumComment).To(Equal("MbzAlbumComment"))
				Expect(album.MbzReleaseGroupID).To(Equal("MbzReleaseGroupID"))
				Expect(album.CatalogNum).To(Equal("CatalogNum"))
				Expect(album.Compilation).To(BeTrue())
				Expect(album.EmbedArtPath).To(Equal("music2/file2.mp3"))
				Expect(album.FolderIDs).To(ConsistOf("Folder1", "Folder2"))
			})
		})
		Context("Aggregated attributes", func() {
			When("we don't have any songs", func() {
				BeforeEach(func() {
					mfs = MediaFiles{}
				})
				It("returns an empty album", func() {
					album := mfs.ToAlbum()
					Expect(album.Duration).To(Equal(float32(0)))
					Expect(album.Size).To(Equal(int64(0)))
					Expect(album.MinYear).To(Equal(0))
					Expect(album.MaxYear).To(Equal(0))
					Expect(album.Date).To(BeEmpty())
					Expect(album.UpdatedAt).To(BeZero())
					Expect(album.CreatedAt).To(BeZero())
				})
			})
			When("we have only one song", func() {
				BeforeEach(func() {
					mfs = MediaFiles{
						{Duration: 100.2, Size: 1024, Year: 1985, Date: "1985-01-02", UpdatedAt: t("2022-12-19 09:30"), BirthTime: t("2022-12-19 08:30")},
					}
				})
				It("calculates the aggregates correctly", func() {
					album := mfs.ToAlbum()
					Expect(album.Duration).To(Equal(float32(100.2)))
					Expect(album.Size).To(Equal(int64(1024)))
					Expect(album.MinYear).To(Equal(1985))
					Expect(album.MaxYear).To(Equal(1985))
					Expect(album.Date).To(Equal("1985-01-02"))
					Expect(album.UpdatedAt).To(Equal(t("2022-12-19 09:30")))
					Expect(album.CreatedAt).To(Equal(t("2022-12-19 08:30")))
				})
			})

			When("we have multiple songs with different dates", func() {
				BeforeEach(func() {
					mfs = MediaFiles{
						{Duration: 100.2, Size: 1024, Year: 1985, Date: "1985-01-02", UpdatedAt: t("2022-12-19 09:30"), BirthTime: t("2022-12-19 08:30")},
						{Duration: 200.2, Size: 2048, Year: 0, Date: "", UpdatedAt: t("2022-12-19 09:45"), BirthTime: t("2022-12-19 08:30")},
						{Duration: 150.6, Size: 1000, Year: 1986, Date: "1986-01-02", UpdatedAt: t("2022-12-19 09:45"), BirthTime: t("2022-12-19 07:30")},
					}
				})
				It("calculates the aggregates correctly", func() {
					album := mfs.ToAlbum()
					Expect(album.Duration).To(Equal(float32(451.0)))
					Expect(album.Size).To(Equal(int64(4072)))
					Expect(album.MinYear).To(Equal(1985))
					Expect(album.MaxYear).To(Equal(1986))
					Expect(album.Date).To(BeEmpty())
					Expect(album.UpdatedAt).To(Equal(t("2022-12-19 09:45")))
					Expect(album.CreatedAt).To(Equal(t("2022-12-19 07:30")))
				})
				Context("MinYear", func() {
					It("returns 0 when all values are 0", func() {
						mfs = MediaFiles{{Year: 0}, {Year: 0}, {Year: 0}}
						a := mfs.ToAlbum()
						Expect(a.MinYear).To(Equal(0))
					})
					It("returns the smallest value from the list, not counting 0", func() {
						mfs = MediaFiles{{Year: 2000}, {Year: 0}, {Year: 1999}}
						a := mfs.ToAlbum()
						Expect(a.MinYear).To(Equal(1999))
					})
				})
				Context("CreatedAt aggregation", func() {
					It("ignores zero BirthTime values when computing the oldest", func() {
						mfs = MediaFiles{
							{BirthTime: t("2022-12-19 08:30")},
							{BirthTime: time.Time{}},
							{BirthTime: t("2022-12-18 10:00")},
						}
						Expect(mfs.ToAlbum().CreatedAt).To(Equal(t("2022-12-18 10:00")))
					})
					It("returns zero when all BirthTime values are zero", func() {
						mfs = MediaFiles{{BirthTime: time.Time{}}, {BirthTime: time.Time{}}}
						Expect(mfs.ToAlbum().CreatedAt).To(BeZero())
					})
				})
			})
			When("we have multiple songs with same dates", func() {
				BeforeEach(func() {
					mfs = MediaFiles{
						{Duration: 100.2, Size: 1024, Year: 1985, Date: "1985-01-02", UpdatedAt: t("2022-12-19 09:30"), BirthTime: t("2022-12-19 08:30")},
						{Duration: 200.2, Size: 2048, Year: 1985, Date: "1985-01-02", UpdatedAt: t("2022-12-19 09:45"), BirthTime: t("2022-12-19 08:30")},
						{Duration: 150.6, Size: 1000, Year: 1985, Date: "1985-01-02", UpdatedAt: t("2022-12-19 09:45"), BirthTime: t("2022-12-19 07:30")},
					}
				})
				It("sets the date field correctly", func() {
					album := mfs.ToAlbum()
					Expect(album.Date).To(Equal("1985-01-02"))
					Expect(album.MinYear).To(Equal(1985))
					Expect(album.MaxYear).To(Equal(1985))
				})
			})
			DescribeTable("explicitStatus",
				func(mfs MediaFiles, status string) {
					Expect(mfs.ToAlbum().ExplicitStatus).To(Equal(status))
				},
				Entry("sets the album to clean when a clean song is present", MediaFiles{{ExplicitStatus: ""}, {ExplicitStatus: "c"}, {ExplicitStatus: ""}}, "c"),
				Entry("sets the album to explicit when an explicit song is present", MediaFiles{{ExplicitStatus: ""}, {ExplicitStatus: "e"}, {ExplicitStatus: ""}}, "e"),
				Entry("takes precedence of explicit songs over clean ones", MediaFiles{{ExplicitStatus: "e"}, {ExplicitStatus: "c"}, {ExplicitStatus: ""}}, "e"),
			)
		})
		Context("Calculated attributes", func() {
			Context("Discs", func() {
				When("we have no discs info", func() {
					BeforeEach(func() {
						mfs = MediaFiles{{Album: "Album1"}, {Album: "Album1"}, {Album: "Album1"}}
					})
					It("adds 1 disc without subtitle", func() {
						album := mfs.ToAlbum()
						Expect(album.Discs).To(Equal(Discs{1: ""}))
					})
				})
				When("we have only one disc", func() {
					BeforeEach(func() {
						mfs = MediaFiles{{DiscNumber: 1, DiscSubtitle: "DiscSubtitle"}}
					})
					It("sets the correct Discs", func() {
						album := mfs.ToAlbum()
						Expect(album.Discs).To(Equal(Discs{1: "DiscSubtitle"}))
					})
				})
				When("we have multiple discs", func() {
					BeforeEach(func() {
						mfs = MediaFiles{{DiscNumber: 1, DiscSubtitle: "DiscSubtitle"}, {DiscNumber: 2, DiscSubtitle: "DiscSubtitle2"}, {DiscNumber: 1, DiscSubtitle: "DiscSubtitle"}}
					})
					It("sets the correct Discs", func() {
						album := mfs.ToAlbum()
						Expect(album.Discs).To(Equal(Discs{1: "DiscSubtitle", 2: "DiscSubtitle2"}))
					})
				})
			})

			Context("Genres/tags", func() {
				When("we don't have any tags", func() {
					BeforeEach(func() {
						mfs = MediaFiles{{}}
					})
					It("sets the correct Genre", func() {
						album := mfs.ToAlbum()
						Expect(album.Tags).To(BeEmpty())
					})
				})
				When("we have only one Genre", func() {
					BeforeEach(func() {
						mfs = MediaFiles{{Tags: Tags{"genre": []string{"Rock"}}}}
					})
					It("sets the correct Genre", func() {
						album := mfs.ToAlbum()
						Expect(album.Tags).To(HaveLen(1))
						Expect(album.Tags).To(HaveKeyWithValue(TagGenre, []string{"Rock"}))
					})
				})
				When("we have multiple Genres", func() {
					BeforeEach(func() {
						mfs = MediaFiles{
							{Tags: Tags{"genre": []string{"Punk"}, "mood": []string{"Happy", "Chill"}}},
							{Tags: Tags{"genre": []string{"Rock"}}},
							{Tags: Tags{"genre": []string{"Alternative", "Rock"}}},
						}
					})
					It("sets the correct Genre, sorted by frequency, then alphabetically", func() {
						album := mfs.ToAlbum()
						Expect(album.Tags).To(HaveLen(2))
						Expect(album.Tags).To(HaveKeyWithValue(TagGenre, []string{"Rock", "Alternative", "Punk"}))
						Expect(album.Tags).To(HaveKeyWithValue(TagMood, []string{"Chill", "Happy"}))
					})
				})
				When("we have tags with mismatching case", func() {
					BeforeEach(func() {
						mfs = MediaFiles{
							{Tags: Tags{"genre": []string{"synthwave"}}},
							{Tags: Tags{"genre": []string{"Synthwave"}}},
						}
					})
					It("normalizes the tags in just one", func() {
						album := mfs.ToAlbum()
						Expect(album.Tags).To(HaveLen(1))
						Expect(album.Tags).To(HaveKeyWithValue(TagGenre, []string{"Synthwave"}))
					})
				})
			})
			Context("Comments", func() {
				When("we have only one Comment", func() {
					BeforeEach(func() {
						mfs = MediaFiles{{Comment: "comment1"}}
					})
					It("sets the correct Comment", func() {
						album := mfs.ToAlbum()
						Expect(album.Comment).To(Equal("comment1"))
					})
				})
				When("we have multiple equal comments", func() {
					BeforeEach(func() {
						mfs = MediaFiles{{Comment: "comment1"}, {Comment: "comment1"}, {Comment: "comment1"}}
					})
					It("sets the correct Comment", func() {
						album := mfs.ToAlbum()
						Expect(album.Comment).To(Equal("comment1"))
					})
				})
				When("we have different comments", func() {
					BeforeEach(func() {
						mfs = MediaFiles{{Comment: "comment1"}, {Comment: "not the same"}, {Comment: "comment1"}}
					})
					It("sets the correct comment", func() {
						album := mfs.ToAlbum()
						Expect(album.Comment).To(BeEmpty())
					})
				})
			})
			Context("Participants", func() {
				var album Album
				BeforeEach(func() {
					mfs = MediaFiles{
						{
							Album: "Album1", AlbumArtistID: "AA1", AlbumArtist: "Display AlbumArtist1", Artist: "Artist1",
							DiscSubtitle: "DiscSubtitle1", SortAlbumName: "SortAlbumName1",
							Participants: Participants{
								RoleAlbumArtist: ParticipantList{_p("AA1", "AlbumArtist1", "SortAlbumArtistName1")},
								RoleArtist:      ParticipantList{_p("A1", "Artist1", "SortArtistName1")},
							},
						},
						{
							Album: "Album1", AlbumArtistID: "AA1", AlbumArtist: "Display AlbumArtist1", Artist: "Artist2",
							DiscSubtitle: "DiscSubtitle2", SortAlbumName: "SortAlbumName1",
							Participants: Participants{
								RoleAlbumArtist: ParticipantList{_p("AA1", "AlbumArtist1", "SortAlbumArtistName1")},
								RoleArtist:      ParticipantList{_p("A2", "Artist2", "SortArtistName2")},
								RoleComposer:    ParticipantList{_p("C1", "Composer1")},
							},
						},
					}
					album = mfs.ToAlbum()
				})
				It("gets all participants from all tracks", func() {
					Expect(album.Participants).To(HaveKeyWithValue(RoleAlbumArtist, ParticipantList{_p("AA1", "AlbumArtist1", "SortAlbumArtistName1")}))
					Expect(album.Participants).To(HaveKeyWithValue(RoleComposer, ParticipantList{_p("C1", "Composer1")}))
					Expect(album.Participants).To(HaveKeyWithValue(RoleArtist, ParticipantList{
						_p("A1", "Artist1", "SortArtistName1"), _p("A2", "Artist2", "SortArtistName2"),
					}))
				})
			})
			Context("MbzAlbumID", func() {
				When("we have only one MbzAlbumID", func() {
					BeforeEach(func() {
						mfs = MediaFiles{{MbzAlbumID: "id1"}}
					})
					It("sets the correct MbzAlbumID", func() {
						album := mfs.ToAlbum()
						Expect(album.MbzAlbumID).To(Equal("id1"))
					})
				})
				When("we have multiple MbzAlbumID", func() {
					BeforeEach(func() {
						mfs = MediaFiles{{MbzAlbumID: "id1"}, {MbzAlbumID: "id2"}, {MbzAlbumID: "id1"}}
					})
					It("uses the most frequent MbzAlbumID", func() {
						album := mfs.ToAlbum()
						Expect(album.MbzAlbumID).To(Equal("id1"))
					})
				})
			})
			Context("Album Art", func() {
				When("we have media files with cover art from multiple discs", func() {
					BeforeEach(func() {
						mfs = MediaFiles{
							{
								Path:        "Artist/Album/Disc2/01.mp3",
								HasCoverArt: true,
								DiscNumber:  2,
							},
							{
								Path:        "Artist/Album/Disc1/01.mp3",
								HasCoverArt: true,
								DiscNumber:  1,
							},
							{
								Path:        "Artist/Album/Disc3/01.mp3",
								HasCoverArt: true,
								DiscNumber:  3,
							},
						}
					})
					It("selects the cover art from the lowest disc number", func() {
						album := mfs.ToAlbum()
						Expect(album.EmbedArtPath).To(Equal("Artist/Album/Disc1/01.mp3"))
					})
				})

				When("we have media files with cover art from the same disc number", func() {
					BeforeEach(func() {
						mfs = MediaFiles{
							{
								Path:        "Artist/Album/Disc1/02.mp3",
								HasCoverArt: true,
								DiscNumber:  1,
							},
							{
								Path:        "Artist/Album/Disc1/01.mp3",
								HasCoverArt: true,
								DiscNumber:  1,
							},
						}
					})
					It("selects the cover art with the lowest path alphabetically", func() {
						album := mfs.ToAlbum()
						Expect(album.EmbedArtPath).To(Equal("Artist/Album/Disc1/01.mp3"))
					})
				})

				When("we have media files with some missing cover art", func() {
					BeforeEach(func() {
						mfs = MediaFiles{
							{
								Path:        "Artist/Album/Disc1/01.mp3",
								HasCoverArt: false,
								DiscNumber:  1,
							},
							{
								Path:        "Artist/Album/Disc2/01.mp3",
								HasCoverArt: true,
								DiscNumber:  2,
							},
						}
					})
					It("selects the file with cover art even if from a higher disc number", func() {
						album := mfs.ToAlbum()
						Expect(album.EmbedArtPath).To(Equal("Artist/Album/Disc2/01.mp3"))
					})
				})

				When("we have media files with path names that don't correlate with disc numbers", func() {
					BeforeEach(func() {
						mfs = MediaFiles{
							{
								Path:        "Artist/Album/file-z.mp3", // Path would be sorted last alphabetically
								HasCoverArt: true,
								DiscNumber:  1, // But it has lowest disc number
							},
							{
								Path:        "Artist/Album/file-a.mp3", // Path would be sorted first alphabetically
								HasCoverArt: true,
								DiscNumber:  2, // But it has higher disc number
							},
							{
								Path:        "Artist/Album/file-m.mp3",
								HasCoverArt: true,
								DiscNumber:  3,
							},
						}
					})
					It("selects the cover art from the lowest disc number regardless of path", func() {
						album := mfs.ToAlbum()
						Expect(album.EmbedArtPath).To(Equal("Artist/Album/file-z.mp3"))
					})
				})
			})
		})
	})

	Describe("ToM3U8", func() {
		It("returns header only for empty MediaFiles", func() {
			mfs = MediaFiles{}
			result := mfs.ToM3U8("My Playlist", false)
			Expect(result).To(Equal("#EXTM3U\n#PLAYLIST:My Playlist\n"))
		})

		DescribeTable("duration formatting",
			func(duration float32, expected string) {
				mfs = MediaFiles{{Title: "Song", Artist: "Artist", Duration: duration, Path: "song.mp3"}}
				result := mfs.ToM3U8("Test", false)
				Expect(result).To(ContainSubstring(expected))
			},
			Entry("zero duration", float32(0.0), "#EXTINF:0,"),
			Entry("whole number", float32(120.0), "#EXTINF:120,"),
			Entry("rounds 0.5 down", float32(180.5), "#EXTINF:180,"),
			Entry("rounds 0.6 up", float32(240.6), "#EXTINF:241,"),
		)

		Context("multiple tracks", func() {
			BeforeEach(func() {
				mfs = MediaFiles{
					{Title: "Song One", Artist: "Artist A", Duration: 120, Path: "a/song1.mp3", LibraryPath: "/music"},
					{Title: "Song Two", Artist: "Artist B", Duration: 241, Path: "b/song2.mp3", LibraryPath: "/music"},
					{Title: "Song with \"quotes\" & ampersands", Artist: "Artist with Ümläuts", Duration: 90, Path: "special/file.mp3", LibraryPath: "/música"},
				}
			})

			DescribeTable("generates correct output",
				func(absolutePaths bool, expectedContent string) {
					if absolutePaths {
						tests.SkipOnWindows("path separator bug (#TBD-path-sep-model)")
					}
					result := mfs.ToM3U8("Multi Track", absolutePaths)
					Expect(result).To(Equal(expectedContent))
				},
				Entry("relative paths",
					false,
					"#EXTM3U\n#PLAYLIST:Multi Track\n#EXTINF:120,Artist A - Song One\na/song1.mp3\n#EXTINF:241,Artist B - Song Two\nb/song2.mp3\n#EXTINF:90,Artist with Ümläuts - Song with \"quotes\" & ampersands\nspecial/file.mp3\n",
				),
				Entry("absolute paths",
					true,
					"#EXTM3U\n#PLAYLIST:Multi Track\n#EXTINF:120,Artist A - Song One\n/music/a/song1.mp3\n#EXTINF:241,Artist B - Song Two\n/music/b/song2.mp3\n#EXTINF:90,Artist with Ümläuts - Song with \"quotes\" & ampersands\n/música/special/file.mp3\n",
				),
				Entry("special characters",
					false,
					"#EXTM3U\n#PLAYLIST:Multi Track\n#EXTINF:120,Artist A - Song One\na/song1.mp3\n#EXTINF:241,Artist B - Song Two\nb/song2.mp3\n#EXTINF:90,Artist with Ümläuts - Song with \"quotes\" & ampersands\nspecial/file.mp3\n",
				),
			)
		})

		Context("path variations", func() {
			It("handles different path structures", func() {
				tests.SkipOnWindows("path separator bug (#TBD-path-sep-model)")
				mfs = MediaFiles{
					{Title: "Root", Artist: "Artist", Duration: 60, Path: "song.mp3", LibraryPath: "/lib"},
					{Title: "Nested", Artist: "Artist", Duration: 60, Path: "deep/nested/song.mp3", LibraryPath: "/lib"},
				}

				relativeResult := mfs.ToM3U8("Test", false)
				Expect(relativeResult).To(ContainSubstring("song.mp3\n"))
				Expect(relativeResult).To(ContainSubstring("deep/nested/song.mp3\n"))

				absoluteResult := mfs.ToM3U8("Test", true)
				Expect(absoluteResult).To(ContainSubstring("/lib/song.mp3\n"))
				Expect(absoluteResult).To(ContainSubstring("/lib/deep/nested/song.mp3\n"))
			})
		})
	})
})

var _ = Describe("MediaFile", func() {
	BeforeEach(func() {
		DeferCleanup(configtest.SetupConfig())
		conf.Server.EnableMediaFileCoverArt = true
	})
	DescribeTable("FullTitle",
		func(enabled bool, tags Tags, expected string) {
			conf.Server.Subsonic.AppendSubtitle = enabled
			mf := MediaFile{Title: "Song", Tags: tags}
			Expect(mf.FullTitle()).To(Equal(expected))
		},
		Entry("appends subtitle when enabled and tag is present", true, Tags{TagSubtitle: []string{"Live"}}, "Song (Live)"),
		Entry("returns just title when disabled", false, Tags{TagSubtitle: []string{"Live"}}, "Song"),
		Entry("returns just title when tag is absent", true, Tags{}, "Song"),
		Entry("returns just title when tag is an empty slice", true, Tags{TagSubtitle: []string{}}, "Song"),
	)
	DescribeTable("FullAlbumName",
		func(enabled bool, tags Tags, expected string) {
			conf.Server.Subsonic.AppendAlbumVersion = enabled
			mf := MediaFile{Album: "Album", Tags: tags}
			Expect(mf.FullAlbumName()).To(Equal(expected))
		},
		Entry("appends version when enabled and tag is present", true, Tags{TagAlbumVersion: []string{"Deluxe Edition"}}, "Album (Deluxe Edition)"),
		Entry("returns just album name when disabled", false, Tags{TagAlbumVersion: []string{"Deluxe Edition"}}, "Album"),
		Entry("returns just album name when tag is absent", true, Tags{}, "Album"),
		Entry("returns just album name when tag is an empty slice", true, Tags{TagAlbumVersion: []string{}}, "Album"),
	)
	Describe("CoverArtId", func() {
		It("returns its own id if it HasCoverArt", func() {
			mf := MediaFile{ID: "111", AlbumID: "1", HasCoverArt: true}
			id := mf.CoverArtID()
			Expect(id.Kind).To(Equal(KindMediaFileArtwork))
			Expect(id.ID).To(Equal(mf.ID))
		})
		It("returns disc art id if HasCoverArt is false and DiscNumber > 0", func() {
			mf := MediaFile{ID: "111", AlbumID: "1", HasCoverArt: false, DiscNumber: 2}
			id := mf.CoverArtID()
			Expect(id.Kind).To(Equal(KindDiscArtwork))
			Expect(id.ID).To(Equal("1:2"))
		})
		It("returns its album id if HasCoverArt is false and DiscNumber is 0", func() {
			mf := MediaFile{ID: "111", AlbumID: "1", HasCoverArt: false}
			id := mf.CoverArtID()
			Expect(id.Kind).To(Equal(KindAlbumArtwork))
			Expect(id.ID).To(Equal(mf.AlbumID))
		})
		It("returns disc art id if EnableMediaFileCoverArt is disabled and DiscNumber > 0", func() {
			conf.Server.EnableMediaFileCoverArt = false
			mf := MediaFile{ID: "111", AlbumID: "1", HasCoverArt: true, DiscNumber: 3}
			id := mf.CoverArtID()
			Expect(id.Kind).To(Equal(KindDiscArtwork))
			Expect(id.ID).To(Equal("1:3"))
		})
		It("returns its album id if EnableMediaFileCoverArt is disabled and DiscNumber is 0", func() {
			conf.Server.EnableMediaFileCoverArt = false
			mf := MediaFile{ID: "111", AlbumID: "1", HasCoverArt: true}
			id := mf.CoverArtID()
			Expect(id.Kind).To(Equal(KindAlbumArtwork))
			Expect(id.ID).To(Equal(mf.AlbumID))
		})
	})

	Describe("AudioCodec", func() {
		It("returns normalized stored codec when available", func() {
			mf := MediaFile{Codec: "AAC", Suffix: "m4a"}
			Expect(mf.AudioCodec()).To(Equal("aac"))
		})

		It("returns stored codec lowercased", func() {
			mf := MediaFile{Codec: "ALAC", Suffix: "m4a"}
			Expect(mf.AudioCodec()).To(Equal("alac"))
		})

		DescribeTable("infers codec from suffix when Codec field is empty",
			func(suffix string, bitDepth int, expected string) {
				mf := MediaFile{Suffix: suffix, BitDepth: bitDepth}
				Expect(mf.AudioCodec()).To(Equal(expected))
			},
			Entry("mp3", "mp3", 0, "mp3"),
			Entry("mpga", "mpga", 0, "mp3"),
			Entry("mp2", "mp2", 0, "mp2"),
			Entry("ogg", "ogg", 0, "vorbis"),
			Entry("oga", "oga", 0, "vorbis"),
			Entry("opus", "opus", 0, "opus"),
			Entry("mpc", "mpc", 0, "mpc"),
			Entry("wma", "wma", 0, "wma"),
			Entry("flac", "flac", 0, "flac"),
			Entry("wav", "wav", 0, "pcm"),
			Entry("aif", "aif", 0, "pcm"),
			Entry("aiff", "aiff", 0, "pcm"),
			Entry("aifc", "aifc", 0, "pcm"),
			Entry("ape", "ape", 0, "ape"),
			Entry("wv", "wv", 0, "wv"),
			Entry("wvp", "wvp", 0, "wv"),
			Entry("tta", "tta", 0, "tta"),
			Entry("tak", "tak", 0, "tak"),
			Entry("shn", "shn", 0, "shn"),
			Entry("dsf", "dsf", 0, "dsd"),
			Entry("dff", "dff", 0, "dsd"),
			Entry("m4a with BitDepth=0 (AAC)", "m4a", 0, "aac"),
			Entry("m4a with BitDepth>0 (ALAC)", "m4a", 16, "alac"),
			Entry("m4b", "m4b", 0, "aac"),
			Entry("m4p", "m4p", 0, "aac"),
			Entry("m4r", "m4r", 0, "aac"),
			Entry("unknown suffix", "xyz", 0, ""),
		)

		It("prefers stored codec over suffix inference", func() {
			mf := MediaFile{Codec: "ALAC", Suffix: "m4a", BitDepth: 0}
			Expect(mf.AudioCodec()).To(Equal("alac"))
		})
	})

})

func t(v string) time.Time {
	var timeFormats = []string{"2006-01-02", "2006-01-02 15:04", "2006-01-02 15:04:05", "2006-01-02T15:04:05", "2006-01-02T15:04", "2006-01-02 15:04:05.999999999 -0700 MST"}
	for _, f := range timeFormats {
		t, err := time.ParseInLocation(f, v, time.UTC)
		if err == nil {
			return t.UTC()
		}
	}
	return time.Time{}
}