mirror of
https://github.com/navidrome/navidrome.git
synced 2026-11-08 11:18:41 +01:00
Most albums embed the same picture in every track, so clients requesting per-track cover art (mf- ids) download and cache the same image once per song. This change hashes the embedded picture at scan time (XXH3 of the bytes, the same digest the artwork pipeline uses) and stores it on media_file.embed_art_hash. ToAlbum stores the hash of the track it already picks as the album's embedded cover (EmbedArtPath) on album.embed_art_hash. A new MediaFile.HasOwnCoverArt replaces the scattered "HasCoverArt && EnableMediaFileCoverArt" checks: a track whose hash equals its album's now returns the disc/album artwork id from CoverArtID, so the Subsonic, Jellyfin and native responses point every matching song at one cached image. Serving a stale mf- URL follows the same rule, so what is advertised and what is served agree. Tracks with a different embedded picture (compilations, per-track art) keep their own ids. Rows scanned before this change have an empty hash and keep the previous behavior until a full scan; no config option is added. The album hash is hydrated onto tracks with one batched PK lookup per page, only when the page has hashed tracks. BestImageIndex is exported from core/artwork so the scanner hashes the same picture the resolver serves.
762 lines
30 KiB
Go
762 lines
30 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, EmbedArtHash: "picturehash2", 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.EmbedArtHash).To(Equal("picturehash2"))
|
|
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 by order of appearance", func() {
|
|
album := mfs.ToAlbum()
|
|
Expect(album.Tags).To(HaveLen(2))
|
|
Expect(album.Tags).To(HaveKeyWithValue(TagGenre, []string{"Rock", "Punk", "Alternative"}))
|
|
Expect(album.Tags).To(HaveKeyWithValue(TagMood, []string{"Happy", "Chill"}))
|
|
})
|
|
})
|
|
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))
|
|
})
|
|
It("returns its album id if its embedded picture is the album's cover", func() {
|
|
mf := MediaFile{ID: "111", AlbumID: "1", HasCoverArt: true, EmbedArtHash: "samepic", AlbumEmbedArtHash: "samepic"}
|
|
Expect(mf.HasOwnCoverArt()).To(BeFalse())
|
|
id := mf.CoverArtID()
|
|
Expect(id.Kind).To(Equal(KindAlbumArtwork))
|
|
Expect(id.ID).To(Equal(mf.AlbumID))
|
|
})
|
|
It("returns disc art id if its embedded picture is the album's cover and DiscNumber > 0", func() {
|
|
mf := MediaFile{ID: "111", AlbumID: "1", HasCoverArt: true, DiscNumber: 2, EmbedArtHash: "samepic", AlbumEmbedArtHash: "samepic"}
|
|
id := mf.CoverArtID()
|
|
Expect(id.Kind).To(Equal(KindDiscArtwork))
|
|
Expect(id.ID).To(Equal("1:2"))
|
|
})
|
|
It("returns its own id if its embedded picture differs from the album's cover", func() {
|
|
mf := MediaFile{ID: "111", AlbumID: "1", HasCoverArt: true, EmbedArtHash: "ownpic", AlbumEmbedArtHash: "samepic"}
|
|
Expect(mf.HasOwnCoverArt()).To(BeTrue())
|
|
Expect(mf.CoverArtID().Kind).To(Equal(KindMediaFileArtwork))
|
|
})
|
|
It("returns its own id if its picture hash is unknown", func() {
|
|
mf := MediaFile{ID: "111", AlbumID: "1", HasCoverArt: true, AlbumEmbedArtHash: "samepic"}
|
|
Expect(mf.CoverArtID().Kind).To(Equal(KindMediaFileArtwork))
|
|
})
|
|
})
|
|
|
|
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() {
|
|
// Pins the hash formula: an accidental change spuriously re-imports every file on the next scan.
|
|
It("hashes to a stable value", func() {
|
|
Expect(MediaFile{Title: "Song"}.Hash()).To(Equal("05fdf70bb0cbe090"))
|
|
Expect(MediaFile{Title: "Song", BPM: new(120), BitDepth: new(16)}.Hash()).To(Equal("b5daf6ac1009a538"))
|
|
})
|
|
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{}
|
|
}
|