diff --git a/model/mediafile.go b/model/mediafile.go index 21d8decc..1fc510ea 100644 --- a/model/mediafile.go +++ b/model/mediafile.go @@ -2,9 +2,12 @@ package model import ( "mime" + "os" + "path/filepath" "strings" "time" + "github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/consts" "github.com/navidrome/navidrome/utils" "github.com/navidrome/navidrome/utils/number" @@ -75,6 +78,7 @@ func (mfs MediaFiles) ToAlbum() Album { var songArtistIds []string var mbzAlbumIds []string var comments []string + var firstPath string for _, m := range mfs { // We assume these attributes are all the same for all songs on an album a.ID = m.AlbumID @@ -115,8 +119,11 @@ func (mfs MediaFiles) ToAlbum() Album { m.SortAlbumName, m.SortAlbumArtistName, m.SortArtistName, m.DiscSubtitle) if m.HasCoverArt { - // TODO CoverArtPriority a.CoverArtId = m.ID + a.CoverArtPath = m.Path + } + if firstPath == "" { + firstPath = m.Path } } comments = slices.Compact(comments) @@ -132,6 +139,14 @@ func (mfs MediaFiles) ToAlbum() Album { slices.Sort(songArtistIds) a.AllArtistIDs = strings.Join(slices.Compact(songArtistIds), " ") a.MbzAlbumID = slice.MostFrequent(mbzAlbumIds) + + if a.CoverArtPath == "" || !strings.HasPrefix(conf.Server.CoverArtPriority, "embedded") { + if path := getCoverFromPath(firstPath, a.CoverArtPath); path != "" { + a.CoverArtId = "al-" + a.ID + a.CoverArtPath = path + } + } + return a } @@ -169,6 +184,44 @@ func fixAlbumArtist(a Album, albumArtistIds []string) Album { return a } +// GetCoverFromPath accepts a path to a file, and returns a path to an eligible cover image from the +// file's directory (as configured with CoverArtPriority). If no cover file is found, among +// available choices, or an error occurs, an empty string is returned. If HasEmbeddedCover is true, +// and 'embedded' is matched among eligible choices, GetCoverFromPath will return early with an +// empty path. +// TODO: Move to scanner (or at least out of here) +func getCoverFromPath(mediaPath string, embeddedPath string) string { + n, err := os.Open(filepath.Dir(mediaPath)) + if err != nil { + return "" + } + + defer n.Close() + names, err := n.Readdirnames(-1) + if err != nil { + return "" + } + + for _, p := range strings.Split(conf.Server.CoverArtPriority, ",") { + pat := strings.ToLower(strings.TrimSpace(p)) + if pat == "embedded" { + if embeddedPath != "" { + return "" + } + continue + } + + for _, name := range names { + match, _ := filepath.Match(pat, strings.ToLower(name)) + if match && utils.IsImageFile(name) { + return filepath.Join(filepath.Dir(mediaPath), name) + } + } + } + + return "" +} + type MediaFileRepository interface { CountAll(options ...QueryOptions) (int64, error) Exists(id string) (bool, error) diff --git a/model/mediafile_internal_test.go b/model/mediafile_internal_test.go index 2f902f8e..b86427f6 100644 --- a/model/mediafile_internal_test.go +++ b/model/mediafile_internal_test.go @@ -1,6 +1,10 @@ package model import ( + "os" + "path/filepath" + + "github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/consts" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" @@ -51,3 +55,57 @@ var _ = Describe("fixAlbumArtist", func() { }) }) }) + +var _ = Describe("getCoverFromPath", func() { + var testFolder, testPath, embeddedPath string + BeforeEach(func() { + testFolder, _ = os.MkdirTemp("", "album_persistence_tests") + if err := os.MkdirAll(testFolder, 0777); err != nil { + panic(err) + } + if _, err := os.Create(filepath.Join(testFolder, "Cover.jpeg")); err != nil { + panic(err) + } + if _, err := os.Create(filepath.Join(testFolder, "FRONT.PNG")); err != nil { + panic(err) + } + testPath = filepath.Join(testFolder, "somefile.test") + embeddedPath = filepath.Join(testFolder, "somefile.mp3") + }) + AfterEach(func() { + _ = os.RemoveAll(testFolder) + }) + + It("returns audio file for embedded cover", func() { + conf.Server.CoverArtPriority = "embedded, cover.*, front.*" + Expect(getCoverFromPath(testPath, embeddedPath)).To(Equal("")) + }) + + It("returns external file when no embedded cover exists", func() { + conf.Server.CoverArtPriority = "embedded, cover.*, front.*" + Expect(getCoverFromPath(testPath, "")).To(Equal(filepath.Join(testFolder, "Cover.jpeg"))) + }) + + It("returns embedded cover even if not first choice", func() { + conf.Server.CoverArtPriority = "something.png, embedded, cover.*, front.*" + Expect(getCoverFromPath(testPath, embeddedPath)).To(Equal("")) + }) + + It("returns first correct match case-insensitively", func() { + conf.Server.CoverArtPriority = "embedded, cover.jpg, front.svg, front.png" + Expect(getCoverFromPath(testPath, "")).To(Equal(filepath.Join(testFolder, "FRONT.PNG"))) + }) + + It("returns match for embedded pattern", func() { + conf.Server.CoverArtPriority = "embedded, cover.jp?g, front.png" + Expect(getCoverFromPath(testPath, "")).To(Equal(filepath.Join(testFolder, "Cover.jpeg"))) + }) + + It("returns empty string if no match was found", func() { + conf.Server.CoverArtPriority = "embedded, cover.jpg, front.apng" + Expect(getCoverFromPath(testPath, "")).To(Equal("")) + }) + + // Reset configuration to default. + conf.Server.CoverArtPriority = "embedded, cover.*, front.*" +})