diff --git a/consts/consts.go b/consts/consts.go index bf4e7ff5b..52736fddc 100644 --- a/consts/consts.go +++ b/consts/consts.go @@ -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" diff --git a/engine/cover.go b/engine/cover.go index e454ed8db..13ac1ede1 100644 --- a/engine/cover.go +++ b/engine/cover.go @@ -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 diff --git a/engine/cover_test.go b/engine/cover_test.go new file mode 100644 index 000000000..2505a52f5 --- /dev/null +++ b/engine/cover_test.go @@ -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)) + }) + +}) diff --git a/engine/engine_suite_test.go b/engine/engine_suite_test.go index bd2e50649..a781a10a3 100644 --- a/engine/engine_suite_test.go +++ b/engine/engine_suite_test.go @@ -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) + }) +}) diff --git a/engine/media_streamer.go b/engine/media_streamer.go index f6aa1d1bd..1e6136605 100644 --- a/engine/media_streamer.go +++ b/engine/media_streamer.go @@ -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 diff --git a/engine/media_streamer_test.go b/engine/media_streamer_test.go index 48c30fec3..72e178edc 100644 --- a/engine/media_streamer_test.go +++ b/engine/media_streamer_test.go @@ -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() {