pre-cache next two files in queue; TODO - use them
This commit is contained in:
+1
-1
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
+44
-17
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user