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())
|
log.Printf("failed to create audio cache: %s", err.Error())
|
||||||
}
|
}
|
||||||
a.AudioCache = ac
|
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.Config.Application.MaxImageCacheSizeMB = clamp(a.Config.Application.MaxImageCacheSizeMB, 1, 500)
|
||||||
a.ImageManager.SetMaxOnDiskCacheSizeBytes(int64(a.Config.Application.MaxImageCacheSizeMB) * 1_048_576)
|
a.ImageManager.SetMaxOnDiskCacheSizeBytes(int64(a.Config.Application.MaxImageCacheSizeMB) * 1_048_576)
|
||||||
a.ServerManager.SetPrefetchAlbumCoverCallback(func(coverID string) {
|
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
|
ctx context.Context
|
||||||
cancelPollPos context.CancelFunc
|
cancelPollPos context.CancelFunc
|
||||||
sm *ServerManager
|
sm *ServerManager
|
||||||
|
audiocache *AudioCache
|
||||||
player player.BasePlayer
|
player player.BasePlayer
|
||||||
|
|
||||||
playTimeStopwatch util.Stopwatch
|
playTimeStopwatch util.Stopwatch
|
||||||
@@ -98,6 +99,7 @@ type playbackEngine struct {
|
|||||||
func NewPlaybackEngine(
|
func NewPlaybackEngine(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
s *ServerManager,
|
s *ServerManager,
|
||||||
|
c *AudioCache,
|
||||||
p player.BasePlayer,
|
p player.BasePlayer,
|
||||||
playbackCfg *PlaybackConfig,
|
playbackCfg *PlaybackConfig,
|
||||||
scrobbleCfg *ScrobbleConfig,
|
scrobbleCfg *ScrobbleConfig,
|
||||||
@@ -108,6 +110,7 @@ func NewPlaybackEngine(
|
|||||||
pm := &playbackEngine{
|
pm := &playbackEngine{
|
||||||
ctx: ctx,
|
ctx: ctx,
|
||||||
sm: s,
|
sm: s,
|
||||||
|
audiocache: c,
|
||||||
player: p,
|
player: p,
|
||||||
scrobbleCfg: scrobbleCfg,
|
scrobbleCfg: scrobbleCfg,
|
||||||
transcodeCfg: transcodeCfg,
|
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() {
|
func (p *playbackEngine) handleOnTrackChange() {
|
||||||
// scrobble the previous song if needed
|
// scrobble the previous song if needed
|
||||||
if !p.alreadyScrobbled {
|
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.sendNowPlayingScrobble() // Must come before invokeOnChangeCallbacks b/c track may immediately be scrobbled
|
||||||
p.invokeOnSongChangeCallbacks()
|
p.invokeOnSongChangeCallbacks()
|
||||||
p.doUpdateTimePos(false)
|
p.doUpdateTimePos(false)
|
||||||
|
p.cacheNextTracks()
|
||||||
p.setNextTrackBasedOnLoopMode(false)
|
p.setNextTrackBasedOnLoopMode(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -648,23 +671,9 @@ func (p *playbackEngine) setTrack(idx int, next bool, startTime float64) error {
|
|||||||
url := ""
|
url := ""
|
||||||
var meta mediaprovider.MediaItemMetadata
|
var meta mediaprovider.MediaItemMetadata
|
||||||
if idx >= 0 {
|
if idx >= 0 {
|
||||||
var err error
|
url = p.getMediaURLForIdx(idx)
|
||||||
item := p.playQueue[idx]
|
if url == "" {
|
||||||
meta = item.Metadata()
|
return errors.New("no stream URL")
|
||||||
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
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if next {
|
if next {
|
||||||
@@ -687,6 +696,24 @@ func (p *playbackEngine) setTrack(idx int, next bool, startTime float64) error {
|
|||||||
panic("Unsupported player type")
|
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 {
|
func (p *playbackEngine) setNextTrack(idx int) error {
|
||||||
return p.setTrack(idx, true, 0)
|
return p.setTrack(idx, true, 0)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -46,13 +46,14 @@ type RemotePlaybackDevice struct {
|
|||||||
func NewPlaybackManager(
|
func NewPlaybackManager(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
s *ServerManager,
|
s *ServerManager,
|
||||||
|
c *AudioCache,
|
||||||
p player.BasePlayer,
|
p player.BasePlayer,
|
||||||
playbackCfg *PlaybackConfig,
|
playbackCfg *PlaybackConfig,
|
||||||
scrobbleCfg *ScrobbleConfig,
|
scrobbleCfg *ScrobbleConfig,
|
||||||
transcodeCfg *TranscodingConfig,
|
transcodeCfg *TranscodingConfig,
|
||||||
appCfg *AppConfig,
|
appCfg *AppConfig,
|
||||||
) *PlaybackManager {
|
) *PlaybackManager {
|
||||||
e := NewPlaybackEngine(ctx, s, p, playbackCfg, scrobbleCfg, transcodeCfg)
|
e := NewPlaybackEngine(ctx, s, c, p, playbackCfg, scrobbleCfg, transcodeCfg)
|
||||||
q := NewCommandQueue()
|
q := NewCommandQueue()
|
||||||
pm := &PlaybackManager{
|
pm := &PlaybackManager{
|
||||||
engine: e,
|
engine: e,
|
||||||
|
|||||||
Reference in New Issue
Block a user