navidrome/core/artwork.go
2022-12-28 15:31:56 -05:00

286 lines
7.4 KiB
Go

package core
import (
"bytes"
"context"
"errors"
"fmt"
"image"
_ "image/gif"
"image/jpeg"
"image/png"
"io"
"os"
"path/filepath"
"reflect"
"runtime"
"strings"
"github.com/dhowden/tag"
"github.com/disintegration/imaging"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/consts"
"github.com/navidrome/navidrome/core/ffmpeg"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/resources"
"github.com/navidrome/navidrome/utils/cache"
"github.com/navidrome/navidrome/utils/singleton"
_ "golang.org/x/image/webp"
)
type Artwork interface {
Get(ctx context.Context, id string, size int) (io.ReadCloser, error)
}
func NewArtwork(ds model.DataStore, cache cache.FileCache, ffmpeg ffmpeg.FFmpeg) Artwork {
return &artwork{ds: ds, cache: cache, ffmpeg: ffmpeg}
}
type artwork struct {
ds model.DataStore
cache cache.FileCache
ffmpeg ffmpeg.FFmpeg
}
func (a *artwork) Get(ctx context.Context, id string, size int) (io.ReadCloser, error) {
var artID model.ArtworkID
var err error
if id != "" {
artID, err = model.ParseArtworkID(id)
if err != nil {
return nil, errors.New("invalid ID")
}
}
key := &artworkKey{a: a, artID: artID, size: size}
r, err := a.cache.Get(ctx, key)
if err != nil {
log.Error(ctx, "Error accessing image cache", "id", id, "size", size, err)
return nil, err
}
return r, err
}
func (a *artwork) get(ctx context.Context, artID model.ArtworkID, size int) (reader io.ReadCloser, path string, err error) {
// If requested a resized image
if size > 0 {
return a.resizedFromOriginal(ctx, artID, size)
}
switch artID.Kind {
case model.KindAlbumArtwork:
reader, path = a.extractAlbumImage(ctx, artID)
case model.KindMediaFileArtwork:
reader, path = a.extractMediaFileImage(ctx, artID)
default:
reader, path = fromPlaceholder()()
}
return reader, path, nil
}
func (a *artwork) extractAlbumImage(ctx context.Context, artID model.ArtworkID) (io.ReadCloser, string) {
al, err := a.ds.Album(ctx).Get(artID.ID)
if errors.Is(err, model.ErrNotFound) {
r, path := fromPlaceholder()()
return r, path
}
if err != nil {
log.Error(ctx, "Could not retrieve album", "id", artID.ID, err)
return nil, ""
}
return 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),
fromFFmpegTag(ctx, a.ffmpeg, al.EmbedArtPath),
fromPlaceholder(),
)
}
func (a *artwork) extractMediaFileImage(ctx context.Context, artID model.ArtworkID) (reader io.ReadCloser, path string) {
mf, err := a.ds.MediaFile(ctx).Get(artID.ID)
if errors.Is(err, model.ErrNotFound) {
r, path := fromPlaceholder()()
return r, path
}
if err != nil {
log.Error(ctx, "Could not retrieve mediafile", "id", artID.ID, err)
return nil, ""
}
return extractImage(ctx, artID,
fromTag(mf.Path),
fromFFmpegTag(ctx, a.ffmpeg, mf.Path),
a.fromAlbum(ctx, mf.AlbumCoverArtID()),
)
}
func (a *artwork) resizedFromOriginal(ctx context.Context, artID model.ArtworkID, size int) (io.ReadCloser, string, error) {
r, path, err := a.get(ctx, artID, 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 {
log.Warn(ctx, "Could not resize image. Using placeholder", "artID", artID, "size", size, err)
r, path := fromPlaceholder()()
return r, path, nil
}
return r, fmt.Sprintf("%s@%d", path, size), nil
}
type fromFunc func() (io.ReadCloser, string)
func (f fromFunc) String() string {
name := runtime.FuncForPC(reflect.ValueOf(f).Pointer()).Name()
name = strings.TrimPrefix(name, "github.com/navidrome/navidrome/core.")
name = strings.TrimSuffix(name, ".func1")
return name
}
func extractImage(ctx context.Context, artID model.ArtworkID, extractFuncs ...fromFunc) (io.ReadCloser, string) {
for _, f := range extractFuncs {
r, path := f()
if r != nil {
log.Trace(ctx, "Found artwork", "artID", artID, "path", path, "origin", f)
return r, path
}
}
log.Error(ctx, "extractImage should never reach this point!", "artID", artID, "path")
return nil, ""
}
func (a *artwork) fromAlbum(ctx context.Context, id model.ArtworkID) fromFunc {
return func() (io.ReadCloser, string) {
r, path, err := a.get(ctx, id, 0)
if err != nil {
return nil, ""
}
return r, path
}
}
// This is a bit 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) fromFunc {
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) fromFunc {
return func() (io.ReadCloser, string) {
if path == "" {
return nil, ""
}
f, err := os.Open(path)
if err != nil {
return nil, ""
}
defer f.Close()
m, err := tag.ReadFrom(f)
if err != nil {
return nil, ""
}
picture := m.Picture()
if picture == nil {
return nil, ""
}
return io.NopCloser(bytes.NewReader(picture.Data)), path
}
}
func fromFFmpegTag(ctx context.Context, ffmpeg ffmpeg.FFmpeg, path string) fromFunc {
return func() (io.ReadCloser, string) {
if path == "" {
return nil, ""
}
r, err := ffmpeg.ExtractImage(ctx, path)
if err != nil {
return nil, ""
}
return r, path
}
}
func fromPlaceholder() fromFunc {
return func() (io.ReadCloser, string) {
r, _ := resources.FS().Open(consts.PlaceholderAlbumArt)
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
}
type ArtworkCache struct {
cache.FileCache
}
type artworkKey struct {
a *artwork
artID model.ArtworkID
size int
}
func (k *artworkKey) Key() string {
return fmt.Sprintf("%s.%d.%d.%d", k.artID.ID, k.size, k.artID.LastUpdate.UnixNano(), conf.Server.CoverJpegQuality)
}
func GetImageCache() cache.FileCache {
return singleton.GetInstance(func() *ArtworkCache {
return &ArtworkCache{
FileCache: cache.NewFileCache("Image", conf.Server.ImageCacheSize, consts.ImageCacheDir, consts.DefaultImageCacheMaxItems,
func(ctx context.Context, arg cache.Item) (io.Reader, error) {
info := arg.(*artworkKey)
r, _, err := info.a.get(ctx, info.artID, info.size)
return r, err
}),
}
})
}