mirror of
https://github.com/navidrome/navidrome.git
synced 2026-08-01 07:21:17 +00:00
When AppendSubtitle or AppendAlbumVersion is enabled, the subtitle/version
tag was always wrapped in parentheses and appended to the title/album name.
If the tag value already came wrapped in brackets (e.g. "(non-explicit
version)"), the result was doubled: "Title ((non-explicit version))".
Append the tag as-is when it is already wrapped in a matching bracket pair
- (), [], {} or <> - and trim surrounding whitespace first. The shared
appendSuffix helper is used by MediaFile.FullTitle, MediaFile.FullAlbumName
and Album.FullName so all consumers behave consistently.
744 lines
29 KiB
Go
744 lines
29 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("ReplayGain", func() {
|
|
It("picks the most frequent non-nil album gain and peak", func() {
|
|
mfs := MediaFiles{
|
|
{Path: "a", RGAlbumGain: new(-8.0), RGAlbumPeak: new(0.9)},
|
|
{Path: "b", RGAlbumGain: new(-8.0), RGAlbumPeak: new(0.9)},
|
|
{Path: "c", RGAlbumGain: new(-5.0), RGAlbumPeak: new(1.0)},
|
|
}
|
|
album := mfs.ToAlbum()
|
|
Expect(album.RGAlbumGain).ToNot(BeNil())
|
|
Expect(*album.RGAlbumGain).To(Equal(-8.0))
|
|
Expect(album.RGAlbumPeak).ToNot(BeNil())
|
|
Expect(*album.RGAlbumPeak).To(Equal(0.9))
|
|
})
|
|
It("keeps a genuine 0.0 gain instead of dropping it", func() {
|
|
mfs := MediaFiles{
|
|
{Path: "a", RGAlbumGain: new(0.0)},
|
|
{Path: "b", RGAlbumGain: new(0.0)},
|
|
}
|
|
album := mfs.ToAlbum()
|
|
Expect(album.RGAlbumGain).ToNot(BeNil())
|
|
Expect(*album.RGAlbumGain).To(Equal(0.0))
|
|
})
|
|
It("leaves gain and peak nil when no track has a value", func() {
|
|
mfs := MediaFiles{{Path: "a"}, {Path: "b"}}
|
|
album := mfs.ToAlbum()
|
|
Expect(album.RGAlbumGain).To(BeNil())
|
|
Expect(album.RGAlbumPeak).To(BeNil())
|
|
})
|
|
})
|
|
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"),
|
|
Entry("does not double parentheses when subtitle is already parenthesized", true, Tags{TagSubtitle: []string{"(non-explicit version)"}}, "Song (non-explicit version)"),
|
|
Entry("does not add parentheses when subtitle is wrapped in square brackets", true, Tags{TagSubtitle: []string{"[Live]"}}, "Song [Live]"),
|
|
Entry("does not add parentheses when subtitle is wrapped in curly braces", true, Tags{TagSubtitle: []string{"{Remix}"}}, "Song {Remix}"),
|
|
Entry("does not add parentheses when subtitle is wrapped in angle brackets", true, Tags{TagSubtitle: []string{"<Live>"}}, "Song <Live>"),
|
|
Entry("adds parentheses when brackets do not match", true, Tags{TagSubtitle: []string{"[Live)"}}, "Song ([Live))"),
|
|
Entry("trims surrounding whitespace before wrapping", true, Tags{TagSubtitle: []string{" Live "}}, "Song (Live)"),
|
|
Entry("trims whitespace around an already-bracketed subtitle", true, Tags{TagSubtitle: []string{" (Live) "}}, "Song (Live)"),
|
|
)
|
|
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"),
|
|
Entry("does not double parentheses when version is already parenthesized", true, Tags{TagAlbumVersion: []string{"(Deluxe Edition)"}}, "Album (Deluxe Edition)"),
|
|
Entry("does not add parentheses when version is wrapped in square brackets", true, Tags{TagAlbumVersion: []string{"[Deluxe Edition]"}}, "Album [Deluxe Edition]"),
|
|
)
|
|
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 _ = DescribeTable("MediaFile.HasEmbeddedLyrics",
|
|
func(lyrics string, expected bool) {
|
|
Expect(MediaFile{Lyrics: lyrics}.HasEmbeddedLyrics()).To(Equal(expected))
|
|
},
|
|
Entry("empty string (never-scanned zero value)", "", false),
|
|
Entry(`the post-scan "[]" no-lyrics sentinel`, "[]", false),
|
|
Entry("a stored lyric list", `[{"lang":"eng","line":[{"value":"la"}]}]`, true),
|
|
)
|
|
|
|
var _ = Describe("MediaFile.Works", func() {
|
|
It("returns nil when there are no work tags", func() {
|
|
mf := MediaFile{}
|
|
Expect(mf.Works()).To(BeNil())
|
|
})
|
|
|
|
It("pairs a work name with its MbzWorkID", func() {
|
|
mf := MediaFile{Tags: Tags{
|
|
TagWork: {"Symphony No. 5"},
|
|
TagMusicBrainzWorkID: {"abc-123"},
|
|
}}
|
|
Expect(mf.Works()).To(Equal([]Work{
|
|
{Name: "Symphony No. 5", MbzWorkID: "abc-123"},
|
|
}))
|
|
})
|
|
|
|
It("leaves MbzWorkID empty when no id is present", func() {
|
|
mf := MediaFile{Tags: Tags{TagWork: {"Symphony No. 5"}}}
|
|
Expect(mf.Works()).To(Equal([]Work{
|
|
{Name: "Symphony No. 5"},
|
|
}))
|
|
})
|
|
|
|
It("pairs by index and ignores extra ids", func() {
|
|
mf := MediaFile{Tags: Tags{
|
|
TagWork: {"Work A", "Work B"},
|
|
TagMusicBrainzWorkID: {"id-a"},
|
|
}}
|
|
Expect(mf.Works()).To(Equal([]Work{
|
|
{Name: "Work A", MbzWorkID: "id-a"},
|
|
{Name: "Work B"},
|
|
}))
|
|
})
|
|
})
|
|
|
|
var _ = Describe("MediaFile.Movements", func() {
|
|
It("returns nil when there are no movement tags", func() {
|
|
mf := MediaFile{}
|
|
Expect(mf.Movements()).To(BeNil())
|
|
})
|
|
|
|
It("builds a movement with name, number and count", func() {
|
|
mf := MediaFile{Tags: Tags{
|
|
TagMovementName: {"I. Allegro"},
|
|
TagMovementNumber: {"1"},
|
|
TagMovementTotal: {"4"},
|
|
}}
|
|
Expect(mf.Movements()).To(Equal([]Movement{
|
|
{Name: "I. Allegro", Number: 1, Count: 4},
|
|
}))
|
|
})
|
|
|
|
It("non-numeric number/count yields 0", func() {
|
|
mf := MediaFile{Tags: Tags{
|
|
TagMovementName: {"I. Allegro"},
|
|
TagMovementNumber: {"not-a-number"},
|
|
}}
|
|
Expect(mf.Movements()).To(Equal([]Movement{
|
|
{Name: "I. Allegro"},
|
|
}))
|
|
})
|
|
})
|
|
|
|
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{}
|
|
}
|