Use new FileCache in cover service
This commit is contained in:
@@ -1,13 +1,10 @@
|
|||||||
package core
|
package core
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"io/ioutil"
|
|
||||||
"os"
|
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/deluan/navidrome/log"
|
"github.com/deluan/navidrome/log"
|
||||||
"github.com/deluan/navidrome/tests"
|
"github.com/deluan/navidrome/tests"
|
||||||
"github.com/djherbis/fscache"
|
|
||||||
. "github.com/onsi/ginkgo"
|
. "github.com/onsi/ginkgo"
|
||||||
. "github.com/onsi/gomega"
|
. "github.com/onsi/gomega"
|
||||||
)
|
)
|
||||||
@@ -18,18 +15,3 @@ func TestEngine(t *testing.T) {
|
|||||||
RegisterFailHandler(Fail)
|
RegisterFailHandler(Fail)
|
||||||
RunSpecs(t, "Core Suite")
|
RunSpecs(t, "Core Suite")
|
||||||
}
|
}
|
||||||
|
|
||||||
var testCache fscache.Cache
|
|
||||||
var testCacheDir string
|
|
||||||
|
|
||||||
var _ = Describe("Core Suite Setup", func() {
|
|
||||||
BeforeSuite(func() {
|
|
||||||
testCacheDir, _ = ioutil.TempDir("", "core_test_cache")
|
|
||||||
fs, _ := fscache.NewFs(testCacheDir, 0755)
|
|
||||||
testCache, _ = fscache.NewCache(fs, nil)
|
|
||||||
})
|
|
||||||
|
|
||||||
AfterSuite(func() {
|
|
||||||
os.RemoveAll(testCacheDir)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|||||||
+31
-41
@@ -22,22 +22,30 @@ import (
|
|||||||
"github.com/deluan/navidrome/utils"
|
"github.com/deluan/navidrome/utils"
|
||||||
"github.com/dhowden/tag"
|
"github.com/dhowden/tag"
|
||||||
"github.com/disintegration/imaging"
|
"github.com/disintegration/imaging"
|
||||||
"github.com/djherbis/fscache"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
type Cover interface {
|
type Cover interface {
|
||||||
Get(ctx context.Context, id string, size int, out io.Writer) error
|
Get(ctx context.Context, id string, size int, out io.Writer) error
|
||||||
}
|
}
|
||||||
|
|
||||||
type ImageCache fscache.Cache
|
func NewCover(ds model.DataStore, cache *FileCache) Cover {
|
||||||
|
|
||||||
func NewCover(ds model.DataStore, cache ImageCache) Cover {
|
|
||||||
return &cover{ds: ds, cache: cache}
|
return &cover{ds: ds, cache: cache}
|
||||||
}
|
}
|
||||||
|
|
||||||
type cover struct {
|
type cover struct {
|
||||||
ds model.DataStore
|
ds model.DataStore
|
||||||
cache fscache.Cache
|
cache *FileCache
|
||||||
|
}
|
||||||
|
|
||||||
|
type coverInfo struct {
|
||||||
|
c *cover
|
||||||
|
path string
|
||||||
|
size int
|
||||||
|
lastUpdate time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ci *coverInfo) String() string {
|
||||||
|
return fmt.Sprintf("%s.%d.%s.%d", ci.path, ci.size, ci.lastUpdate.Format(time.RFC3339Nano), conf.Server.CoverJpegQuality)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *cover) Get(ctx context.Context, id string, size int, out io.Writer) error {
|
func (c *cover) Get(ctx context.Context, id string, size int, out io.Writer) error {
|
||||||
@@ -46,41 +54,18 @@ func (c *cover) Get(ctx context.Context, id string, size int, out io.Writer) err
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
// If cache is disabled, just read the coverart directly from file
|
info := &coverInfo{
|
||||||
if c.cache == nil {
|
c: c,
|
||||||
log.Trace(ctx, "Retrieving cover art from file", "path", path, "size", size, err)
|
path: path,
|
||||||
reader, err := c.getCover(ctx, path, size)
|
size: size,
|
||||||
if err != nil {
|
lastUpdate: lastUpdate,
|
||||||
log.Error(ctx, "Error loading cover art", "path", path, "size", size, err)
|
|
||||||
} else {
|
|
||||||
_, err = io.Copy(out, reader)
|
|
||||||
}
|
|
||||||
return err
|
|
||||||
}
|
}
|
||||||
|
|
||||||
cacheKey := imageCacheKey(path, size, lastUpdate)
|
r, err := c.cache.Get(ctx, info)
|
||||||
r, w, err := c.cache.Get(cacheKey)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Error(ctx, "Error reading from image cache", "path", path, "size", size, err)
|
log.Error(ctx, "Error accessing image cache", "path", path, "size", size, err)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
defer r.Close()
|
|
||||||
if w != nil {
|
|
||||||
log.Trace(ctx, "Image cache miss", "path", path, "size", size, "lastUpdate", lastUpdate)
|
|
||||||
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
|
|
||||||
}
|
|
||||||
if _, err := io.Copy(w, reader); err != nil {
|
|
||||||
log.Error(ctx, "Error saving covert art to cache", "path", path, "size", size, err)
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
} else {
|
|
||||||
log.Trace(ctx, "Loading image from cache", "path", path, "size", size, "lastUpdate", lastUpdate)
|
|
||||||
}
|
|
||||||
|
|
||||||
_, err = io.Copy(out, r)
|
_, err = io.Copy(out, r)
|
||||||
return err
|
return err
|
||||||
@@ -118,10 +103,6 @@ func (c *cover) getCoverPath(ctx context.Context, id string) (path string, lastU
|
|||||||
return c.getCoverPath(ctx, "al-"+mf.AlbumID)
|
return c.getCoverPath(ctx, "al-"+mf.AlbumID)
|
||||||
}
|
}
|
||||||
|
|
||||||
func imageCacheKey(path string, size int, lastUpdate time.Time) string {
|
|
||||||
return fmt.Sprintf("%s.%d.%s.%d", path, size, lastUpdate.Format(time.RFC3339Nano), conf.Server.CoverJpegQuality)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *cover) getCover(ctx context.Context, path string, size int) (reader io.Reader, err error) {
|
func (c *cover) getCover(ctx context.Context, path string, size int) (reader io.Reader, err error) {
|
||||||
defer func() {
|
defer func() {
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -201,6 +182,15 @@ func readFromFile(path string) ([]byte, error) {
|
|||||||
return buf.Bytes(), nil
|
return buf.Bytes(), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewImageCache() (ImageCache, error) {
|
func NewImageCache() (*FileCache, error) {
|
||||||
return newFSCache("Image", conf.Server.ImageCacheSize, consts.ImageCacheDir, consts.DefaultImageCacheMaxItems)
|
return NewFileCache("Image", conf.Server.ImageCacheSize, consts.ImageCacheDir, consts.DefaultImageCacheMaxItems,
|
||||||
|
func(ctx context.Context, arg fmt.Stringer) (io.Reader, error) {
|
||||||
|
info := arg.(*coverInfo)
|
||||||
|
reader, err := info.c.getCover(ctx, info.path, info.size)
|
||||||
|
if err != nil {
|
||||||
|
log.Error(ctx, "Error loading cover art", "path", info.path, "size", info.size, err)
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return reader, nil
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
+11
-16
@@ -4,7 +4,10 @@ import (
|
|||||||
"bytes"
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
"image"
|
"image"
|
||||||
|
"io/ioutil"
|
||||||
|
"os"
|
||||||
|
|
||||||
|
"github.com/deluan/navidrome/conf"
|
||||||
"github.com/deluan/navidrome/log"
|
"github.com/deluan/navidrome/log"
|
||||||
"github.com/deluan/navidrome/model"
|
"github.com/deluan/navidrome/model"
|
||||||
"github.com/deluan/navidrome/persistence"
|
"github.com/deluan/navidrome/persistence"
|
||||||
@@ -25,7 +28,14 @@ var _ = Describe("Cover", func() {
|
|||||||
|
|
||||||
Context("Cache is configured", func() {
|
Context("Cache is configured", func() {
|
||||||
BeforeEach(func() {
|
BeforeEach(func() {
|
||||||
cover = NewCover(ds, testCache)
|
conf.Server.DataFolder, _ = ioutil.TempDir("", "file_caches")
|
||||||
|
conf.Server.ImageCacheSize = "100MB"
|
||||||
|
cache, _ := NewImageCache()
|
||||||
|
cover = NewCover(ds, cache)
|
||||||
|
})
|
||||||
|
|
||||||
|
AfterEach(func() {
|
||||||
|
os.RemoveAll(conf.Server.DataFolder)
|
||||||
})
|
})
|
||||||
|
|
||||||
It("retrieves the external cover art for an album", func() {
|
It("retrieves the external cover art for an album", func() {
|
||||||
@@ -118,19 +128,4 @@ var _ = Describe("Cover", func() {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
Context("Cache is NOT configured", func() {
|
|
||||||
BeforeEach(func() {
|
|
||||||
cover = NewCover(ds, nil)
|
|
||||||
})
|
|
||||||
|
|
||||||
It("retrieves the original cover art from an album", func() {
|
|
||||||
buf := new(bytes.Buffer)
|
|
||||||
|
|
||||||
Expect(cover.Get(ctx, "al-222", 0, buf)).To(BeNil())
|
|
||||||
|
|
||||||
_, format, err := image.Decode(bytes.NewReader(buf.Bytes()))
|
|
||||||
Expect(err).To(BeNil())
|
|
||||||
Expect(format).To(Equal("jpeg"))
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
})
|
||||||
|
|||||||
+5
-4
@@ -123,14 +123,15 @@ func copyAndClose(ctx context.Context, w io.WriteCloser, r io.Reader) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func newFSCache(name, cacheSize, cacheFolder string, maxItems int) (fscache.Cache, error) {
|
func newFSCache(name, cacheSize, cacheFolder string, maxItems int) (fscache.Cache, error) {
|
||||||
if cacheSize == "0" {
|
|
||||||
log.Warn(fmt.Sprintf("%s cache disabled", name))
|
|
||||||
return nil, nil
|
|
||||||
}
|
|
||||||
size, err := humanize.ParseBytes(cacheSize)
|
size, err := humanize.ParseBytes(cacheSize)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
log.Error("Invalid cache size. Using default size", "cache", name, "size", cacheSize, "defaultSize", consts.DefaultCacheSize)
|
||||||
size = consts.DefaultCacheSize
|
size = consts.DefaultCacheSize
|
||||||
}
|
}
|
||||||
|
if size == 0 {
|
||||||
|
log.Warn(fmt.Sprintf("%s cache disabled", name))
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
lru := fscache.NewLRUHaunter(maxItems, int64(size), consts.DefaultCacheCleanUpInterval)
|
lru := fscache.NewLRUHaunter(maxItems, int64(size), consts.DefaultCacheCleanUpInterval)
|
||||||
h := fscache.NewLRUHaunterStrategy(lru)
|
h := fscache.NewLRUHaunterStrategy(lru)
|
||||||
cacheFolder = filepath.Join(conf.Server.DataFolder, cacheFolder)
|
cacheFolder = filepath.Join(conf.Server.DataFolder, cacheFolder)
|
||||||
|
|||||||
@@ -1,9 +1,13 @@
|
|||||||
package core
|
package core
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
"io/ioutil"
|
"io/ioutil"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
|
||||||
"github.com/deluan/navidrome/conf"
|
"github.com/deluan/navidrome/conf"
|
||||||
. "github.com/onsi/ginkgo"
|
. "github.com/onsi/ginkgo"
|
||||||
@@ -20,21 +24,21 @@ var _ = Describe("File Caches", func() {
|
|||||||
|
|
||||||
Describe("NewFileCache", func() {
|
Describe("NewFileCache", func() {
|
||||||
It("creates the cache folder", func() {
|
It("creates the cache folder", func() {
|
||||||
Expect(NewFileCache("test", "1k", "test", 10, nil)).ToNot(BeNil())
|
Expect(NewFileCache("test", "1k", "test", 0, nil)).ToNot(BeNil())
|
||||||
|
|
||||||
_, err := os.Stat(filepath.Join(conf.Server.DataFolder, "test"))
|
_, err := os.Stat(filepath.Join(conf.Server.DataFolder, "test"))
|
||||||
Expect(os.IsNotExist(err)).To(BeFalse())
|
Expect(os.IsNotExist(err)).To(BeFalse())
|
||||||
})
|
})
|
||||||
|
|
||||||
It("creates the cache folder with invalid size", func() {
|
It("creates the cache folder with invalid size", func() {
|
||||||
fc, err := NewFileCache("test", "abc", "test", 10, nil)
|
fc, err := NewFileCache("test", "abc", "test", 0, nil)
|
||||||
Expect(err).To(BeNil())
|
Expect(err).To(BeNil())
|
||||||
Expect(fc.cache).ToNot(BeNil())
|
Expect(fc.cache).ToNot(BeNil())
|
||||||
Expect(fc.disabled).To(BeFalse())
|
Expect(fc.disabled).To(BeFalse())
|
||||||
})
|
})
|
||||||
|
|
||||||
It("returns empty if cache size is '0'", func() {
|
It("returns empty if cache size is '0'", func() {
|
||||||
fc, err := NewFileCache("test", "0", "test", 10, nil)
|
fc, err := NewFileCache("test", "0", "test", 0, nil)
|
||||||
Expect(err).To(BeNil())
|
Expect(err).To(BeNil())
|
||||||
Expect(fc.cache).To(BeNil())
|
Expect(fc.cache).To(BeNil())
|
||||||
Expect(fc.disabled).To(BeTrue())
|
Expect(fc.disabled).To(BeTrue())
|
||||||
@@ -42,6 +46,46 @@ var _ = Describe("File Caches", func() {
|
|||||||
})
|
})
|
||||||
|
|
||||||
Describe("FileCache", func() {
|
Describe("FileCache", func() {
|
||||||
|
It("caches data if cache is enabled", func() {
|
||||||
|
called := false
|
||||||
|
fc, _ := NewFileCache("test", "1KB", "test", 0, func(ctx context.Context, arg fmt.Stringer) (io.Reader, error) {
|
||||||
|
called = true
|
||||||
|
return strings.NewReader(arg.String()), nil
|
||||||
|
})
|
||||||
|
// First call is a MISS
|
||||||
|
s, err := fc.Get(context.TODO(), &testArg{"test"})
|
||||||
|
Expect(err).To(BeNil())
|
||||||
|
Expect(ioutil.ReadAll(s)).To(Equal([]byte("test")))
|
||||||
|
|
||||||
|
// Second call is a HIT
|
||||||
|
called = false
|
||||||
|
s, err = fc.Get(context.TODO(), &testArg{"test"})
|
||||||
|
Expect(err).To(BeNil())
|
||||||
|
Expect(ioutil.ReadAll(s)).To(Equal([]byte("test")))
|
||||||
|
Expect(called).To(BeFalse())
|
||||||
|
})
|
||||||
|
|
||||||
|
It("does not cache data if cache is disabled", func() {
|
||||||
|
called := false
|
||||||
|
fc, _ := NewFileCache("test", "0", "test", 0, func(ctx context.Context, arg fmt.Stringer) (io.Reader, error) {
|
||||||
|
called = true
|
||||||
|
return strings.NewReader(arg.String()), nil
|
||||||
|
})
|
||||||
|
// First call is a MISS
|
||||||
|
s, err := fc.Get(context.TODO(), &testArg{"test"})
|
||||||
|
Expect(err).To(BeNil())
|
||||||
|
Expect(ioutil.ReadAll(s)).To(Equal([]byte("test")))
|
||||||
|
|
||||||
|
// Second call is also a MISS
|
||||||
|
called = false
|
||||||
|
s, err = fc.Get(context.TODO(), &testArg{"test"})
|
||||||
|
Expect(err).To(BeNil())
|
||||||
|
Expect(ioutil.ReadAll(s)).To(Equal([]byte("test")))
|
||||||
|
Expect(called).To(BeTrue())
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
type testArg struct{ s string }
|
||||||
|
|
||||||
|
func (t *testArg) String() string { return t.s }
|
||||||
|
|||||||
@@ -83,7 +83,7 @@ func (ms *mediaStreamer) NewStream(ctx context.Context, id string, reqFormat str
|
|||||||
}
|
}
|
||||||
r, err := ms.cache.Get(ctx, job)
|
r, err := ms.cache.Get(ctx, job)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Error(ctx, "Error accessing cache", "id", mf.ID, err)
|
log.Error(ctx, "Error accessing transcoding cache", "id", mf.ID, err)
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -21,7 +21,6 @@ var _ = Describe("MediaStreamer", func() {
|
|||||||
var ds model.DataStore
|
var ds model.DataStore
|
||||||
ffmpeg := &fakeFFmpeg{Data: "fake data"}
|
ffmpeg := &fakeFFmpeg{Data: "fake data"}
|
||||||
ctx := log.NewContext(context.TODO())
|
ctx := log.NewContext(context.TODO())
|
||||||
log.SetLevel(log.LevelTrace)
|
|
||||||
|
|
||||||
BeforeEach(func() {
|
BeforeEach(func() {
|
||||||
conf.Server.DataFolder, _ = ioutil.TempDir("", "file_caches")
|
conf.Server.DataFolder, _ = ioutil.TempDir("", "file_caches")
|
||||||
@@ -61,6 +60,8 @@ var _ = Describe("MediaStreamer", func() {
|
|||||||
It("returns a seekable stream if the file is complete in the cache", func() {
|
It("returns a seekable stream if the file is complete in the cache", func() {
|
||||||
s, err := streamer.NewStream(ctx, "123", "mp3", 32)
|
s, err := streamer.NewStream(ctx, "123", "mp3", 32)
|
||||||
Expect(err).To(BeNil())
|
Expect(err).To(BeNil())
|
||||||
|
_, _ = ioutil.ReadAll(s)
|
||||||
|
_ = s.Close()
|
||||||
Eventually(func() bool { return ffmpeg.closed }, "3s").Should(BeTrue())
|
Eventually(func() bool { return ffmpeg.closed }, "3s").Should(BeTrue())
|
||||||
|
|
||||||
s, err = streamer.NewStream(ctx, "123", "mp3", 32)
|
s, err = streamer.NewStream(ctx, "123", "mp3", 32)
|
||||||
|
|||||||
Reference in New Issue
Block a user