diff --git a/core/artwork.go b/core/artwork.go
index 56abc337d..58a4a2030 100644
--- a/core/artwork.go
+++ b/core/artwork.go
@@ -14,6 +14,7 @@ import (
 	"strings"
 	"time"
 
+	"github.com/deluan/navidrome/core/cache"
 	_ "golang.org/x/image/webp"
 
 	"github.com/deluan/navidrome/conf"
@@ -30,7 +31,7 @@ type Artwork interface {
 	Get(ctx context.Context, id string, size int, out io.Writer) error
 }
 
-type ArtworkCache FileCache
+type ArtworkCache cache.FileCache
 
 func NewArtwork(ds model.DataStore, cache ArtworkCache) Artwork {
 	return &artwork{ds: ds, cache: cache}
@@ -38,7 +39,7 @@ func NewArtwork(ds model.DataStore, cache ArtworkCache) Artwork {
 
 type artwork struct {
 	ds    model.DataStore
-	cache FileCache
+	cache cache.FileCache
 }
 
 type imageInfo struct {
@@ -196,7 +197,7 @@ func readFromFile(path string) ([]byte, error) {
 }
 
 func NewImageCache() ArtworkCache {
-	return NewFileCache("Image", conf.Server.ImageCacheSize, consts.ImageCacheDir, consts.DefaultImageCacheMaxItems,
+	return cache.NewFileCache("Image", conf.Server.ImageCacheSize, consts.ImageCacheDir, consts.DefaultImageCacheMaxItems,
 		func(ctx context.Context, arg fmt.Stringer) (io.Reader, error) {
 			info := arg.(*imageInfo)
 			reader, err := info.c.getArtwork(ctx, info.path, info.size)
diff --git a/core/cache/cache_suite_test.go b/core/cache/cache_suite_test.go
new file mode 100644
index 000000000..d781ebf84
--- /dev/null
+++ b/core/cache/cache_suite_test.go
@@ -0,0 +1,17 @@
+package cache
+
+import (
+	"testing"
+
+	"github.com/deluan/navidrome/log"
+	"github.com/deluan/navidrome/tests"
+	. "github.com/onsi/ginkgo"
+	. "github.com/onsi/gomega"
+)
+
+func TestCache(t *testing.T) {
+	tests.Init(t, false)
+	log.SetLevel(log.LevelCritical)
+	RegisterFailHandler(Fail)
+	RunSpecs(t, "Cache Suite")
+}
diff --git a/core/file_caches.go b/core/cache/file_caches.go
similarity index 98%
rename from core/file_caches.go
rename to core/cache/file_caches.go
index 55fe8b6cd..2264e933e 100644
--- a/core/file_caches.go
+++ b/core/cache/file_caches.go
@@ -1,4 +1,4 @@
-package core
+package cache
 
 import (
 	"context"
@@ -184,7 +184,7 @@ func newFSCache(name, cacheSize, cacheFolder string, maxItems int) (fscache.Cach
 	cacheFolder = filepath.Join(conf.Server.DataFolder, cacheFolder)
 
 	log.Info(fmt.Sprintf("Creating %s cache", name), "path", cacheFolder, "maxSize", humanize.Bytes(size))
-	fs, err := fscache.NewFs(cacheFolder, 0755)
+	fs, err := NewSpreadFs(cacheFolder, 0755)
 	if err != nil {
 		log.Error(fmt.Sprintf("Error initializing %s cache", name), err, "elapsedTime", time.Since(start))
 		return nil, err
diff --git a/core/file_caches_test.go b/core/cache/file_caches_test.go
similarity index 99%
rename from core/file_caches_test.go
rename to core/cache/file_caches_test.go
index 161c799f8..2724d301d 100644
--- a/core/file_caches_test.go
+++ b/core/cache/file_caches_test.go
@@ -1,4 +1,4 @@
-package core
+package cache
 
 import (
 	"context"
diff --git a/core/cache/spread_fs.go b/core/cache/spread_fs.go
new file mode 100644
index 000000000..755b5a599
--- /dev/null
+++ b/core/cache/spread_fs.go
@@ -0,0 +1,83 @@
+package cache
+
+import (
+	"crypto/md5"
+	"fmt"
+	"os"
+	"path/filepath"
+	"strings"
+
+	"github.com/djherbis/fscache"
+	"github.com/karrick/godirwalk"
+	"gopkg.in/djherbis/atime.v1"
+	"gopkg.in/djherbis/stream.v1"
+)
+
+type spreadFs struct {
+	root string
+	mode os.FileMode
+	init func() error
+}
+
+// NewSpreadFs returns a FileSystem rooted at directory dir. It
+// Dir is created with perms if it doesn't exist.
+func NewSpreadFs(dir string, mode os.FileMode) (fscache.FileSystem, error) {
+	fs := &spreadFs{root: dir, mode: mode, init: func() error {
+		return os.MkdirAll(dir, mode)
+	}}
+	return fs, fs.init()
+}
+
+func (fs *spreadFs) Reload(f func(key string, name string)) error {
+	return godirwalk.Walk(fs.root, &godirwalk.Options{
+		Callback: func(absoluteFilePath string, de *godirwalk.Dirent) error {
+			path, err := filepath.Rel(fs.root, absoluteFilePath)
+			if err != nil {
+				return nil
+			}
+
+			parts := strings.Split(path, string(os.PathSeparator))
+			if len(parts) != 3 || len(parts[0]) != 2 || len(parts[1]) != 2 {
+				return nil
+			}
+
+			key := filepath.Base(path)
+			f(key, absoluteFilePath)
+			return nil
+		},
+		Unsorted: true,
+	})
+}
+
+func (fs *spreadFs) Create(name string) (stream.File, error) {
+	key := fmt.Sprintf("%x", md5.Sum([]byte(name)))
+	path := fmt.Sprintf("%s%c%s", key[0:2], os.PathSeparator, key[2:4])
+	err := os.MkdirAll(filepath.Join(fs.root, path), fs.mode)
+	if err != nil {
+		return nil, err
+	}
+	return os.OpenFile(filepath.Join(fs.root, path, key), os.O_RDWR|os.O_CREATE|os.O_TRUNC, 0600)
+}
+
+func (fs *spreadFs) Open(name string) (stream.File, error) {
+	return os.Open(name)
+}
+
+func (fs *spreadFs) Remove(name string) error {
+	return os.Remove(name)
+}
+
+func (fs *spreadFs) Stat(name string) (fscache.FileInfo, error) {
+	stat, err := os.Stat(name)
+	if err != nil {
+		return fscache.FileInfo{}, err
+	}
+	return fscache.FileInfo{FileInfo: stat, Atime: atime.Get(stat)}, nil
+}
+
+func (fs *spreadFs) RemoveAll() error {
+	if err := os.RemoveAll(fs.root); err != nil {
+		return err
+	}
+	return fs.init()
+}
diff --git a/core/media_streamer.go b/core/media_streamer.go
index 6d3347a4c..a673fdf84 100644
--- a/core/media_streamer.go
+++ b/core/media_streamer.go
@@ -10,6 +10,7 @@ import (
 
 	"github.com/deluan/navidrome/conf"
 	"github.com/deluan/navidrome/consts"
+	"github.com/deluan/navidrome/core/cache"
 	"github.com/deluan/navidrome/core/transcoder"
 	"github.com/deluan/navidrome/log"
 	"github.com/deluan/navidrome/model"
@@ -20,7 +21,7 @@ type MediaStreamer interface {
 	NewStream(ctx context.Context, id string, reqFormat string, reqBitRate int) (*Stream, error)
 }
 
-type TranscodingCache FileCache
+type TranscodingCache cache.FileCache
 
 func NewMediaStreamer(ds model.DataStore, ffm transcoder.Transcoder, cache TranscodingCache) MediaStreamer {
 	return &mediaStreamer{ds: ds, ffm: ffm, cache: cache}
@@ -29,7 +30,7 @@ func NewMediaStreamer(ds model.DataStore, ffm transcoder.Transcoder, cache Trans
 type mediaStreamer struct {
 	ds    model.DataStore
 	ffm   transcoder.Transcoder
-	cache FileCache
+	cache cache.FileCache
 }
 
 type streamJob struct {
@@ -167,7 +168,7 @@ func selectTranscodingOptions(ctx context.Context, ds model.DataStore, mf *model
 }
 
 func NewTranscodingCache() TranscodingCache {
-	return NewFileCache("Transcoding", conf.Server.TranscodingCacheSize,
+	return cache.NewFileCache("Transcoding", conf.Server.TranscodingCacheSize,
 		consts.TranscodingCacheDir, consts.DefaultTranscodingCacheMaxItems,
 		func(ctx context.Context, arg fmt.Stringer) (io.Reader, error) {
 			job := arg.(*streamJob)
diff --git a/go.mod b/go.mod
index d111ba6b0..58e6f122a 100644
--- a/go.mod
+++ b/go.mod
@@ -21,6 +21,7 @@ require (
 	github.com/golangci/golangci-lint v1.31.0
 	github.com/google/uuid v1.1.2
 	github.com/google/wire v0.4.0
+	github.com/karrick/godirwalk v1.16.1
 	github.com/kennygrant/sanitize v0.0.0-20170120101633-6a0bfdde8629
 	github.com/lib/pq v1.3.0 // indirect
 	github.com/mattn/go-sqlite3 v2.0.3+incompatible
@@ -43,8 +44,8 @@ require (
 	golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8
 	golang.org/x/tools v0.0.0-20200812195022-5ae4c3c160a0
 	google.golang.org/protobuf v1.25.0 // indirect
-	gopkg.in/djherbis/atime.v1 v1.0.0 // indirect
-	gopkg.in/djherbis/stream.v1 v1.3.1 // indirect
+	gopkg.in/djherbis/atime.v1 v1.0.0
+	gopkg.in/djherbis/stream.v1 v1.3.1
 	gopkg.in/ini.v1 v1.57.0 // indirect
 )
 
diff --git a/go.sum b/go.sum
index d0766c256..8f38d8fa2 100644
--- a/go.sum
+++ b/go.sum
@@ -293,6 +293,8 @@ github.com/jstemmer/go-junit-report v0.0.0-20190106144839-af01ea7f8024/go.mod h1
 github.com/jtolds/gls v4.20.0+incompatible h1:xdiiI2gbIgH/gLH7ADydsJ1uDOEzR8yvV7C0MuV77Wo=
 github.com/jtolds/gls v4.20.0+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU=
 github.com/julienschmidt/httprouter v1.2.0/go.mod h1:SYymIcj16QtmaHHD7aYtjjsJG7VTCxuUUipMqKk8s4w=
+github.com/karrick/godirwalk v1.16.1 h1:DynhcF+bztK8gooS0+NDJFrdNZjJ3gzVzC545UNA9iw=
+github.com/karrick/godirwalk v1.16.1/go.mod h1:j4mkqPuvaLI8mp1DroR3P6ad7cyYd4c1qeJ3RV7ULlk=
 github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 h1:Z9n2FFNUXsshfwJMBgNA0RU6/i7WVaAegv3PtuIHPMs=
 github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51/go.mod h1:CzGEWj7cYgsdH8dAjBGEr58BoE7ScuLd+fwFZ44+/x8=
 github.com/kennygrant/sanitize v0.0.0-20170120101633-6a0bfdde8629 h1:m1E9veL+2sjZOMSM7y3a6jJ9fNVaGyIJCXYDPm9U+/0=