Cache cover arts. closes #19

This commit is contained in:
Deluan 2020-04-05 20:31:05 -04:00 committed by Deluan Quintão
parent a1ba5c59b2
commit 05ffb1acad
6 changed files with 170 additions and 58 deletions

View File

@ -20,15 +20,15 @@ const (
UIAssetsLocalPath = "ui/build"
TranscodingCacheDir = "cache/transcoding"
DefaultTranscodingCacheSize = 100 * 1024 * 1024 // 100MB
DefaultTranscodingCacheMaxItems = 0 // Unlimited
DefaultTranscodingCachePurgeInterval = 10 * time.Minute
TranscodingCacheDir = "cache/transcoding"
DefaultTranscodingCacheSize = 100 * 1024 * 1024 // 100MB
DefaultTranscodingCacheMaxItems = 0 // Unlimited
DefaultTranscodingCacheCleanUpInterval = 10 * time.Minute
ImageCacheDir = "cache/images"
DefaultImageCacheSize = 100 * 1024 * 1024 // 100MB
DefaultImageCacheMaxItems = 0 // Unlimited
DefaultImageCachePurgeInterval = 10 * time.Minute
ImageCacheDir = "cache/images"
DefaultImageCacheSize = 100 * 1024 * 1024 // 100MB
DefaultImageCacheMaxItems = 0 // Unlimited
DefaultImageCacheCleanUpInterval = 10 * time.Minute
DevInitialUserName = "admin"
DevInitialName = "Dev Admin"

View File

@ -4,6 +4,7 @@ import (
"bytes"
"context"
"errors"
"fmt"
"image"
_ "image/gif"
"image/jpeg"
@ -40,47 +41,67 @@ type cover struct {
cache fscache.Cache
}
func (c *cover) getCoverPath(ctx context.Context, id string) (string, *time.Time, error) {
var found bool
var err error
if found, err = c.ds.Album(ctx).Exists(id); err != nil {
return "", nil, err
}
if found {
al, err := c.ds.Album(ctx).Get(id)
if err != nil {
return "", nil, err
}
if al.CoverArtId == "" {
return "", nil, model.ErrNotFound
}
id = al.CoverArtId
}
mf, err := c.ds.MediaFile(ctx).Get(id)
if err != nil {
return "", nil, err
}
if mf.HasCoverArt {
return mf.Path, &mf.UpdatedAt, nil
}
return "", nil, model.ErrNotFound
}
func (c *cover) Get(ctx context.Context, id string, size int, out io.Writer) error {
id = strings.TrimPrefix(id, "al-")
path, _, err := c.getCoverPath(ctx, id)
path, lastUpdate, err := c.getCoverPath(ctx, id)
if err != nil && err != model.ErrNotFound {
return err
}
reader, err := c.getCover(ctx, path, size)
cacheKey := imageCacheKey(path, size, lastUpdate)
r, w, err := c.cache.Get(cacheKey)
if err != nil {
return err
log.Error(ctx, "Error reading from image cache", "path", path, "size", size, err)
}
_, err = io.Copy(out, reader)
defer r.Close()
if w != nil {
go func() {
defer w.Close()
reader, err := c.getCover(ctx, path, size)
if err != nil {
log.Error(ctx, "Error loading cover art", "path", path, "size", size, err)
return
}
io.Copy(w, reader)
}()
}
_, err = io.Copy(out, r)
return err
}
func (c *cover) getCoverPath(ctx context.Context, id string) (path string, lastUpdated time.Time, err error) {
var found bool
if found, err = c.ds.Album(ctx).Exists(id); err != nil {
return
}
if found {
var al *model.Album
al, err = c.ds.Album(ctx).Get(id)
if err != nil {
return
}
if al.CoverArtId == "" {
err = model.ErrNotFound
return
}
id = al.CoverArtId
}
var mf *model.MediaFile
mf, err = c.ds.MediaFile(ctx).Get(id)
if err != nil {
return
}
if mf.HasCoverArt {
return mf.Path, mf.UpdatedAt, nil
}
return "", time.Time{}, model.ErrNotFound
}
func imageCacheKey(path string, size int, lastUpdate time.Time) string {
return fmt.Sprintf("%s.%d.%s", path, size, lastUpdate.Format(time.RFC3339Nano))
}
func (c *cover) getCover(ctx context.Context, path string, size int) (reader io.Reader, err error) {
defer func() {
if err != nil {
@ -139,11 +160,11 @@ func NewImageCache() (ImageCache, error) {
if err != nil {
cacheSize = consts.DefaultImageCacheSize
}
lru := fscache.NewLRUHaunter(consts.DefaultImageCacheMaxItems, int64(cacheSize), consts.DefaultImageCachePurgeInterval)
lru := fscache.NewLRUHaunter(consts.DefaultImageCacheMaxItems, int64(cacheSize), consts.DefaultImageCacheCleanUpInterval)
h := fscache.NewLRUHaunterStrategy(lru)
cacheFolder := filepath.Join(conf.Server.DataFolder, consts.ImageCacheDir)
log.Info("Creating image cache", "path", cacheFolder, "maxSize", humanize.Bytes(cacheSize),
"cleanUpInterval", consts.DefaultImageCachePurgeInterval)
"cleanUpInterval", consts.DefaultImageCacheCleanUpInterval)
fs, err := fscache.NewFs(cacheFolder, 0755)
if err != nil {
return nil, err

88
engine/cover_test.go Normal file
View File

@ -0,0 +1,88 @@
package engine
import (
"bytes"
"image"
"github.com/deluan/navidrome/log"
"github.com/deluan/navidrome/model"
"github.com/deluan/navidrome/persistence"
. "github.com/onsi/ginkgo"
. "github.com/onsi/gomega"
)
var _ = Describe("Cover", func() {
var cover Cover
var ds model.DataStore
ctx := log.NewContext(nil)
BeforeEach(func() {
ds = &persistence.MockDataStore{MockedTranscoding: &mockTranscodingRepository{}}
ds.Album(ctx).(*persistence.MockAlbum).SetData(`[{"id": "222", "CoverArtId": "222"}, {"id": "333", "CoverArtId": ""}]`, 1)
ds.MediaFile(ctx).(*persistence.MockMediaFile).SetData(`[{"id": "123", "path": "tests/fixtures/test.mp3", "hasCoverArt": true, "updatedAt":"2020-04-02T21:29:31.6377Z"}]`, 1)
cover = NewCover(ds, testCache)
})
It("retrieves the original cover art from an album", func() {
buf := new(bytes.Buffer)
Expect(cover.Get(ctx, "222", 0, buf)).To(BeNil())
_, format, err := image.Decode(bytes.NewReader(buf.Bytes()))
Expect(err).To(BeNil())
Expect(format).To(Equal("png"))
})
It("accepts albumIds with 'al-' prefix", func() {
buf := new(bytes.Buffer)
Expect(cover.Get(ctx, "al-222", 0, buf)).To(BeNil())
_, _, err := image.Decode(bytes.NewReader(buf.Bytes()))
Expect(err).To(BeNil())
})
It("returns the default cover if album does not have cover", func() {
buf := new(bytes.Buffer)
Expect(cover.Get(ctx, "333", 0, buf)).To(BeNil())
_, format, err := image.Decode(bytes.NewReader(buf.Bytes()))
Expect(err).To(BeNil())
Expect(format).To(Equal("png"))
})
It("returns the default cover if album is not found", func() {
buf := new(bytes.Buffer)
Expect(cover.Get(ctx, "444", 0, buf)).To(BeNil())
_, format, err := image.Decode(bytes.NewReader(buf.Bytes()))
Expect(err).To(BeNil())
Expect(format).To(Equal("png"))
})
It("retrieves the original cover art from a media_file", func() {
buf := new(bytes.Buffer)
Expect(cover.Get(ctx, "123", 0, buf)).To(BeNil())
img, format, err := image.Decode(bytes.NewReader(buf.Bytes()))
Expect(err).To(BeNil())
Expect(format).To(Equal("jpeg"))
Expect(img.Bounds().Size().X).To(Equal(600))
Expect(img.Bounds().Size().Y).To(Equal(600))
})
It("resized cover art as requested", func() {
buf := new(bytes.Buffer)
Expect(cover.Get(ctx, "123", 200, buf)).To(BeNil())
img, _, err := image.Decode(bytes.NewReader(buf.Bytes()))
Expect(err).To(BeNil())
Expect(img.Bounds().Size().X).To(Equal(200))
Expect(img.Bounds().Size().Y).To(Equal(200))
})
})

View File

@ -1,10 +1,13 @@
package engine
import (
"io/ioutil"
"os"
"testing"
"github.com/deluan/navidrome/log"
"github.com/deluan/navidrome/tests"
"github.com/djherbis/fscache"
. "github.com/onsi/ginkgo"
. "github.com/onsi/gomega"
)
@ -15,3 +18,18 @@ func TestEngine(t *testing.T) {
RegisterFailHandler(Fail)
RunSpecs(t, "Engine Suite")
}
var testCache fscache.Cache
var testCacheDir string
var _ = Describe("Engine Suite Setup", func() {
BeforeSuite(func() {
testCacheDir, _ = ioutil.TempDir("", "test_cache")
fs, _ := fscache.NewFs(testCacheDir, 0755)
testCache, _ = fscache.NewCache(fs, nil)
})
AfterSuite(func() {
os.RemoveAll(testCacheDir)
})
})

View File

@ -212,11 +212,11 @@ func NewTranscodingCache() (TranscodingCache, error) {
if err != nil {
cacheSize = consts.DefaultTranscodingCacheSize
}
lru := fscache.NewLRUHaunter(consts.DefaultTranscodingCacheMaxItems, int64(cacheSize), consts.DefaultTranscodingCachePurgeInterval)
lru := fscache.NewLRUHaunter(consts.DefaultTranscodingCacheMaxItems, int64(cacheSize), consts.DefaultTranscodingCacheCleanUpInterval)
h := fscache.NewLRUHaunterStrategy(lru)
cacheFolder := filepath.Join(conf.Server.DataFolder, consts.TranscodingCacheDir)
log.Info("Creating transcoding cache", "path", cacheFolder, "maxSize", humanize.Bytes(cacheSize),
"cleanUpInterval", consts.DefaultTranscodingCachePurgeInterval)
"cleanUpInterval", consts.DefaultTranscodingCacheCleanUpInterval)
fs, err := fscache.NewFs(cacheFolder, 0755)
if err != nil {
return nil, err

View File

@ -3,14 +3,11 @@ package engine
import (
"context"
"io"
"io/ioutil"
"os"
"strings"
"github.com/deluan/navidrome/log"
"github.com/deluan/navidrome/model"
"github.com/deluan/navidrome/persistence"
"github.com/djherbis/fscache"
. "github.com/onsi/ginkgo"
. "github.com/onsi/gomega"
)
@ -18,25 +15,13 @@ import (
var _ = Describe("MediaStreamer", func() {
var streamer MediaStreamer
var ds model.DataStore
var cache fscache.Cache
var tempDir string
ffmpeg := &fakeFFmpeg{Data: "fake data"}
ctx := log.NewContext(nil)
BeforeSuite(func() {
tempDir, _ = ioutil.TempDir("", "stream_tests")
fs, _ := fscache.NewFs(tempDir, 0755)
cache, _ = fscache.NewCache(fs, nil)
})
BeforeEach(func() {
ds = &persistence.MockDataStore{MockedTranscoding: &mockTranscodingRepository{}}
ds.MediaFile(ctx).(*persistence.MockMediaFile).SetData(`[{"id": "123", "path": "tests/fixtures/test.mp3", "suffix": "mp3", "bitRate": 128, "duration": 257.0}]`, 1)
streamer = NewMediaStreamer(ds, ffmpeg, cache)
})
AfterSuite(func() {
os.RemoveAll(tempDir)
streamer = NewMediaStreamer(ds, ffmpeg, testCache)
})
Context("NewStream", func() {