Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
43 changes: 43 additions & 0 deletions audio/chunked-reader.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down Expand Up @@ -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
}
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
}
Expand Down
148 changes: 148 additions & 0 deletions cache/cache.go
Original file line number Diff line number Diff line change
@@ -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 <dir>/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 }
107 changes: 107 additions & 0 deletions cache/cache_test.go
Original file line number Diff line number Diff line change
@@ -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())
}
Loading
Loading