diff --git a/backend/audiocache.go b/backend/audiocache.go index 48e0af6..d9d9cb8 100644 --- a/backend/audiocache.go +++ b/backend/audiocache.go @@ -26,8 +26,10 @@ type AudioCache struct { } type cacheEntry struct { - done bool - cancel context.CancelFunc + done bool + refCount int + pendingDeletion bool + cancel context.CancelFunc } // AudioCacheRequest represents a request to prefetch and cache an audio file. @@ -62,19 +64,52 @@ func (a *AudioCache) PathForCachedFile(id string) string { return "" } +// IsFullyDownloaded returns true if the file for the given id is fully downloaded. +func (a *AudioCache) IsFullyDownloaded(id string) bool { + return a.PathForCachedFile(id) != "" +} + // PathForCachedFile returns the local filesystem path for a cached track, // including one that is in the process of downloading. // If it is not cached or downloading, it returns an empty string. func (a *AudioCache) PathForCachedOrDownloadingFile(id string) string { + return a.pathForCachedOrDownloadingFile(id, false) +} + +// ObtainReferenceToFile returns the local filesystem path for a cached track, +// including one that is in the process of downloading, and obtains a refernce +// to it such that it will not be deleted until ReleaseReferenceToFile is called. + +// If it is not cached or downloading, it returns an empty string. +func (a *AudioCache) ObtainReferenceToFile(id string) string { + return a.pathForCachedOrDownloadingFile(id, true) +} + +func (a *AudioCache) pathForCachedOrDownloadingFile(id string, obtainReference bool) string { a.mutex.Lock() defer a.mutex.Unlock() - if _, ok := a.entries[id]; ok { + if entry, ok := a.entries[id]; ok { + if obtainReference { + entry.refCount++ + } return a.pathForID(id) } return "" } +func (a *AudioCache) ReleaseReferenceToFile(id string) { + a.mutex.Lock() + defer a.mutex.Unlock() + + if e, ok := a.entries[id]; ok { + e.refCount-- + if e.refCount == 0 && e.pendingDeletion { + a.deleteEntry(id, e) + } + } +} + // 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) { @@ -119,10 +154,11 @@ func (a *AudioCache) CacheOnly(keep string, fetch []AudioCacheRequest) { if id != keep && !slices.ContainsFunc(fetch, func(a AudioCacheRequest) bool { return a.ID == id }) { - _ = e - e.cancel() - _ = os.Remove(a.pathForID(id)) - delete(a.entries, id) + if e.refCount == 0 { + a.deleteEntry(id, e) + } else { + e.pendingDeletion = true + } } } @@ -156,3 +192,9 @@ func (a *AudioCache) Shutdown() { func (a *AudioCache) pathForID(id string) string { return filepath.Join(a.baseCacheDir, id) } + +func (a *AudioCache) deleteEntry(id string, e *cacheEntry) { + e.cancel() + _ = os.Remove(a.pathForID(id)) + delete(a.entries, id) +} diff --git a/backend/playbackmanager.go b/backend/playbackmanager.go index bb1c086..72109f7 100644 --- a/backend/playbackmanager.go +++ b/backend/playbackmanager.go @@ -78,9 +78,9 @@ func NewPlaybackManager( return pm } -func (p *PlaybackManager) findWfmImageJob(id string) (*WaveformImageJob, bool) { +func (p *PlaybackManager) findWfmImageJob(id string, uncanceledOnly bool) (*WaveformImageJob, bool) { for _, j := range p.wfmImageJobs { - if j != nil && j.ItemID == id { + if j != nil && j.ItemID == id && (!uncanceledOnly || !j.Canceled()) { return j, true } } @@ -106,8 +106,10 @@ func (p *PlaybackManager) addOnTrackChangeHook() { p.engine.onBeforeSongChange = append(p.engine.onBeforeSongChange, func(item mediaprovider.MediaItem) { if p.engine.playbackCfg.UseWaveformSeekbar { if p.wfmGen != nil && item != nil && item.Metadata().Type == mediaprovider.MediaItemTypeTrack { - // start generating waveform image for next-up track - p.addWfmImageJob(p.wfmGen.StartWaveformGeneration(item.(*mediaprovider.Track))) + if _, ok := p.findWfmImageJob(item.Metadata().ID, true); !ok { + // start generating waveform image for next-up track + p.addWfmImageJob(p.wfmGen.StartWaveformGeneration(item.(*mediaprovider.Track))) + } } } }) @@ -168,13 +170,15 @@ func (p *PlaybackManager) handleWaveformImageSongChange(item mediaprovider.Media if item != nil { // cancel possible waveform generation job for previous track - if old, ok := p.findWfmImageJob(p.lastPlayingID); ok { - old.Cancel() + if p.lastPlayingID != item.Metadata().ID { + if old, ok := p.findWfmImageJob(p.lastPlayingID, false); ok { + old.Cancel() + } + p.lastPlayingID = item.Metadata().ID } - p.lastPlayingID = item.Metadata().ID var job *WaveformImageJob - if j, ok := p.findWfmImageJob(item.Metadata().ID); ok { + if j, ok := p.findWfmImageJob(item.Metadata().ID, true); ok { job = j } else if tr, ok := item.(*mediaprovider.Track); ok { job = p.wfmGen.StartWaveformGeneration(tr) diff --git a/backend/util/filestreamer.go b/backend/util/filestreamer.go index ac241fa..36e4892 100644 --- a/backend/util/filestreamer.go +++ b/backend/util/filestreamer.go @@ -77,14 +77,25 @@ func (fs *FileStreamerServer) streamHandler(w http.ResponseWriter, _ *http.Reque flusher, canFlush := w.(http.Flusher) + bytesRead := int64(0) buf := make([]byte, 4096) for { complete := fs.IsComplete() + if !complete { + if s, err := os.Stat(fs.Path); err == nil { + // make sure we don't read near EOF until file is complete + maxToRead := max(0, s.Size()-bytesRead-1024) /*safety buffer*/ + buf = buf[:min(int64(cap(buf)), maxToRead)] + } + } else { + buf = buf[:cap(buf)] + } n, err := file.Read(buf) if err != nil && err != io.EOF { log.Printf("read error: %v", err) break } + bytesRead += int64(n) if n > 0 { _, err := w.Write(buf[:n]) diff --git a/backend/waveformimage.go b/backend/waveformimage.go index fbfeac9..e065e0f 100644 --- a/backend/waveformimage.go +++ b/backend/waveformimage.go @@ -48,17 +48,22 @@ type WaveformImageJob struct { progress int // first invalid pixel in X direction done bool cancel func() - - step int + canceled bool } func (w *WaveformImageJob) Cancel() { - if w != nil && w.cancel != nil { - w.step = -1 - w.cancel() + if w != nil { + w.canceled = true + if w.cancel != nil { + w.cancel() + } } } +func (w *WaveformImageJob) Canceled() bool { + return w.canceled +} + func (w *WaveformImageJob) Done() bool { w.lock.Lock() defer w.lock.Unlock() @@ -109,7 +114,7 @@ func (w *WaveformImageGenerator) StartWaveformGeneration(item *mediaprovider.Tra // 3. Begin analyzing the resulting WAV file // 4. Begin generating the image from the analysis data go func() { - path := w.audioCache.PathForCachedOrDownloadingFile(job.ItemID) + path := w.audioCache.ObtainReferenceToFile(job.ItemID) // wait for file to begin downloading if not already for path == "" { time.Sleep(50 * time.Millisecond) @@ -117,7 +122,7 @@ func (w *WaveformImageGenerator) StartWaveformGeneration(item *mediaprovider.Tra job.setError(e) return } - path = w.audioCache.PathForCachedOrDownloadingFile(job.ItemID) + path = w.audioCache.ObtainReferenceToFile(job.ItemID) } // and wait for content to begin being written for { @@ -130,13 +135,22 @@ func (w *WaveformImageGenerator) StartWaveformGeneration(item *mediaprovider.Tra return } } - job.step = 1 dir := filepath.Dir(path) - transcodeFile := filepath.Join(dir, filepath.Base(path)+"_waveform.wav") + var transcodeFile string + for i := 0; true; i++ { + if i > 0 { + transcodeFile = filepath.Join(dir, fmt.Sprintf("%s_waveform_%d.wav", filepath.Base(path), i)) + } else { + transcodeFile = filepath.Join(dir, filepath.Base(path)+"_waveform.wav") + } + if _, err := os.Stat(transcodeFile); os.IsNotExist(err) { + break // found a suitable filename that doesn't exist + } + } fileDone := func() bool { - return w.audioCache.PathForCachedFile(job.ItemID) != "" + return w.audioCache.IsFullyDownloaded(job.ItemID) } // If file isn't fully downloaded from server, @@ -158,8 +172,7 @@ func (w *WaveformImageGenerator) StartWaveformGeneration(item *mediaprovider.Tra // Start converting the file to WAV for analysis var wavConvertDone bool go func() { - job.step = 2 - err := convertToWav(ctx, path, transcodeFile) + err := w.convertToWav(ctx, job.ItemID, path, transcodeFile) wavConvertDone = true if err != nil { job.setError(err) @@ -177,7 +190,6 @@ func (w *WaveformImageGenerator) StartWaveformGeneration(item *mediaprovider.Tra return } } - job.step = 3 // Start analyzing the converted wav file data := &waveformData{} @@ -392,7 +404,7 @@ func float64ToByte(val float64) byte { return byte(val * 255) } -func convertToWav(ctx context.Context, inPath, outPath string) error { +func (w *WaveformImageGenerator) convertToWav(ctx context.Context, id, inPath, outPath string) error { m := mpv.Create() m.SetOptionString("video", "no") m.SetOptionString("audio-display", "no") @@ -413,6 +425,7 @@ func convertToWav(ctx context.Context, inPath, outPath string) error { defer m.TerminateDestroy() m.Command([]string{"loadfile", inPath, "replace"}) + defer w.audioCache.ReleaseReferenceToFile(id) // Wait for MPV idle or ctx expiry for {