Cache cover arts. closes #19
This commit is contained in:
+8
-8
@@ -20,15 +20,15 @@ const (
|
|||||||
|
|
||||||
UIAssetsLocalPath = "ui/build"
|
UIAssetsLocalPath = "ui/build"
|
||||||
|
|
||||||
TranscodingCacheDir = "cache/transcoding"
|
TranscodingCacheDir = "cache/transcoding"
|
||||||
DefaultTranscodingCacheSize = 100 * 1024 * 1024 // 100MB
|
DefaultTranscodingCacheSize = 100 * 1024 * 1024 // 100MB
|
||||||
DefaultTranscodingCacheMaxItems = 0 // Unlimited
|
DefaultTranscodingCacheMaxItems = 0 // Unlimited
|
||||||
DefaultTranscodingCachePurgeInterval = 10 * time.Minute
|
DefaultTranscodingCacheCleanUpInterval = 10 * time.Minute
|
||||||
|
|
||||||
ImageCacheDir = "cache/images"
|
ImageCacheDir = "cache/images"
|
||||||
DefaultImageCacheSize = 100 * 1024 * 1024 // 100MB
|
DefaultImageCacheSize = 100 * 1024 * 1024 // 100MB
|
||||||
DefaultImageCacheMaxItems = 0 // Unlimited
|
DefaultImageCacheMaxItems = 0 // Unlimited
|
||||||
DefaultImageCachePurgeInterval = 10 * time.Minute
|
DefaultImageCacheCleanUpInterval = 10 * time.Minute
|
||||||
|
|
||||||
DevInitialUserName = "admin"
|
DevInitialUserName = "admin"
|
||||||
DevInitialName = "Dev Admin"
|
DevInitialName = "Dev Admin"
|
||||||
|
|||||||
+53
-32
@@ -4,6 +4,7 @@ import (
|
|||||||
"bytes"
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
|
"fmt"
|
||||||
"image"
|
"image"
|
||||||
_ "image/gif"
|
_ "image/gif"
|
||||||
"image/jpeg"
|
"image/jpeg"
|
||||||
@@ -40,47 +41,67 @@ type cover struct {
|
|||||||
cache fscache.Cache
|
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 {
|
func (c *cover) Get(ctx context.Context, id string, size int, out io.Writer) error {
|
||||||
id = strings.TrimPrefix(id, "al-")
|
id = strings.TrimPrefix(id, "al-")
|
||||||
path, _, err := c.getCoverPath(ctx, id)
|
path, lastUpdate, err := c.getCoverPath(ctx, id)
|
||||||
if err != nil && err != model.ErrNotFound {
|
if err != nil && err != model.ErrNotFound {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
reader, err := c.getCover(ctx, path, size)
|
cacheKey := imageCacheKey(path, size, lastUpdate)
|
||||||
|
r, w, err := c.cache.Get(cacheKey)
|
||||||
if err != nil {
|
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
|
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) {
|
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 {
|
||||||
@@ -139,11 +160,11 @@ func NewImageCache() (ImageCache, error) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
cacheSize = consts.DefaultImageCacheSize
|
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)
|
h := fscache.NewLRUHaunterStrategy(lru)
|
||||||
cacheFolder := filepath.Join(conf.Server.DataFolder, consts.ImageCacheDir)
|
cacheFolder := filepath.Join(conf.Server.DataFolder, consts.ImageCacheDir)
|
||||||
log.Info("Creating image cache", "path", cacheFolder, "maxSize", humanize.Bytes(cacheSize),
|
log.Info("Creating image cache", "path", cacheFolder, "maxSize", humanize.Bytes(cacheSize),
|
||||||
"cleanUpInterval", consts.DefaultImageCachePurgeInterval)
|
"cleanUpInterval", consts.DefaultImageCacheCleanUpInterval)
|
||||||
fs, err := fscache.NewFs(cacheFolder, 0755)
|
fs, err := fscache.NewFs(cacheFolder, 0755)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
|
|||||||
@@ -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))
|
||||||
|
})
|
||||||
|
|
||||||
|
})
|
||||||
@@ -1,10 +1,13 @@
|
|||||||
package engine
|
package engine
|
||||||
|
|
||||||
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"
|
||||||
)
|
)
|
||||||
@@ -15,3 +18,18 @@ func TestEngine(t *testing.T) {
|
|||||||
RegisterFailHandler(Fail)
|
RegisterFailHandler(Fail)
|
||||||
RunSpecs(t, "Engine Suite")
|
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)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|||||||
@@ -212,11 +212,11 @@ func NewTranscodingCache() (TranscodingCache, error) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
cacheSize = consts.DefaultTranscodingCacheSize
|
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)
|
h := fscache.NewLRUHaunterStrategy(lru)
|
||||||
cacheFolder := filepath.Join(conf.Server.DataFolder, consts.TranscodingCacheDir)
|
cacheFolder := filepath.Join(conf.Server.DataFolder, consts.TranscodingCacheDir)
|
||||||
log.Info("Creating transcoding cache", "path", cacheFolder, "maxSize", humanize.Bytes(cacheSize),
|
log.Info("Creating transcoding cache", "path", cacheFolder, "maxSize", humanize.Bytes(cacheSize),
|
||||||
"cleanUpInterval", consts.DefaultTranscodingCachePurgeInterval)
|
"cleanUpInterval", consts.DefaultTranscodingCacheCleanUpInterval)
|
||||||
fs, err := fscache.NewFs(cacheFolder, 0755)
|
fs, err := fscache.NewFs(cacheFolder, 0755)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
|
|||||||
@@ -3,14 +3,11 @@ package engine
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"io"
|
"io"
|
||||||
"io/ioutil"
|
|
||||||
"os"
|
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"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"
|
||||||
"github.com/djherbis/fscache"
|
|
||||||
. "github.com/onsi/ginkgo"
|
. "github.com/onsi/ginkgo"
|
||||||
. "github.com/onsi/gomega"
|
. "github.com/onsi/gomega"
|
||||||
)
|
)
|
||||||
@@ -18,25 +15,13 @@ import (
|
|||||||
var _ = Describe("MediaStreamer", func() {
|
var _ = Describe("MediaStreamer", func() {
|
||||||
var streamer MediaStreamer
|
var streamer MediaStreamer
|
||||||
var ds model.DataStore
|
var ds model.DataStore
|
||||||
var cache fscache.Cache
|
|
||||||
var tempDir string
|
|
||||||
ffmpeg := &fakeFFmpeg{Data: "fake data"}
|
ffmpeg := &fakeFFmpeg{Data: "fake data"}
|
||||||
ctx := log.NewContext(nil)
|
ctx := log.NewContext(nil)
|
||||||
|
|
||||||
BeforeSuite(func() {
|
|
||||||
tempDir, _ = ioutil.TempDir("", "stream_tests")
|
|
||||||
fs, _ := fscache.NewFs(tempDir, 0755)
|
|
||||||
cache, _ = fscache.NewCache(fs, nil)
|
|
||||||
})
|
|
||||||
|
|
||||||
BeforeEach(func() {
|
BeforeEach(func() {
|
||||||
ds = &persistence.MockDataStore{MockedTranscoding: &mockTranscodingRepository{}}
|
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)
|
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)
|
streamer = NewMediaStreamer(ds, ffmpeg, testCache)
|
||||||
})
|
|
||||||
|
|
||||||
AfterSuite(func() {
|
|
||||||
os.RemoveAll(tempDir)
|
|
||||||
})
|
})
|
||||||
|
|
||||||
Context("NewStream", func() {
|
Context("NewStream", func() {
|
||||||
|
|||||||
Reference in New Issue
Block a user