diff --git a/backend/app.go b/backend/app.go index af3af30..5173a4f 100644 --- a/backend/app.go +++ b/backend/app.go @@ -149,7 +149,7 @@ func StartupApp(appName, displayAppName, appVersion, appVersionTag, latestReleas log.Printf("failed to create audio cache: %s", err.Error()) } a.AudioCache = ac - a.PlaybackManager = NewPlaybackManager(a.bgrndCtx, a.ServerManager, a.LocalPlayer, &a.Config.Playback, &a.Config.Scrobbling, &a.Config.Transcoding, &a.Config.Application) + a.PlaybackManager = NewPlaybackManager(a.bgrndCtx, a.ServerManager, a.AudioCache, a.LocalPlayer, &a.Config.Playback, &a.Config.Scrobbling, &a.Config.Transcoding, &a.Config.Application) a.Config.Application.MaxImageCacheSizeMB = clamp(a.Config.Application.MaxImageCacheSizeMB, 1, 500) a.ImageManager.SetMaxOnDiskCacheSizeBytes(int64(a.Config.Application.MaxImageCacheSizeMB) * 1_048_576) a.ServerManager.SetPrefetchAlbumCoverCallback(func(coverID string) { diff --git a/backend/audiocache.go b/backend/audiocache.go new file mode 100644 index 0000000..965d14d --- /dev/null +++ b/backend/audiocache.go @@ -0,0 +1,135 @@ +package backend + +import ( + "context" + "errors" + "log" + "os" + "path/filepath" + "slices" + "sync" + + "github.com/20after4/configdir" + "github.com/dweymouth/supersonic/sharedutil" +) + +// AudioCache manages temporary local storage of audio files fetched from the remote music server. +// It prefetches and stores tracks on disk based on an upcoming play queue. +type AudioCache struct { + mutex sync.Mutex + + s *ServerManager + rootCtx context.Context + baseCacheDir string + + entries map[string]*cacheEntry +} + +type cacheEntry struct { + done bool + cancel context.CancelFunc +} + +// AudioCacheRequest represents a request to prefetch and cache an audio file. +type AudioCacheRequest struct { + ID string + DownloadURL string +} + +// NewAudioCache initializes an AudioCache using the given context, server manager, +// and local filesystem directory for storing audio files. +func NewAudioCache(ctx context.Context, s *ServerManager, baseCacheDir string) (*AudioCache, error) { + if err := configdir.MakePath(baseCacheDir); err != nil { + return nil, errors.New("failed to create audio cache dir") + } + return &AudioCache{ + s: s, + rootCtx: ctx, + baseCacheDir: baseCacheDir, + entries: make(map[string]*cacheEntry), + }, nil +} + +// PathForCachedFile returns the local filesystem path for a cached track, +// if the file has finished downloading. If not cached, it returns an empty string. +func (a *AudioCache) PathForCachedFile(id string) string { + if item, ok := a.entries[id]; ok && item.done { + return a.pathForID(id) + } + return "" +} + +// cacheFile begins downloading a file (if not already downloading) and stores it +// to the cache directory under its ID as filename. The download is asynchronous. +func (a *AudioCache) cacheFile(id, dlURL string) { + s := a.s.Server + if s == nil { + return + } + + if _, ok := a.entries[id]; !ok { + ctx, cancel := context.WithCancel(a.rootCtx) + a.entries[id] = &cacheEntry{cancel: cancel} + go func() { + ok, err := sharedutil.DownloadFileWithContext(ctx, dlURL, a.pathForID(id)) + if ok { + a.mutex.Lock() + if e, ok := a.entries[id]; ok { + e.done = true + } + a.mutex.Unlock() + } else if err != context.DeadlineExceeded { + log.Printf("error downloading audio file: %v", err) + } + cancel() // release ctx resources when done + }() + } +} + +// CacheOnly ensures that only the given 'fetch' list of files (plus one extra 'keep' ID) remain cached. +// Any other cached files are cancelled and deleted from disk. +func (a *AudioCache) CacheOnly(keep string, fetch []AudioCacheRequest) { + a.mutex.Lock() + defer a.mutex.Unlock() + + // delete files we're not keeping + for id, e := range a.entries { + if id != keep && !slices.ContainsFunc(fetch, func(a AudioCacheRequest) bool { + return a.ID == id + }) { + e.cancel() + _ = os.Remove(a.pathForID(id)) + delete(a.entries, id) + } + } + + // start caching the ones from fetch if not already present + for _, item := range fetch { + if _, ok := a.entries[item.ID]; !ok { + a.cacheFile(item.ID, item.DownloadURL) + } + } +} + +// Shutdown cancels all in-progress downloads and deletes all files in the audio cache directory. +// This should be called during application shutdown to clean up temporary audio data. +func (a *AudioCache) Shutdown() { + a.mutex.Lock() + // Cancel all active downloads + for _, entry := range a.entries { + if entry.cancel != nil { + entry.cancel() + } + } + a.entries = nil // clear cache state + a.mutex.Unlock() + + // Remove all files in the cache directory + if a.baseCacheDir != "" { + _ = os.RemoveAll(a.baseCacheDir) + } +} + +func (a *AudioCache) pathForID(id string) string { + return filepath.Join(a.baseCacheDir, id) +} diff --git a/backend/playbackengine.go b/backend/playbackengine.go index fa54680..cad0e6d 100644 --- a/backend/playbackengine.go +++ b/backend/playbackengine.go @@ -52,6 +52,7 @@ type playbackEngine struct { ctx context.Context cancelPollPos context.CancelFunc sm *ServerManager + audiocache *AudioCache player player.BasePlayer playTimeStopwatch util.Stopwatch @@ -98,6 +99,7 @@ type playbackEngine struct { func NewPlaybackEngine( ctx context.Context, s *ServerManager, + c *AudioCache, p player.BasePlayer, playbackCfg *PlaybackConfig, scrobbleCfg *ScrobbleConfig, @@ -108,6 +110,7 @@ func NewPlaybackEngine( pm := &playbackEngine{ ctx: ctx, sm: s, + audiocache: c, player: p, scrobbleCfg: scrobbleCfg, transcodeCfg: transcodeCfg, @@ -555,6 +558,25 @@ func (p *playbackEngine) SetReplayGainMode(mode player.ReplayGainMode) { }) } +func (p *playbackEngine) cacheNextTracks() { + if p.audiocache != nil { + // fetch up to the 2 next tracks in the queue to the cache + fetch := make([]AudioCacheRequest, 0, 2) + for _, idx := range [2]int{p.nowPlayingIdx + 1, p.nowPlayingIdx + 2} { + if idx < len(p.playQueue) { + item := p.playQueue[idx] + if item.Metadata().Type == mediaprovider.MediaItemTypeTrack { + fetch = append(fetch, AudioCacheRequest{ + ID: p.playQueue[idx].Metadata().ID, + DownloadURL: p.getMediaURLForIdx(idx), + }) + } + } + } + p.audiocache.CacheOnly(p.NowPlaying().Metadata().ID, fetch) + } +} + func (p *playbackEngine) handleOnTrackChange() { // scrobble the previous song if needed if !p.alreadyScrobbled { @@ -585,6 +607,7 @@ func (p *playbackEngine) handleOnTrackChange() { p.sendNowPlayingScrobble() // Must come before invokeOnChangeCallbacks b/c track may immediately be scrobbled p.invokeOnSongChangeCallbacks() p.doUpdateTimePos(false) + p.cacheNextTracks() p.setNextTrackBasedOnLoopMode(false) } @@ -648,23 +671,9 @@ func (p *playbackEngine) setTrack(idx int, next bool, startTime float64) error { url := "" var meta mediaprovider.MediaItemMetadata if idx >= 0 { - var err error - item := p.playQueue[idx] - meta = item.Metadata() - if tr, ok := item.(*mediaprovider.Track); ok { - var ts *mediaprovider.TranscodeSettings - if p.transcodeCfg.RequestTranscode { - ts = &mediaprovider.TranscodeSettings{ - Codec: p.transcodeCfg.Codec, - BitRateKBPS: p.transcodeCfg.MaxBitRateKBPS, - } - } - url, err = p.sm.Server.GetStreamURL(tr.ID, ts, p.transcodeCfg.ForceRawFile) - } else { - url = item.(*mediaprovider.RadioStation).StreamURL - } - if err != nil { - return err + url = p.getMediaURLForIdx(idx) + if url == "" { + return errors.New("no stream URL") } } if next { @@ -687,6 +696,24 @@ func (p *playbackEngine) setTrack(idx int, next bool, startTime float64) error { panic("Unsupported player type") } +func (p *playbackEngine) getMediaURLForIdx(idx int) string { + var url string + item := p.playQueue[idx] + if tr, ok := item.(*mediaprovider.Track); ok { + var ts *mediaprovider.TranscodeSettings + if p.transcodeCfg.RequestTranscode { + ts = &mediaprovider.TranscodeSettings{ + Codec: p.transcodeCfg.Codec, + BitRateKBPS: p.transcodeCfg.MaxBitRateKBPS, + } + } + url, _ = p.sm.Server.GetStreamURL(tr.ID, ts, p.transcodeCfg.ForceRawFile) + } else { + url = item.(*mediaprovider.RadioStation).StreamURL + } + return url +} + func (p *playbackEngine) setNextTrack(idx int) error { return p.setTrack(idx, true, 0) } diff --git a/backend/playbackmanager.go b/backend/playbackmanager.go index 153760f..c331747 100644 --- a/backend/playbackmanager.go +++ b/backend/playbackmanager.go @@ -46,13 +46,14 @@ type RemotePlaybackDevice struct { func NewPlaybackManager( ctx context.Context, s *ServerManager, + c *AudioCache, p player.BasePlayer, playbackCfg *PlaybackConfig, scrobbleCfg *ScrobbleConfig, transcodeCfg *TranscodingConfig, appCfg *AppConfig, ) *PlaybackManager { - e := NewPlaybackEngine(ctx, s, p, playbackCfg, scrobbleCfg, transcodeCfg) + e := NewPlaybackEngine(ctx, s, c, p, playbackCfg, scrobbleCfg, transcodeCfg) q := NewCommandQueue() pm := &PlaybackManager{ engine: e,