From 213ceeca7893d3c85eb688e6e99c09dd6cd7e453 Mon Sep 17 00:00:00 2001
From: Deluan <deluan@navidrome.org>
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 c8899cccc..cb4c98b8e 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 64b9f05af..1f8e4eb12 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 9a750f255..c179d11e7 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 c6d8645b0..3d36b515e 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 1b21700d1..cef45c473 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 0cd71f925..170421ab8 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),
 			}))