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 }