From 5e6b3b30927ac07e5e5b2caefd5a17026c528313 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 5 Jul 2026 13:33:48 +0000 Subject: [PATCH 1/3] feat: cache encrypted audio files on disk Port librespot's on-disk audio cache to go-librespot. Encrypted audio files downloaded from the CDN are stored on disk keyed by their Spotify file id (hex, two-character subdirectory), so replaying a track skips the storage-resolve and CDN download entirely. Only the still-encrypted bytes are stored, mirroring librespot: a cached file is useless without the audio key retrieved on every playback. The cache is enabled by default with a 1 GB size limit, after which the least-recently-used files are evicted. Configurable via cache.enabled, cache.dir and cache.size_limit. - new cache package: Cache (File/SaveFile, atomic writes) plus an LRU size limiter that reflects on-disk state across restarts - HttpChunkedReader gains an OnComplete hook that fires once every chunk is downloaded, used to persist the complete encrypted file - player.NewStream reads cache-first and saves on completion; the raw stream is closed if setup fails before hand-off Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_019FHWG4ossSBydy7jbVFWho --- README.md | 14 ++++ audio/chunked-reader.go | 43 +++++++++++ cache/cache.go | 141 ++++++++++++++++++++++++++++++++++ cache/cache_test.go | 107 ++++++++++++++++++++++++++ cache/limiter.go | 114 +++++++++++++++++++++++++++ cache/limiter_test.go | 80 +++++++++++++++++++ cmd/daemon/cli_config.go | 60 +++++++++++++++ cmd/daemon/cli_config_test.go | 42 ++++++++++ config_schema.json | 21 +++++ daemon/app.go | 12 +++ daemon/config.go | 14 ++++ player/player.go | 67 +++++++++++++--- 12 files changed, 706 insertions(+), 9 deletions(-) create mode 100644 cache/cache.go create mode 100644 cache/cache_test.go create mode 100644 cache/limiter.go create mode 100644 cache/limiter_test.go create mode 100644 cmd/daemon/cli_config_test.go diff --git a/README.md b/README.md index 5cee6808..0f8d6c7f 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 are 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 enabled by default with a 1 GB limit, after which the least-recently-used +files are evicted. + +```yaml +cache: + enabled: true # Whether to cache downloaded audio files (default: true) + dir: '' # Directory for cached files (default: a 'cache' subdirectory of the config directory) + 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..0aaf1e2b --- /dev/null +++ b/cache/cache.go @@ -0,0 +1,141 @@ +// 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" + + 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) + } + + 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..da2cd93c --- /dev/null +++ b/cache/limiter_test.go @@ -0,0 +1,80 @@ +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 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..b69624f1 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,13 @@ 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 == "" { + 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 +195,9 @@ func loadCLIConfig(cfg *cliConfig) error { "credentials.type": "zeroconf", + "cache.enabled": true, + "cache.size_limit": "1GB", + "zeroconf_backend": "builtin", "server.address": "localhost", @@ -236,5 +253,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..1ac33041 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": true + }, + "dir": { + "type": "string", + "description": "Directory to store cached audio files (defaults to a 'cache' subdirectory of the config directory)", + "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 } From fa61e5c7d4154e2d6c91d718968ab9c72d05fca8 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 5 Jul 2026 13:55:23 +0000 Subject: [PATCH 2/3] fix: persist cache access time to disk so LRU order survives restarts The size limiter seeds recency from file modification times on startup, so a purely in-memory touch meant the LRU order after a restart reflected write time rather than last access. Update the on-disk timestamps when a cached file is read, matching librespot's filetime-based touch. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_019FHWG4ossSBydy7jbVFWho --- cache/cache.go | 7 +++++++ cache/limiter_test.go | 33 +++++++++++++++++++++++++++++++++ 2 files changed, 40 insertions(+) diff --git a/cache/cache.go b/cache/cache.go index 0aaf1e2b..ed75a132 100644 --- a/cache/cache.go +++ b/cache/cache.go @@ -13,6 +13,7 @@ import ( "io" "os" "path/filepath" + "time" librespot "github.com/devgianlu/go-librespot" ) @@ -83,6 +84,12 @@ func (c *Cache) File(fileId []byte) (librespot.SizedReadAtSeeker, bool) { 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 } diff --git a/cache/limiter_test.go b/cache/limiter_test.go index da2cd93c..41d3d05d 100644 --- a/cache/limiter_test.go +++ b/cache/limiter_test.go @@ -44,6 +44,39 @@ func TestEvictsLeastRecentlyUsed(t *testing.T) { 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) From 164b18da9ced1c2b41c39f9ae1557e642f1ba7fa Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 19 Jul 2026 06:34:10 +0000 Subject: [PATCH 3/3] =?UTF-8?q?refactor:=20address=20review=20=E2=80=94=20?= =?UTF-8?q?disable=20cache=20by=20default,=20use=20XDG=20cache=20dir?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per upstream review on PR #327: - default cache.enabled to false so existing installs are unaffected - default the cache directory to the XDG cache location via os.UserCacheDir() ($XDG_CACHE_HOME, falling back to $HOME/.cache), matching the reference librespot behaviour Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_019FHWG4ossSBydy7jbVFWho --- README.md | 10 +++++----- cmd/daemon/cli_config.go | 10 ++++++++-- config_schema.json | 4 ++-- 3 files changed, 15 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index 0f8d6c7f..e8f3eb48 100644 --- a/README.md +++ b/README.md @@ -160,15 +160,15 @@ For detailed API documentation see [here](/API.md). ### Audio cache -Downloaded audio files are cached on disk so that replaying a track does not download it again from the CDN. Only the +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 enabled by default with a 1 GB limit, after which the least-recently-used -files are evicted. +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: true # Whether to cache downloaded audio files (default: true) - dir: '' # Directory for cached files (default: a 'cache' subdirectory of the config directory) + 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) ``` diff --git a/cmd/daemon/cli_config.go b/cmd/daemon/cli_config.go index b69624f1..6a5ec777 100644 --- a/cmd/daemon/cli_config.go +++ b/cmd/daemon/cli_config.go @@ -133,7 +133,13 @@ func (c *cliConfig) toDaemonConfig() *daemon.Config { dc.Cache.Enabled = c.Cache.Enabled dc.Cache.Dir = c.Cache.Dir if dc.Cache.Dir == "" { - dc.Cache.Dir = filepath.Join(c.ConfigDir, "cache") + // 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) @@ -195,7 +201,7 @@ func loadCLIConfig(cfg *cliConfig) error { "credentials.type": "zeroconf", - "cache.enabled": true, + "cache.enabled": false, "cache.size_limit": "1GB", "zeroconf_backend": "builtin", diff --git a/config_schema.json b/config_schema.json index 1ac33041..a840072a 100644 --- a/config_schema.json +++ b/config_schema.json @@ -172,11 +172,11 @@ "enabled": { "type": "boolean", "description": "Whether to cache downloaded audio files on disk to avoid re-downloading them from the CDN", - "default": true + "default": false }, "dir": { "type": "string", - "description": "Directory to store cached audio files (defaults to a 'cache' subdirectory of the config directory)", + "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": {