Resize if requested

This commit is contained in:
Deluan 2022-12-19 17:07:29 -05:00 committed by Deluan Quintão
parent 7b87386089
commit 213ceeca78
6 changed files with 163 additions and 36 deletions

View File

@ -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
}

View File

@ -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))
})
})
})

View File

@ -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,
}
}

View File

@ -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")

View File

@ -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)

View File

@ -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),
}))