diff --git a/README.md b/README.md
index 5cee6808..e8f3eb48 100644
--- a/README.md
+++ b/README.md
@@ -158,6 +158,20 @@ server:
For detailed API documentation see [here](/API.md).
+### Audio cache
+
+Downloaded audio files can be cached on disk so that replaying a track does not download it again from the CDN. Only the
+raw, still-encrypted files are stored: a cached file is useless without a valid Spotify account, since the audio key is
+retrieved again on every playback. The cache is disabled by default; once enabled it applies a 1 GB limit, after which the
+least-recently-used files are evicted.
+
+```yaml
+cache:
+ enabled: false # Whether to cache downloaded audio files (default: false)
+ dir: '' # Directory for cached files (default: the XDG cache directory, e.g. $XDG_CACHE_HOME/go-librespot or $HOME/.cache/go-librespot)
+ size_limit: '1GB' # Maximum total cache size before evicting least-recently-used files ('0' for unlimited)
+```
+
### Volume synchronization
Various configurations for volume control are available:
diff --git a/audio/chunked-reader.go b/audio/chunked-reader.go
index b9013348..6c3e963d 100644
--- a/audio/chunked-reader.go
+++ b/audio/chunked-reader.go
@@ -72,6 +72,11 @@ type HttpChunkedReader struct {
ctx context.Context
cancel context.CancelFunc
+ completeMu sync.Mutex
+ completedChunks int
+ onComplete func(io.ReaderAt, int64)
+ onCompleteFired bool
+
latMu sync.Mutex
latencies []time.Duration
}
@@ -123,6 +128,9 @@ func NewHttpChunkedReader(log librespot.Logger, client *http.Client, audioUrl st
return nil, fmt.Errorf("failed reading first chunk: %w", err)
}
+ // The first chunk is fetched eagerly here, so count it towards completion.
+ r.completedChunks = 1
+
log.Debugf("fetched first chunk of %d, total size is %d bytes", len(r.chunks), r.len)
return r, nil
}
@@ -231,6 +239,8 @@ func (r *HttpChunkedReader) fetchChunk(idx int) ([]byte, error) {
chunk.Broadcast()
chunk.L.Unlock()
+ r.markChunkComplete()
+
r.log.Debugf("fetched chunk %d/%d, size: %d", idx, len(r.chunks)-1, len(data))
if r.isClosed() {
return nil, net.ErrClosed
@@ -374,6 +384,39 @@ func (r *HttpChunkedReader) Size() int64 {
return r.len
}
+// OnComplete registers a callback invoked once, in a separate goroutine, as
+// soon as every chunk has been downloaded. The callback receives the reader as
+// an io.ReaderAt (serving the fully-buffered chunks) and the total size, which
+// allows the complete encrypted file to be persisted to a cache. If the file is
+// already fully downloaded, the callback fires immediately.
+func (r *HttpChunkedReader) OnComplete(cb func(io.ReaderAt, int64)) {
+ r.completeMu.Lock()
+ r.onComplete = cb
+ r.maybeFireComplete()
+ r.completeMu.Unlock()
+}
+
+// markChunkComplete records that one more chunk finished downloading and fires
+// the completion callback when all chunks are present.
+func (r *HttpChunkedReader) markChunkComplete() {
+ r.completeMu.Lock()
+ r.completedChunks++
+ r.maybeFireComplete()
+ r.completeMu.Unlock()
+}
+
+// maybeFireComplete fires the completion callback exactly once. The caller must
+// hold completeMu.
+func (r *HttpChunkedReader) maybeFireComplete() {
+ if r.onCompleteFired || r.onComplete == nil || r.completedChunks < len(r.chunks) {
+ return
+ }
+
+ r.onCompleteFired = true
+ cb := r.onComplete
+ go cb(r, r.len)
+}
+
func (r *HttpChunkedReader) Url() *url.URL {
return r.url
}
diff --git a/cache/cache.go b/cache/cache.go
new file mode 100644
index 00000000..ed75a132
--- /dev/null
+++ b/cache/cache.go
@@ -0,0 +1,148 @@
+// Package cache implements an on-disk cache for the encrypted audio files
+// downloaded from the Spotify CDN.
+//
+// Only the raw, still-encrypted bytes are stored: a cached file is useless
+// without a valid audio key retrieved per playback, mirroring the behaviour of
+// the reference librespot implementation. Files are keyed by their Spotify file
+// id and evicted least-recently-used once an optional size limit is reached.
+package cache
+
+import (
+ "encoding/hex"
+ "fmt"
+ "io"
+ "os"
+ "path/filepath"
+ "time"
+
+ librespot "github.com/devgianlu/go-librespot"
+)
+
+// Cache stores encrypted audio files on disk under
/audio.
+type Cache struct {
+ log librespot.Logger
+ dir string
+ limiter *sizeLimiter
+}
+
+// New creates an audio file cache rooted at dir. When sizeLimit is greater than
+// zero, least-recently-used files are evicted to keep the cache within the
+// limit; a value of zero disables eviction (unbounded cache).
+func New(log librespot.Logger, dir string, sizeLimit int64) (*Cache, error) {
+ audioDir := filepath.Join(dir, "audio")
+ if err := os.MkdirAll(audioDir, 0o700); err != nil {
+ return nil, fmt.Errorf("failed creating cache directory: %w", err)
+ }
+
+ c := &Cache{log: log, dir: audioDir}
+ if sizeLimit > 0 {
+ c.limiter = newSizeLimiter(sizeLimit)
+ if err := c.limiter.init(audioDir); err != nil {
+ return nil, fmt.Errorf("failed initializing cache size limiter: %w", err)
+ }
+
+ // The limit may have shrunk since the last run, prune eagerly.
+ for _, evicted := range c.limiter.prune() {
+ log.Debugf("evicted cached file %s", filepath.Base(evicted))
+ }
+ }
+
+ log.Debugf("initialized audio cache at %s", audioDir)
+ return c, nil
+}
+
+// filePath returns the on-disk path for the given file id. The first two hex
+// characters form a subdirectory to avoid a single directory with a huge number
+// of entries.
+func (c *Cache) filePath(fileId []byte) string {
+ name := hex.EncodeToString(fileId)
+ return filepath.Join(c.dir, name[:2], name[2:])
+}
+
+// File returns a reader for the cached encrypted audio file, if present. The
+// boolean reports whether the file was found. Accessing a file refreshes its
+// recency for LRU eviction.
+func (c *Cache) File(fileId []byte) (librespot.SizedReadAtSeeker, bool) {
+ path := c.filePath(fileId)
+
+ f, err := os.Open(path)
+ if err != nil {
+ if !os.IsNotExist(err) {
+ c.log.WithError(err).Warnf("failed opening cached file %s", filepath.Base(path))
+ }
+ return nil, false
+ }
+
+ fi, err := f.Stat()
+ if err != nil {
+ _ = f.Close()
+ c.log.WithError(err).Warnf("failed stating cached file %s", filepath.Base(path))
+ return nil, false
+ }
+
+ if c.limiter != nil {
+ c.limiter.touch(path)
+ }
+
+ // Persist the access on disk as well (like librespot does with filetime):
+ // the limiter seeds recency from the modification time on startup, so
+ // without this the LRU order would not survive a restart. Best-effort.
+ now := time.Now()
+ _ = os.Chtimes(path, now, now)
+
+ return &fileReader{File: f, size: fi.Size()}, true
+}
+
+// SaveFile stores the encrypted audio file identified by fileId, reading its
+// contents from r. Writing is atomic: the data is written to a temporary file
+// and renamed into place, so a crash never leaves a truncated cache entry.
+// After a successful write, least-recently-used files are evicted when a size
+// limit is configured.
+func (c *Cache) SaveFile(fileId []byte, r io.Reader) error {
+ path := c.filePath(fileId)
+ if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
+ return fmt.Errorf("failed creating cache subdirectory: %w", err)
+ }
+
+ tmp, err := os.CreateTemp(filepath.Dir(path), filepath.Base(path)+".*.tmp")
+ if err != nil {
+ return fmt.Errorf("failed creating temporary cache file: %w", err)
+ }
+ tmpPath := tmp.Name()
+
+ size, err := io.Copy(tmp, r)
+ if err != nil {
+ _ = tmp.Close()
+ _ = os.Remove(tmpPath)
+ return fmt.Errorf("failed writing cache file: %w", err)
+ }
+
+ if err := tmp.Close(); err != nil {
+ _ = os.Remove(tmpPath)
+ return fmt.Errorf("failed closing cache file: %w", err)
+ }
+
+ if err := os.Rename(tmpPath, path); err != nil {
+ _ = os.Remove(tmpPath)
+ return fmt.Errorf("failed renaming cache file: %w", err)
+ }
+
+ c.log.Debugf("cached audio file %s (%d bytes)", filepath.Base(path), size)
+
+ if c.limiter != nil {
+ c.limiter.add(path, size)
+ for _, evicted := range c.limiter.prune() {
+ c.log.Debugf("evicted cached file %s", filepath.Base(evicted))
+ }
+ }
+
+ return nil
+}
+
+// fileReader adapts an *os.File to librespot.SizedReadAtSeeker.
+type fileReader struct {
+ *os.File
+ size int64
+}
+
+func (r *fileReader) Size() int64 { return r.size }
diff --git a/cache/cache_test.go b/cache/cache_test.go
new file mode 100644
index 00000000..36289583
--- /dev/null
+++ b/cache/cache_test.go
@@ -0,0 +1,107 @@
+package cache
+
+import (
+ "bytes"
+ "io"
+ "os"
+ "path/filepath"
+ "testing"
+
+ librespot "github.com/devgianlu/go-librespot"
+ "github.com/stretchr/testify/require"
+)
+
+func newTestCache(t *testing.T, sizeLimit int64) *Cache {
+ t.Helper()
+ c, err := New(&librespot.NullLogger{}, t.TempDir(), sizeLimit)
+ require.NoError(t, err)
+ return c
+}
+
+func TestFilePathLayout(t *testing.T) {
+ c := newTestCache(t, 0)
+
+ fileId := []byte{0xab, 0xcd, 0xef, 0x01, 0x23}
+ path := c.filePath(fileId)
+
+ require.Equal(t, "ab", filepath.Base(filepath.Dir(path)))
+ require.Equal(t, "cdef0123", filepath.Base(path))
+}
+
+func TestSaveAndReadRoundTrip(t *testing.T) {
+ c := newTestCache(t, 0)
+ fileId := []byte{0x01, 0x02, 0x03, 0x04}
+ data := []byte("some encrypted audio payload")
+
+ // miss before save
+ _, ok := c.File(fileId)
+ require.False(t, ok)
+
+ require.NoError(t, c.SaveFile(fileId, bytes.NewReader(data)))
+
+ r, ok := c.File(fileId)
+ require.True(t, ok)
+ defer func() { _ = r.(io.Closer).Close() }()
+
+ require.Equal(t, int64(len(data)), r.Size())
+
+ got, err := io.ReadAll(r)
+ require.NoError(t, err)
+ require.Equal(t, data, got)
+
+ // no leftover temporary files
+ entries, err := filepath.Glob(filepath.Join(c.dir, "*", "*.tmp"))
+ require.NoError(t, err)
+ require.Empty(t, entries)
+}
+
+func TestReadAtSeek(t *testing.T) {
+ c := newTestCache(t, 0)
+ fileId := []byte{0x0a, 0x0b}
+ data := []byte("0123456789")
+ require.NoError(t, c.SaveFile(fileId, bytes.NewReader(data)))
+
+ r, ok := c.File(fileId)
+ require.True(t, ok)
+ defer func() { _ = r.(io.Closer).Close() }()
+
+ buf := make([]byte, 4)
+ n, err := r.ReadAt(buf, 3)
+ require.NoError(t, err)
+ require.Equal(t, 4, n)
+ require.Equal(t, []byte("3456"), buf)
+
+ pos, err := r.Seek(2, io.SeekStart)
+ require.NoError(t, err)
+ require.Equal(t, int64(2), pos)
+}
+
+func TestSavePersistsAcrossReopen(t *testing.T) {
+ dir := t.TempDir()
+ fileId := []byte{0xde, 0xad, 0xbe, 0xef}
+ data := []byte("persisted payload")
+
+ c1, err := New(&librespot.NullLogger{}, dir, 1<<20)
+ require.NoError(t, err)
+ require.NoError(t, c1.SaveFile(fileId, bytes.NewReader(data)))
+
+ // A fresh cache instance over the same directory must find the file and
+ // account for its size.
+ c2, err := New(&librespot.NullLogger{}, dir, 1<<20)
+ require.NoError(t, err)
+
+ r, ok := c2.File(fileId)
+ require.True(t, ok)
+ defer func() { _ = r.(io.Closer).Close() }()
+ require.EqualValues(t, len(data), c2.limiter.inUse)
+}
+
+func TestNewCreatesAudioDir(t *testing.T) {
+ dir := t.TempDir()
+ _, err := New(&librespot.NullLogger{}, dir, 0)
+ require.NoError(t, err)
+
+ info, err := os.Stat(filepath.Join(dir, "audio"))
+ require.NoError(t, err)
+ require.True(t, info.IsDir())
+}
diff --git a/cache/limiter.go b/cache/limiter.go
new file mode 100644
index 00000000..711224af
--- /dev/null
+++ b/cache/limiter.go
@@ -0,0 +1,114 @@
+package cache
+
+import (
+ "os"
+ "path/filepath"
+ "strings"
+ "sync"
+ "time"
+)
+
+// limiterEntry tracks a single cached file for eviction purposes.
+type limiterEntry struct {
+ size int64
+ atime time.Time
+}
+
+// sizeLimiter keeps the total size of the cached files within a configured
+// limit by evicting the least-recently-used entries. It is safe for concurrent
+// use.
+//
+// Eviction candidates are found with a linear scan over the tracked files. The
+// number of cached audio files is small enough (a bounded cache of multi-megabyte
+// files) that a heap-based priority queue, as used by librespot, is not worth
+// the extra complexity here.
+type sizeLimiter struct {
+ mu sync.Mutex
+ limit int64
+ inUse int64
+ files map[string]*limiterEntry
+}
+
+func newSizeLimiter(limit int64) *sizeLimiter {
+ return &sizeLimiter{limit: limit, files: make(map[string]*limiterEntry)}
+}
+
+// init walks the cache directory and registers every existing file so the
+// limiter reflects the on-disk state after a restart. Recency is seeded from
+// the file modification time, as access time is not portably available. Any
+// leftover temporary files from an interrupted write are removed.
+func (s *sizeLimiter) init(dir string) error {
+ return filepath.Walk(dir, func(path string, info os.FileInfo, err error) error {
+ if err != nil {
+ return err
+ }
+ if info.IsDir() {
+ return nil
+ }
+ if strings.HasSuffix(path, ".tmp") {
+ _ = os.Remove(path)
+ return nil
+ }
+
+ s.mu.Lock()
+ s.files[path] = &limiterEntry{size: info.Size(), atime: info.ModTime()}
+ s.inUse += info.Size()
+ s.mu.Unlock()
+ return nil
+ })
+}
+
+// add registers a newly written file (or updates an existing one) with the
+// current time as its access time.
+func (s *sizeLimiter) add(path string, size int64) {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+
+ if e, ok := s.files[path]; ok {
+ s.inUse -= e.size
+ }
+ s.files[path] = &limiterEntry{size: size, atime: time.Now()}
+ s.inUse += size
+}
+
+// touch refreshes the access time of a file, marking it as recently used.
+func (s *sizeLimiter) touch(path string) {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+
+ if e, ok := s.files[path]; ok {
+ e.atime = time.Now()
+ }
+}
+
+// prune removes least-recently-used files from disk until the tracked size is
+// within the configured limit. It returns the paths that were evicted.
+func (s *sizeLimiter) prune() []string {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+
+ var evicted []string
+ for s.inUse > s.limit {
+ var oldestPath string
+ var oldest *limiterEntry
+ for path, e := range s.files {
+ if oldest == nil || e.atime.Before(oldest.atime) {
+ oldest, oldestPath = e, path
+ }
+ }
+ if oldest == nil {
+ break
+ }
+
+ err := os.Remove(oldestPath)
+ delete(s.files, oldestPath)
+ s.inUse -= oldest.size
+ if err != nil && !os.IsNotExist(err) {
+ // Stop after an un-removable file to avoid a busy loop; the entry has
+ // already been dropped from accounting.
+ break
+ }
+ evicted = append(evicted, oldestPath)
+ }
+ return evicted
+}
diff --git a/cache/limiter_test.go b/cache/limiter_test.go
new file mode 100644
index 00000000..41d3d05d
--- /dev/null
+++ b/cache/limiter_test.go
@@ -0,0 +1,113 @@
+package cache
+
+import (
+ "bytes"
+ "os"
+ "testing"
+ "time"
+
+ librespot "github.com/devgianlu/go-librespot"
+ "github.com/stretchr/testify/require"
+)
+
+func TestEvictsLeastRecentlyUsed(t *testing.T) {
+ // Limit fits two 10-byte files but not three.
+ c := newTestCacheWithLimit(t, 25)
+
+ a := []byte{0x0a}
+ b := []byte{0x0b}
+ d := []byte{0x0c}
+ payload := bytes.Repeat([]byte("x"), 10)
+
+ require.NoError(t, c.SaveFile(a, bytes.NewReader(payload)))
+ touchOlder(c, c.filePath(a), 3*time.Second)
+
+ require.NoError(t, c.SaveFile(b, bytes.NewReader(payload)))
+ touchOlder(c, c.filePath(b), 2*time.Second)
+
+ // Access a so that b becomes the least recently used.
+ r, ok := c.File(a)
+ require.True(t, ok)
+ _ = r.(interface{ Close() error }).Close()
+
+ // Saving d pushes us over the limit; b (oldest untouched) must be evicted.
+ require.NoError(t, c.SaveFile(d, bytes.NewReader(payload)))
+
+ _, ok = c.File(b)
+ require.False(t, ok, "least recently used file should have been evicted")
+
+ _, ok = c.File(a)
+ require.True(t, ok, "recently used file should be retained")
+ _, ok = c.File(d)
+ require.True(t, ok, "newly written file should be retained")
+
+ require.LessOrEqual(t, c.limiter.inUse, int64(25))
+}
+
+func TestRecencySurvivesRestart(t *testing.T) {
+ dir := t.TempDir()
+ a := []byte{0x0a}
+ b := []byte{0x0b}
+ payload := bytes.Repeat([]byte("x"), 10)
+
+ c1, err := New(&librespot.NullLogger{}, dir, 100)
+ require.NoError(t, err)
+ require.NoError(t, c1.SaveFile(a, bytes.NewReader(payload)))
+ require.NoError(t, c1.SaveFile(b, bytes.NewReader(payload)))
+
+ // Age both files on disk, then access b: reading must persist the access
+ // time to disk, not just in memory.
+ old := time.Now().Add(-time.Hour)
+ require.NoError(t, os.Chtimes(c1.filePath(a), old, old))
+ require.NoError(t, os.Chtimes(c1.filePath(b), old.Add(-time.Hour), old.Add(-time.Hour)))
+
+ r, ok := c1.File(b)
+ require.True(t, ok)
+ _ = r.(interface{ Close() error }).Close()
+
+ // A fresh instance over the same directory, with a limit fitting only one
+ // file, must evict a (stale) and keep b (recently accessed), even though b
+ // was written last... i.e. the LRU order reflects access, not write order.
+ c2, err := New(&librespot.NullLogger{}, dir, 15)
+ require.NoError(t, err)
+
+ _, ok = c2.File(a)
+ require.False(t, ok, "stale file should have been evicted on startup")
+ _, ok = c2.File(b)
+ require.True(t, ok, "recently accessed file should survive a restart")
+}
+
+func TestNoEvictionWithoutLimit(t *testing.T) {
+ c := newTestCache(t, 0)
+ require.Nil(t, c.limiter)
+
+ payload := bytes.Repeat([]byte("y"), 1000)
+ for i := 0; i < 20; i++ {
+ require.NoError(t, c.SaveFile([]byte{byte(i), 0xff}, bytes.NewReader(payload)))
+ }
+
+ for i := 0; i < 20; i++ {
+ _, ok := c.File([]byte{byte(i), 0xff})
+ require.True(t, ok)
+ }
+}
+
+func newTestCacheWithLimit(t *testing.T, limit int64) *Cache {
+ t.Helper()
+ c, err := New(&librespot.NullLogger{}, t.TempDir(), limit)
+ require.NoError(t, err)
+ return c
+}
+
+// touchOlder rewinds a tracked entry's access time to simulate an older last
+// access, since the test writes files in quick succession.
+func touchOlder(c *Cache, path string, d time.Duration) {
+ c.limiter.mu.Lock()
+ if e, ok := c.limiter.files[path]; ok {
+ e.atime = time.Now().Add(-d)
+ }
+ c.limiter.mu.Unlock()
+ // Keep the on-disk mtime consistent for good measure.
+ old := time.Now().Add(-d)
+ _ = os.Chtimes(path, old, old)
+}
diff --git a/cmd/daemon/cli_config.go b/cmd/daemon/cli_config.go
index b6425ee7..6a5ec777 100644
--- a/cmd/daemon/cli_config.go
+++ b/cmd/daemon/cli_config.go
@@ -5,6 +5,7 @@ import (
"fmt"
"os"
"path/filepath"
+ "strconv"
"strings"
"github.com/devgianlu/go-librespot/daemon"
@@ -72,6 +73,12 @@ type cliConfig struct {
ImageSize string `koanf:"image_size"`
} `koanf:"server"`
+ Cache struct {
+ Enabled bool `koanf:"enabled"`
+ Dir string `koanf:"dir"`
+ SizeLimit string `koanf:"size_limit"`
+ } `koanf:"cache"`
+
Credentials struct {
Type string `koanf:"type"`
Interactive struct {
@@ -123,6 +130,19 @@ func (c *cliConfig) toDaemonConfig() *daemon.Config {
FlacEnabled: c.FlacEnabled,
ImageSize: c.Server.ImageSize,
}
+ dc.Cache.Enabled = c.Cache.Enabled
+ dc.Cache.Dir = c.Cache.Dir
+ if dc.Cache.Dir == "" {
+ // Default to the XDG cache directory ($XDG_CACHE_HOME, falling back to
+ // $HOME/.cache), matching the reference librespot behaviour.
+ if cacheDir, err := os.UserCacheDir(); err == nil {
+ dc.Cache.Dir = filepath.Join(cacheDir, "go-librespot")
+ } else {
+ dc.Cache.Dir = filepath.Join(c.ConfigDir, "cache")
+ }
+ }
+ // The value is validated in loadCLIConfig, so the error is unreachable here.
+ dc.Cache.SizeLimit, _ = parseSize(c.Cache.SizeLimit)
dc.Credentials.Type = c.Credentials.Type
dc.Credentials.Interactive.CallbackPort = c.Credentials.Interactive.CallbackPort
dc.Credentials.SpotifyToken.Username = c.Credentials.SpotifyToken.Username
@@ -181,6 +201,9 @@ func loadCLIConfig(cfg *cliConfig) error {
"credentials.type": "zeroconf",
+ "cache.enabled": false,
+ "cache.size_limit": "1GB",
+
"zeroconf_backend": "builtin",
"server.address": "localhost",
@@ -236,5 +259,48 @@ func loadCLIConfig(cfg *cliConfig) error {
}
}
+ if _, err := parseSize(cfg.Cache.SizeLimit); err != nil {
+ return fmt.Errorf("invalid cache.size_limit: %w", err)
+ }
+
return nil
}
+
+// parseSize parses a human-readable size string such as "1GB", "500MB" or a
+// plain byte count. An empty string or "0" means no limit (returns 0).
+func parseSize(s string) (int64, error) {
+ s = strings.TrimSpace(s)
+ if s == "" {
+ return 0, nil
+ }
+
+ upper := strings.ToUpper(s)
+ var multiplier int64 = 1
+ // Ordered longest-first so "GB" is matched before the "B" suffix.
+ for _, unit := range []struct {
+ suffix string
+ factor int64
+ }{
+ {"TB", 1 << 40},
+ {"GB", 1 << 30},
+ {"MB", 1 << 20},
+ {"KB", 1 << 10},
+ {"B", 1},
+ } {
+ if strings.HasSuffix(upper, unit.suffix) {
+ multiplier = unit.factor
+ upper = strings.TrimSpace(strings.TrimSuffix(upper, unit.suffix))
+ break
+ }
+ }
+
+ value, err := strconv.ParseFloat(upper, 64)
+ if err != nil {
+ return 0, fmt.Errorf("invalid size %q", s)
+ }
+ if value < 0 {
+ return 0, fmt.Errorf("size cannot be negative: %q", s)
+ }
+
+ return int64(value * float64(multiplier)), nil
+}
diff --git a/cmd/daemon/cli_config_test.go b/cmd/daemon/cli_config_test.go
new file mode 100644
index 00000000..c97efe7e
--- /dev/null
+++ b/cmd/daemon/cli_config_test.go
@@ -0,0 +1,42 @@
+package main
+
+import (
+ "testing"
+
+ "github.com/stretchr/testify/require"
+)
+
+func TestParseSize(t *testing.T) {
+ cases := []struct {
+ in string
+ want int64
+ wantErr bool
+ }{
+ {"", 0, false},
+ {"0", 0, false},
+ {"1GB", 1 << 30, false},
+ {"1gb", 1 << 30, false},
+ {"500MB", 500 << 20, false},
+ {"512KB", 512 << 10, false},
+ {"2TB", 2 << 40, false},
+ {"1024", 1024, false},
+ {"1024B", 1024, false},
+ {"1.5GB", 1610612736, false},
+ {" 256MB ", 256 << 20, false},
+ {"abc", 0, true},
+ {"-1GB", 0, true},
+ {"GB", 0, true},
+ }
+
+ for _, tc := range cases {
+ t.Run(tc.in, func(t *testing.T) {
+ got, err := parseSize(tc.in)
+ if tc.wantErr {
+ require.Error(t, err)
+ return
+ }
+ require.NoError(t, err)
+ require.Equal(t, tc.want, got)
+ })
+ }
+}
diff --git a/config_schema.json b/config_schema.json
index e7c58e6e..a840072a 100644
--- a/config_schema.json
+++ b/config_schema.json
@@ -165,6 +165,27 @@
}
}
},
+ "cache": {
+ "type": "object",
+ "description": "On-disk cache for downloaded (encrypted) audio files",
+ "properties": {
+ "enabled": {
+ "type": "boolean",
+ "description": "Whether to cache downloaded audio files on disk to avoid re-downloading them from the CDN",
+ "default": false
+ },
+ "dir": {
+ "type": "string",
+ "description": "Directory to store cached audio files (defaults to the XDG cache directory, e.g. $XDG_CACHE_HOME/go-librespot or $HOME/.cache/go-librespot)",
+ "default": ""
+ },
+ "size_limit": {
+ "type": "string",
+ "description": "Maximum total size of the audio cache before the least-recently-used files are evicted (e.g. '1GB', '500MB'; '0' for unlimited)",
+ "default": "1GB"
+ }
+ }
+ },
"zeroconf_enabled": {
"type": "boolean",
"description": "Whether Zeroconf discovery should always be enabled (even when logging in with credentials)",
diff --git a/daemon/app.go b/daemon/app.go
index e90297ff..8b4c0e4a 100644
--- a/daemon/app.go
+++ b/daemon/app.go
@@ -12,6 +12,7 @@ import (
librespot "github.com/devgianlu/go-librespot"
"github.com/devgianlu/go-librespot/apresolve"
+ "github.com/devgianlu/go-librespot/cache"
"github.com/devgianlu/go-librespot/mpris"
"github.com/devgianlu/go-librespot/player"
"github.com/devgianlu/go-librespot/playplay"
@@ -41,6 +42,8 @@ type App struct {
mpris mpris.Server
logoutCh chan *AppPlayer
+ audioCache *cache.Cache
+
closed bool
}
@@ -127,6 +130,13 @@ func New(opts *Options) (*App, error) {
app.mpris = mpris.DummyServer{}
}
+ if app.cfg.Cache.Enabled && app.cfg.Cache.Dir != "" {
+ app.audioCache, err = cache.New(app.log, app.cfg.Cache.Dir, app.cfg.Cache.SizeLimit)
+ if err != nil {
+ return nil, fmt.Errorf("failed initializing audio cache: %w", err)
+ }
+ }
+
return app, nil
}
@@ -233,6 +243,8 @@ func (app *App) newAppPlayer(ctx context.Context, creds any) (_ *AppPlayer, err
Events: appPlayer.sess.Events(),
Log: app.log,
+ Cache: app.audioCache,
+
FlacEnabled: app.cfg.FlacEnabled,
NormalisationEnabled: !app.cfg.NormalisationDisabled,
diff --git a/daemon/config.go b/daemon/config.go
index e35ad7c5..bf067c4f 100644
--- a/daemon/config.go
+++ b/daemon/config.go
@@ -39,9 +39,23 @@ type Config struct {
// "default", "small", "medium", "large", "xlarge".
ImageSize string
+ Cache CacheConfig
+
Credentials CredentialsConfig
}
+// CacheConfig configures the on-disk cache for downloaded (encrypted) audio
+// files.
+type CacheConfig struct {
+ // Enabled turns the audio file cache on or off.
+ Enabled bool
+ // Dir is the directory the cache is stored in.
+ Dir string
+ // SizeLimit is the maximum total size of the cached audio files in bytes.
+ // A value of zero disables eviction (unbounded cache).
+ SizeLimit int64
+}
+
type CredentialsConfig struct {
Type string
Interactive InteractiveCredentials
diff --git a/player/player.go b/player/player.go
index 16feb606..ac871d35 100644
--- a/player/player.go
+++ b/player/player.go
@@ -11,6 +11,7 @@ import (
librespot "github.com/devgianlu/go-librespot"
"github.com/devgianlu/go-librespot/audio"
+ "github.com/devgianlu/go-librespot/cache"
"github.com/devgianlu/go-librespot/flac"
"github.com/devgianlu/go-librespot/output"
"github.com/devgianlu/go-librespot/playplay"
@@ -51,6 +52,8 @@ type Player struct {
audioKey *audio.KeyProvider
events EventManager
+ cache *cache.Cache
+
cdnQuarantine map[string]time.Time
newOutput func(source librespot.Float32Reader, volume float32) (output.Output, error)
@@ -96,6 +99,10 @@ type Options struct {
Log librespot.Logger
+ // Cache, when non-nil, is used to store and retrieve encrypted audio files
+ // on disk, avoiding a CDN download when a track is played again.
+ Cache *cache.Cache
+
// FlacEnabled specifies if FLAC files should be preferred when available.
// When setting this to true, it is assumed that the PlayPlay plugin is provided.
FlacEnabled bool
@@ -174,6 +181,7 @@ func NewPlayer(opts *Options) (*Player, error) {
sp: opts.Spclient,
audioKey: opts.AudioKey,
events: opts.Events,
+ cache: opts.Cache,
cdnQuarantine: make(map[string]time.Time),
flacEnabled: opts.FlacEnabled,
normalisationEnabled: opts.NormalisationEnabled,
@@ -668,19 +676,59 @@ func (p *Player) NewStream(ctx context.Context, client *http.Client, spotId libr
p.events.PostStreamRequestAudioKey(playbackId)
- storageResolve, err := p.sp.ResolveStorageInteractive(ctx, file.FileId, file.Format, false)
- if err != nil {
- return nil, fmt.Errorf("failed resolving track storage: %w", err)
+ // Prefer a cached copy of the encrypted audio file when available: this
+ // avoids resolving storage and downloading from the CDN entirely. The audio
+ // key is still required to decrypt it below.
+ var rawStream librespot.SizedReadAtSeeker
+
+ // Close the raw stream if stream setup fails before it is handed off to a
+ // Stream (e.g. a cached file that fails to decode); this releases the open
+ // file handle or cancels the in-flight download.
+ streamHandedOff := false
+ defer func() {
+ if !streamHandedOff {
+ if closer, ok := rawStream.(io.Closer); ok && closer != nil {
+ _ = closer.Close()
+ }
+ }
+ }()
+
+ if p.cache != nil {
+ if cached, ok := p.cache.File(file.FileId); ok {
+ log.Debugf("using cached audio file (%d bytes)", cached.Size())
+ rawStream = cached
+ }
}
- p.events.PostStreamResolveStorage(playbackId)
+ if rawStream == nil {
+ storageResolve, err := p.sp.ResolveStorageInteractive(ctx, file.FileId, file.Format, false)
+ if err != nil {
+ return nil, fmt.Errorf("failed resolving track storage: %w", err)
+ }
- rawStream, err := p.httpChunkedReaderFromStorageResolve(log, client, storageResolve)
- if err != nil {
- return nil, fmt.Errorf("failed creating chunked reader: %w", err)
- }
+ p.events.PostStreamResolveStorage(playbackId)
+
+ httpStream, err := p.httpChunkedReaderFromStorageResolve(log, client, storageResolve)
+ if err != nil {
+ return nil, fmt.Errorf("failed creating chunked reader: %w", err)
+ }
+
+ p.events.PostStreamInitHttpChunkReader(playbackId, httpStream)
- p.events.PostStreamInitHttpChunkReader(playbackId, rawStream)
+ // Persist the encrypted file to the cache once it has been fully
+ // downloaded. This is best-effort: caching failures never affect
+ // playback.
+ if p.cache != nil {
+ fileId := file.FileId
+ httpStream.OnComplete(func(r io.ReaderAt, size int64) {
+ if err := p.cache.SaveFile(fileId, io.NewSectionReader(r, 0, size)); err != nil {
+ log.WithError(err).Warnf("failed caching audio file")
+ }
+ })
+ }
+
+ rawStream = httpStream
+ }
decryptedStream, err := audio.NewAesAudioDecryptor(rawStream, audioKey)
if err != nil {
@@ -733,5 +781,6 @@ func (p *Player) NewStream(ctx context.Context, client *http.Client, spotId libr
}
}
+ streamHandedOff = true
return &Stream{PlaybackId: playbackId, RequestedId: requestedId, Source: stream, Media: media, File: file}, nil
}