navidrome/model/mediafile_test.go
Deluan Quintão da56df3160
feat(smartplaylist): extend isMissing/isPresent to bpm, bitDepth and many text fields (#5603)
* feat(smartplaylist): support isMissing/isPresent on mbz_* and lyrics fields

Mark the six mbz_* MusicBrainz ID columns and the lyrics column as Nullable
in the criteria field map, then extend missingExpr to handle string columns
where absence is encoded as NULL or empty string (plus '[]' for lyrics). The
Numeric/Boolean path (ReplayGain) is preserved via an explicit type check.

* refactor(model): make MediaFile BPM and BitDepth nullable pointers

Convert BPM and BitDepth fields in model.MediaFile from int to *int so that
'tag absent' is distinguishable from zero. The metadata mapper now uses
NullableFloat for BPM (nil when absent or zero/unparseable) and only sets
BitDepth when the audio property is non-zero (lossy codecs report 0).

All read sites use gg.V() for zero-fallback deref so Subsonic API output and
transcoding behaviour are byte-identical to before. The persistence layer
bridges the existing NOT NULL DB columns by coercing nil to 0 on write and 0
back to nil on read in PostMapArgs/PostScan; a later migration task will drop
those constraints.

Hash upgrade safety is verified by a new MediaFile.Hash describe block: nil
*int hashes identically to the old int(0) default via ZeroNil+IgnoreZeroValue,
so no files will be spuriously re-imported after this change.

Extra files touched beyond the plan's list: core/stream/legacy_client_test.go
(BitDepth in model.MediaFile literals), persistence/mediafile_repository.go
(NOT NULL bridge).

* test(model): pin pre-conversion golden hashes for BPM/BitDepth

* feat(smartplaylist): support isMissing/isPresent on bpm and bitDepth

* feat(db): make bpm and bit_depth columns nullable, backfill 0 to NULL

Drop the NOT NULL constraint on media_file.bpm and bit_depth via a
lossless migration that converts legacy 0-means-absent values to real
NULL. Remove the temporary shim in PostScan/PostMapArgs that was bridging
the old NOT NULL columns to the *int model fields. Add round-trip
persistence tests asserting NULL storage for nil pointers and correct
value round-trip for non-nil pointers.

* test(e2e): verify isMissing/isPresent partition for nullable fields

Add DescribeTable covering bpm, bitdepth, lyrics, and mbz_recording_id:
for each field, isMissing + isPresent song counts must equal the total
library count, proving the nullable-column SQL is exhaustive and correct.

* test(e2e): seed bpm tag so isMissing/isPresent partition is non-trivial

* fix(model): omit bitDepth from JSON when absent instead of emitting null

* feat(smartplaylist): support isMissing/isPresent on more string fields

Enable isMissing/isPresent operators for album, comment, catalognumber,
discsubtitle, albumcomment, sorttitle, sortalbum, sortartist,
sortalbumartist, and explicitstatus by marking them Nullable in fieldMap.

* refactor(smartplaylist): unify missingExpr column logic into one flow

Collapse the numeric/string fork in missingExpr into a single
empties-driven loop (numeric/boolean fields simply have no empties),
and replace the duplicated IsTag/IsRole guard with a three-way switch
that expresses the dispatch model once. No SQL semantics change for
string fields; numeric/boolean fields now emit a single-element Or/And
which squirrel parenthesizes (e.g. `(col IS NULL)` instead of bare
`col IS NULL`) — update the affected test expectations accordingly.
2026-06-13 13:15:20 -04:00

633 lines
24 KiB
Go

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: new(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"}
Expect(mf.AudioCodec()).To(Equal("alac"))
})
})
})
var _ = Describe("MediaFile.Hash", func() {
// Guards the upgrade guarantee: converting BPM/BitDepth from int to *int must not change hashes,
// or every file would be spuriously re-imported on the next scan.
// Golden hashes were captured at 46221d516 when those fields were plain ints.
It("keeps hashes identical to the pre-pointer-conversion values", func() {
// Golden hashes computed at 46221d516, when BPM/BitDepth were plain ints — pinning
// them guarantees the pointer conversion cannot trigger a full-library re-import.
Expect(MediaFile{Title: "Song"}.Hash()).To(Equal("1d856ced42cb96db39e354a4bac9a622"))
Expect(MediaFile{Title: "Song", BPM: new(120), BitDepth: new(16)}.Hash()).To(Equal("b2b0b1d1dd7fd767093588e4af3a0689"))
})
It("changes the hash when a pointer field has a value", func() {
base := MediaFile{Title: "Song"}
Expect(base.Equals(MediaFile{Title: "Song", BPM: new(120)})).To(BeFalse())
Expect(base.Equals(MediaFile{Title: "Song", BitDepth: new(24)})).To(BeFalse())
})
})
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{}
}