From 213ceeca7893d3c85eb688e6e99c09dd6cd7e453 Mon Sep 17 00:00:00 2001 From: Deluan Date: Mon, 19 Dec 2022 17:07:29 -0500 Subject: [PATCH] Resize if requested --- core/artwork.go | 87 ++++++++++++++++++++++++++++++++- core/artwork_internal_test.go | 92 ++++++++++++++++++++++++++--------- model/artwork_id.go | 12 ++--- model/artwork_id_test.go | 4 +- model/playlist.go | 2 +- scanner/walk_dir_tree_test.go | 2 +- 6 files changed, 163 insertions(+), 36 deletions(-) diff --git a/core/artwork.go b/core/artwork.go index c8899ccc..cb4c98b8 100644 --- a/core/artwork.go +++ b/core/artwork.go @@ -4,12 +4,19 @@ import ( "bytes" "context" "errors" + "fmt" + "image" _ "image/gif" - _ "image/png" + "image/jpeg" + "image/png" "io" "os" + "path/filepath" + "strings" "github.com/dhowden/tag" + "github.com/disintegration/imaging" + "github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/consts" "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" @@ -34,11 +41,17 @@ func (a *artwork) Get(ctx context.Context, id string, size int) (io.ReadCloser, return r, err } -func (a *artwork) get(ctx context.Context, id string, size int) (io.ReadCloser, string, error) { +func (a *artwork) get(ctx context.Context, id string, size int) (reader io.ReadCloser, path string, err error) { artId, err := model.ParseArtworkID(id) if err != nil { return nil, "", errors.New("invalid ID") } + + // If requested a resized + if size > 0 { + return a.resizedFromOriginal(ctx, id, size) + } + id = artId.ID al, err := a.ds.Album(ctx).Get(id) if errors.Is(err, model.ErrNotFound) { @@ -48,13 +61,34 @@ func (a *artwork) get(ctx context.Context, id string, size int) (io.ReadCloser, if err != nil { return nil, "", err } + r, path := extractImage(ctx, artId, + fromExternalFile(al.ImageFiles, "cover.png", "cover.jpg", "cover.jpeg", "cover.webp"), + fromExternalFile(al.ImageFiles, "folder.png", "folder.jpg", "folder.jpeg", "folder.webp"), + fromExternalFile(al.ImageFiles, "album.png", "album.jpg", "album.jpeg", "album.webp"), + fromExternalFile(al.ImageFiles, "albumart.png", "albumart.jpg", "albumart.jpeg", "albumart.webp"), + fromExternalFile(al.ImageFiles, "front.png", "front.jpg", "front.jpeg", "front.webp"), fromTag(al.EmbedArtPath), fromPlaceholder(), ) return r, path, nil } +func (a *artwork) resizedFromOriginal(ctx context.Context, id string, size int) (io.ReadCloser, string, error) { + r, path, err := a.get(ctx, id, 0) + if err != nil || r == nil { + return nil, "", err + } + defer r.Close() + usePng := strings.ToLower(filepath.Ext(path)) == ".png" + r, err = resizeImage(r, size, usePng) + if err != nil { + r, path := fromPlaceholder()() + return r, path, err + } + return r, fmt.Sprintf("%s@%d", path, size), nil +} + func extractImage(ctx context.Context, artId model.ArtworkID, extractFuncs ...func() (io.ReadCloser, string)) (io.ReadCloser, string) { for _, f := range extractFuncs { r, path := f() @@ -67,8 +101,33 @@ func extractImage(ctx context.Context, artId model.ArtworkID, extractFuncs ...fu return nil, "" } +// This seems unoptimized, but we need to make sure the priority order of validNames +// is preserved (i.e. png is better than jpg) +func fromExternalFile(files string, validNames ...string) func() (io.ReadCloser, string) { + return func() (io.ReadCloser, string) { + fileList := filepath.SplitList(files) + for _, validName := range validNames { + for _, file := range fileList { + _, name := filepath.Split(file) + if !strings.EqualFold(validName, name) { + continue + } + f, err := os.Open(file) + if err != nil { + continue + } + return f, file + } + } + return nil, "" + } +} + func fromTag(path string) func() (io.ReadCloser, string) { return func() (io.ReadCloser, string) { + if path == "" { + return nil, "" + } f, err := os.Open(path) if err != nil { return nil, "" @@ -94,3 +153,27 @@ func fromPlaceholder() func() (io.ReadCloser, string) { return r, consts.PlaceholderAlbumArt } } + +func resizeImage(reader io.Reader, size int, usePng bool) (io.ReadCloser, error) { + img, _, err := image.Decode(reader) + if err != nil { + return nil, err + } + + // Preserve the aspect ratio of the image. + var m *image.NRGBA + bounds := img.Bounds() + if bounds.Max.X > bounds.Max.Y { + m = imaging.Resize(img, size, 0, imaging.Lanczos) + } else { + m = imaging.Resize(img, 0, size, imaging.Lanczos) + } + + buf := new(bytes.Buffer) + if usePng { + err = png.Encode(buf, m) + } else { + err = jpeg.Encode(buf, m, &jpeg.Options{Quality: conf.Server.CoverJpegQuality}) + } + return io.NopCloser(buf), err +} diff --git a/core/artwork_internal_test.go b/core/artwork_internal_test.go index 64b9f05a..1f8e4eb1 100644 --- a/core/artwork_internal_test.go +++ b/core/artwork_internal_test.go @@ -2,6 +2,7 @@ package core import ( "context" + "image" "github.com/navidrome/navidrome/consts" "github.com/navidrome/navidrome/log" @@ -11,50 +12,93 @@ import ( . "github.com/onsi/gomega" ) -var _ = FDescribe("Artwork", func() { +var _ = Describe("Artwork", func() { var aw *artwork var ds model.DataStore ctx := log.NewContext(context.TODO()) - var alOnlyEmbed, alEmbedNotFound model.Album + var alOnlyEmbed, alEmbedNotFound, alOnlyExternal, alExternalNotFound, alAllOptions model.Album BeforeEach(func() { ds = &tests.MockDataStore{MockedTranscoding: &tests.MockTranscodingRepo{}} alOnlyEmbed = model.Album{ID: "222", Name: "Only embed", EmbedArtPath: "tests/fixtures/test.mp3"} alEmbedNotFound = model.Album{ID: "333", Name: "Embed not found", EmbedArtPath: "tests/fixtures/NON_EXISTENT.mp3"} - // {ID: "666", Name: "All options", EmbedArtPath: "tests/fixtures/test.mp3", - // ImageFiles: "tests/fixtures/cover.jpg:tests/fixtures/front.png"}, - //}) + alOnlyExternal = model.Album{ID: "444", Name: "Only external", ImageFiles: "tests/fixtures/front.png"} + alExternalNotFound = model.Album{ID: "555", Name: "External not found", ImageFiles: "tests/fixtures/NON_EXISTENT.png"} + alAllOptions = model.Album{ID: "666", Name: "All options", EmbedArtPath: "tests/fixtures/test.mp3", + ImageFiles: "tests/fixtures/cover.jpg:tests/fixtures/front.png", + } aw = NewArtwork(ds).(*artwork) }) - When("cover art is not found", func() { - BeforeEach(func() { - ds.Album(ctx).(*tests.MockAlbumRepo).SetData(model.Albums{ - alOnlyEmbed, + Context("Albums", func() { + Context("ID not found", func() { + BeforeEach(func() { + ds.Album(ctx).(*tests.MockAlbumRepo).SetData(model.Albums{ + alOnlyEmbed, + }) + }) + It("returns placeholder if album is not in the DB", func() { + _, path, err := aw.get(context.Background(), "al-999-0", 0) + Expect(err).ToNot(HaveOccurred()) + Expect(path).To(Equal(consts.PlaceholderAlbumArt)) }) }) - It("returns placeholder if album is not in the DB", func() { - _, path, err := aw.get(context.Background(), "al-999-0", 0) - Expect(err).ToNot(HaveOccurred()) - Expect(path).To(Equal(consts.PlaceholderAlbumArt)) + Context("Embed images", func() { + BeforeEach(func() { + ds.Album(ctx).(*tests.MockAlbumRepo).SetData(model.Albums{ + alOnlyEmbed, + alEmbedNotFound, + }) + }) + It("returns embed cover", func() { + _, path, err := aw.get(context.Background(), alOnlyEmbed.CoverArtID().String(), 0) + Expect(err).ToNot(HaveOccurred()) + Expect(path).To(Equal("tests/fixtures/test.mp3")) + }) + It("returns placeholder if embed path is not available", func() { + _, path, err := aw.get(context.Background(), alEmbedNotFound.CoverArtID().String(), 0) + Expect(err).ToNot(HaveOccurred()) + Expect(path).To(Equal(consts.PlaceholderAlbumArt)) + }) + }) + Context("External images", func() { + BeforeEach(func() { + ds.Album(ctx).(*tests.MockAlbumRepo).SetData(model.Albums{ + alOnlyExternal, + alAllOptions, + }) + }) + It("returns external cover", func() { + _, path, err := aw.get(context.Background(), alOnlyExternal.CoverArtID().String(), 0) + Expect(err).ToNot(HaveOccurred()) + Expect(path).To(Equal("tests/fixtures/front.png")) + }) + It("returns the first image if more than one is available", func() { + _, path, err := aw.get(context.Background(), alAllOptions.CoverArtID().String(), 0) + Expect(err).ToNot(HaveOccurred()) + Expect(path).To(Equal("tests/fixtures/cover.jpg")) + }) + It("returns placeholder if external file is not available", func() { + _, path, err := aw.get(context.Background(), alExternalNotFound.CoverArtID().String(), 0) + Expect(err).ToNot(HaveOccurred()) + Expect(path).To(Equal(consts.PlaceholderAlbumArt)) + }) }) }) - When("album has only embed images", func() { + Context("Resize", func() { BeforeEach(func() { ds.Album(ctx).(*tests.MockAlbumRepo).SetData(model.Albums{ - alOnlyEmbed, - alEmbedNotFound, + alOnlyExternal, }) }) - It("returns embed cover", func() { - _, path, err := aw.get(context.Background(), alOnlyEmbed.CoverArtID().String(), 0) + It("returns external cover resized", func() { + r, path, err := aw.get(context.Background(), alOnlyExternal.CoverArtID().String(), 300) Expect(err).ToNot(HaveOccurred()) - Expect(path).To(Equal("tests/fixtures/test.mp3")) - }) - It("returns placeholder if embed path is not available", func() { - _, path, err := aw.get(context.Background(), alEmbedNotFound.CoverArtID().String(), 0) - Expect(err).ToNot(HaveOccurred()) - Expect(path).To(Equal(consts.PlaceholderAlbumArt)) + Expect(path).To(Equal("tests/fixtures/front.png@300")) + img, _, err := image.Decode(r) + Expect(err).To(BeNil()) + Expect(img.Bounds().Size().X).To(Equal(300)) + Expect(img.Bounds().Size().Y).To(Equal(300)) }) }) }) diff --git a/model/artwork_id.go b/model/artwork_id.go index 9a750f25..c179d11e 100644 --- a/model/artwork_id.go +++ b/model/artwork_id.go @@ -18,15 +18,15 @@ var ( type ArtworkID struct { Kind Kind ID string - LastAccess time.Time + LastUpdate time.Time } func (id ArtworkID) String() string { s := fmt.Sprintf("%s-%s", id.Kind.prefix, id.ID) - if id.LastAccess.Unix() < 0 { + if id.LastUpdate.Unix() < 0 { return s + "-0" } - return fmt.Sprintf("%s-%x", s, id.LastAccess.Unix()) + return fmt.Sprintf("%s-%x", s, id.LastUpdate.Unix()) } func ParseArtworkID(id string) (ArtworkID, error) { @@ -44,7 +44,7 @@ func ParseArtworkID(id string) (ArtworkID, error) { return ArtworkID{ Kind: Kind{parts[0]}, ID: parts[1], - LastAccess: time.Unix(lastUpdate, 0), + LastUpdate: time.Unix(lastUpdate, 0), }, nil } @@ -52,7 +52,7 @@ func artworkIDFromAlbum(al Album) ArtworkID { return ArtworkID{ Kind: KindAlbumArtwork, ID: al.ID, - LastAccess: al.UpdatedAt, + LastUpdate: al.UpdatedAt, } } @@ -60,6 +60,6 @@ func artworkIDFromMediaFile(mf MediaFile) ArtworkID { return ArtworkID{ Kind: KindMediaFileArtwork, ID: mf.ID, - LastAccess: mf.UpdatedAt, + LastUpdate: mf.UpdatedAt, } } diff --git a/model/artwork_id_test.go b/model/artwork_id_test.go index c6d8645b..3d36b515 100644 --- a/model/artwork_id_test.go +++ b/model/artwork_id_test.go @@ -14,14 +14,14 @@ var _ = Describe("ParseArtworkID()", func() { Expect(err).ToNot(HaveOccurred()) Expect(id.Kind).To(Equal(model.KindAlbumArtwork)) Expect(id.ID).To(Equal("1234")) - Expect(id.LastAccess).To(Equal(time.Unix(255, 0))) + Expect(id.LastUpdate).To(Equal(time.Unix(255, 0))) }) It("parses media file artwork ids", func() { id, err := model.ParseArtworkID("mf-a6f8d2b1-ffff") Expect(err).ToNot(HaveOccurred()) Expect(id.Kind).To(Equal(model.KindMediaFileArtwork)) Expect(id.ID).To(Equal("a6f8d2b1")) - Expect(id.LastAccess).To(Equal(time.Unix(65535, 0))) + Expect(id.LastUpdate).To(Equal(time.Unix(65535, 0))) }) It("fails to parse malformed ids", func() { _, err := model.ParseArtworkID("a6f8d2b1") diff --git a/model/playlist.go b/model/playlist.go index 1b21700d..cef45c47 100644 --- a/model/playlist.go +++ b/model/playlist.go @@ -46,7 +46,7 @@ func (pls Playlist) MediaFiles() MediaFiles { func (pls *Playlist) RemoveTracks(idxToRemove []int) { var newTracks PlaylistTracks for i, t := range pls.Tracks { - if slices.Index(idxToRemove, i) >= 0 { + if slices.Contains(idxToRemove, i) { continue } newTracks = append(newTracks, t) diff --git a/scanner/walk_dir_tree_test.go b/scanner/walk_dir_tree_test.go index 0cd71f92..170421ab 100644 --- a/scanner/walk_dir_tree_test.go +++ b/scanner/walk_dir_tree_test.go @@ -34,7 +34,7 @@ var _ = Describe("walk_dir_tree", func() { Eventually(errC).Should(Receive(nil)) Expect(collected[baseDir]).To(MatchFields(IgnoreExtras, Fields{ - "Images": ConsistOf("cover.jpg"), + "Images": ConsistOf("cover.jpg", "front.png"), "HasPlaylist": BeFalse(), "AudioFilesCount": BeNumerically("==", 5), }))