From 867461dab56691c5b480193f4de7354f9936c1cb Mon Sep 17 00:00:00 2001 From: Drew Weymouth Date: Thu, 2 Feb 2023 15:52:59 -0800 Subject: [PATCH] remove gcache dependency, switch to custom image cache with TTL for memory savings --- backend/albumiterator.go | 18 +------- backend/app.go | 2 +- backend/imagecache.go | 98 ++++++++++++++++++++++++++++++---------- backend/imagemanager.go | 81 +++++++++++++++++++-------------- go.mod | 1 - go.sum | 2 - ui/bottompanel.go | 8 +++- 7 files changed, 132 insertions(+), 78 deletions(-) diff --git a/backend/albumiterator.go b/backend/albumiterator.go index 6ef2c76..9b163f3 100644 --- a/backend/albumiterator.go +++ b/backend/albumiterator.go @@ -4,7 +4,6 @@ import ( "log" "strconv" - "github.com/bluele/gcache" subsonic "github.com/dweymouth/go-subsonic" ) @@ -15,15 +14,12 @@ type AlbumIterator interface { type LibraryManager struct { PreCacheCoverFn func(string) - s *ServerManager - albumDetailCache gcache.Cache + s *ServerManager } func NewLibraryManager(s *ServerManager) *LibraryManager { - cache := gcache.New(250).LRU().Build() return &LibraryManager{ - s: s, - albumDetailCache: cache, + s: s, } } @@ -85,21 +81,11 @@ func (l *LibraryManager) SearchIterWithFilter(query string, filter func(*subsoni return l.newSearchIter(query, filter) } -func (l *LibraryManager) CacheAlbum(a *subsonic.AlbumID3) { - l.albumDetailCache.Set(a.ID, a) -} - func (l *LibraryManager) GetAlbum(id string) (*subsonic.AlbumID3, error) { - if l.albumDetailCache.Has(id) { - if a, err := l.albumDetailCache.Get(id); err == nil { - return a.(*subsonic.AlbumID3), nil - } - } a, err := l.s.Server.GetAlbum(id) if err != nil { return nil, err } - l.albumDetailCache.Set(a.ID, a) return a, nil } diff --git a/backend/app.go b/backend/app.go index 4d3713e..285d7c7 100644 --- a/backend/app.go +++ b/backend/app.go @@ -50,7 +50,7 @@ func StartupApp() (*App, error) { a.ServerManager = NewServerManager() a.PlaybackManager = NewPlaybackManager(a.bgrndCtx, a.ServerManager, a.Player) a.LibraryManager = NewLibraryManager(a.ServerManager) - a.ImageManager = NewImageManager(a.ServerManager, configdir.LocalCache(AppName)) + a.ImageManager = NewImageManager(a.bgrndCtx, a.ServerManager, configdir.LocalCache(AppName)) a.LibraryManager.PreCacheCoverFn = func(albumID string) { _, _ = a.ImageManager.GetAlbumThumbnail(albumID) } diff --git a/backend/imagecache.go b/backend/imagecache.go index 45160cf..23f117f 100644 --- a/backend/imagecache.go +++ b/backend/imagecache.go @@ -1,6 +1,7 @@ package backend import ( + "container/heap" "context" "errors" "image" @@ -17,7 +18,7 @@ type CacheItem struct { lastAccessed int64 } -// A custom cache for images with the following eviction strategy: +// A custom in-memory cache for images with the following eviction strategy: // 1. If there are fewer than MinSize items in the cache, none will be evicted // 2. If a new addition would make the cache exceed MaxSize, an item will be immediately evicted // 2a. in this case, evict the LRU expired item or if none expired, the LRU item @@ -36,12 +37,13 @@ var ( ErrNotFound = errors.New("item not found") ) -func (i *ImageCache) Init(ctx context.Context) { +func (i *ImageCache) Init(ctx context.Context, evictionInterval time.Duration) { i.cache = make(map[string]CacheItem) + go i.periodicallyEvict(ctx, evictionInterval) } // holds writer lock for O(i.MaxSize) worst case -func (i *ImageCache) AddWithTTL(key string, val image.Image, ttl time.Duration) { +func (i *ImageCache) SetWithTTL(key string, val image.Image, ttl time.Duration) { i.mu.Lock() defer i.mu.Unlock() @@ -54,17 +56,17 @@ func (i *ImageCache) AddWithTTL(key string, val image.Image, ttl time.Duration) } if len(i.cache) == i.MaxSize { i.evictOne() - i.cache[key] = CacheItem{ - val: val, - ttl: ttl, - expiresAt: time.Now().Add(ttl).Unix(), - lastAccessed: time.Now().Unix(), - } + } + i.cache[key] = CacheItem{ + val: val, + ttl: ttl, + expiresAt: time.Now().Add(ttl).Unix(), + lastAccessed: time.Now().Unix(), } } -func (i *ImageCache) Add(key string, val image.Image) { - i.AddWithTTL(key, val, i.DefaultTTL) +func (i *ImageCache) Set(key string, val image.Image) { + i.SetWithTTL(key, val, i.DefaultTTL) } func (i *ImageCache) Has(key string) bool { @@ -75,7 +77,11 @@ func (i *ImageCache) Has(key string) bool { return ok } -func (i *ImageCache) Get(key string, resetTTL bool) (image.Image, error) { +func (i *ImageCache) Get(key string) (image.Image, error) { + return i.GetResetTTL(key, false) +} + +func (i *ImageCache) GetResetTTL(key string, resetTTL bool) (image.Image, error) { i.mu.RLock() defer i.mu.RUnlock() @@ -128,15 +134,56 @@ func (i *ImageCache) evictOne() { } } +func (i *ImageCache) periodicallyEvict(ctx context.Context, interval time.Duration) { + t := time.NewTicker(interval) + for { + select { + case <-ctx.Done(): + t.Stop() + return + case <-t.C: + i.EvictExpired() + } + } +} + type expiredItem struct { key string lastAccessed int64 } -// TODO +type expiredHeap []expiredItem + +func (h expiredHeap) Len() int { return len(h) } +func (h expiredHeap) Less(i, j int) bool { return h[i].lastAccessed < h[j].lastAccessed } +func (h expiredHeap) Swap(i, j int) { h[i], h[j] = h[j], h[i] } + +func (h *expiredHeap) Push(x any) { + // Push and Pop use pointer receivers because they modify the slice's length, + // not just its contents. + *h = append(*h, x.(expiredItem)) +} + +func (h *expiredHeap) Pop() any { + old := *h + n := len(old) + x := old[n-1] + *h = old[0 : n-1] + return x +} + +// EvictExpired evicts least recently used expired items from the cache +// until there are no more expired items or the cache contains MinSize elements +// Holds the reader lock for O(n) time and writer lock for O(n) func (i *ImageCache) EvictExpired() { i.mu.RLock() - expired := make([]expiredItem, 0, len(i.cache)-i.MinSize) + count := len(i.cache) + sliceCap := count - i.MinSize + if sliceCap <= 0 { + i.mu.RUnlock() + return + } + expired := make(expiredHeap, 0, sliceCap) now := time.Now().Unix() for k, v := range i.cache { if v.expiresAt < now { @@ -145,16 +192,21 @@ func (i *ImageCache) EvictExpired() { } i.mu.RUnlock() - count := len(i.cache) - for count > i.MinSize { + heap.Init(&expired) + var keysToRemove []string + for count > i.MinSize && len(expired) > 0 { + keysToRemove = append(keysToRemove, heap.Pop(&expired).(expiredItem).key) + count -= 1 + } + i.mu.Lock() + defer i.mu.Unlock() + for _, key := range keysToRemove { + // during the interim when we don't hold the lock, some expired items + // could have been re-set, so check expiry again + if item, ok := i.cache[key]; ok && item.expiresAt < now { + delete(i.cache, key) + } } } - -func heapify(arr *[]expiredItem, i int) { - //smallest := i - //lChild := 2*i + 1 - //rChild := 2*i + 2 - -} diff --git a/backend/imagemanager.go b/backend/imagemanager.go index 3858efe..2f9d068 100644 --- a/backend/imagemanager.go +++ b/backend/imagemanager.go @@ -1,6 +1,7 @@ package backend import ( + "context" "fmt" "image" "image/jpeg" @@ -11,62 +12,55 @@ import ( "time" "github.com/20after4/configdir" - "github.com/bluele/gcache" ) type ImageManager struct { s *ServerManager baseCacheDir string - thumbnailCache gcache.Cache + thumbnailCache ImageCache cachedFullSizeCover image.Image cachedFullSizeCoverID string } -func NewImageManager(s *ServerManager, baseCacheDir string) *ImageManager { - cache := gcache.New(100).LRU().Build() +func NewImageManager(ctx context.Context, s *ServerManager, baseCacheDir string) *ImageManager { if err := configdir.MakePath(baseCacheDir); err != nil { log.Println("failed to create album cover cache dir") baseCacheDir = "" } - return &ImageManager{ - s: s, - baseCacheDir: baseCacheDir, - thumbnailCache: cache, + i := &ImageManager{ + s: s, + baseCacheDir: baseCacheDir, + thumbnailCache: ImageCache{ + MinSize: 24, + MaxSize: 150, + DefaultTTL: 1 * time.Minute, + }, } + i.thumbnailCache.Init(ctx, 2*time.Minute) + return i } func (i *ImageManager) GetAlbumThumbnailFromCache(albumID string) (image.Image, bool) { - if img, err := i.thumbnailCache.Get(albumID); err == nil && img != nil { - return img.(image.Image), true + if img, err := i.thumbnailCache.GetResetTTL(albumID, true); err == nil && img != nil { + return img, true } return nil, false } func (i *ImageManager) GetAlbumThumbnail(albumID string) (image.Image, error) { + if im, ok := i.GetAlbumThumbnailFromCache(albumID); ok { + return im, nil + } + return i.fetchAndCacheCoverFromDiskOrServer(albumID, i.thumbnailCache.DefaultTTL) +} + +func (i *ImageManager) GetAlbumThumbnailWithTTL(albumID string, ttl time.Duration) (image.Image, error) { // in-memory cache - if i.thumbnailCache.Has(albumID) { - if img, err := i.thumbnailCache.Get(albumID); err == nil { - return img.(image.Image), nil - } + if img, err := i.thumbnailCache.GetWithNewTTL(albumID, ttl); err == nil { + return img, nil } - - // on disc cache - path := i.filePathForCover(albumID) - if i.ensureCoverCacheDir() != "" { - if s, err := os.Stat(path); err == nil { - go i.checkRefreshLocalCover(s, albumID) - if f, err := os.Open(path); err == nil { - defer f.Close() - if img, _, err := image.Decode(f); err == nil { - i.thumbnailCache.Set(albumID, img) - return img, nil - } - } - } - } - - return i.fetchAndCacheCoverFromServer(albumID) + return i.fetchAndCacheCoverFromDiskOrServer(albumID, ttl) } func (i *ImageManager) GetFullSizeAlbumCover(albumID string) (image.Image, error) { @@ -88,7 +82,26 @@ func (i *ImageManager) ensureCoverCacheDir() string { return path } -func (i *ImageManager) fetchAndCacheCoverFromServer(albumID string) (image.Image, error) { +func (i *ImageManager) fetchAndCacheCoverFromDiskOrServer(albumID string, ttl time.Duration) (image.Image, error) { + // on disc cache + path := i.filePathForCover(albumID) + if i.ensureCoverCacheDir() != "" { + if s, err := os.Stat(path); err == nil { + go i.checkRefreshLocalCover(s, albumID) + if f, err := os.Open(path); err == nil { + defer f.Close() + if img, _, err := image.Decode(f); err == nil { + i.thumbnailCache.SetWithTTL(albumID, img, ttl) + return img, nil + } + } + } + } + + return i.fetchAndCacheCoverFromServer(albumID, ttl) +} + +func (i *ImageManager) fetchAndCacheCoverFromServer(albumID string, ttl time.Duration) (image.Image, error) { img, err := i.s.Server.GetCoverArt(albumID, map[string]string{"size": "300"}) if err != nil { return nil, err @@ -107,8 +120,8 @@ func (i *ImageManager) fetchAndCacheCoverFromServer(albumID string) (image.Image } func (i *ImageManager) checkRefreshLocalCover(stat os.FileInfo, albumID string) { - if time.Now().Sub(stat.ModTime()) > 24*time.Hour { - i.fetchAndCacheCoverFromServer(albumID) + if time.Since(stat.ModTime()) > 24*time.Hour { + i.fetchAndCacheCoverFromServer(albumID, i.thumbnailCache.DefaultTTL) } } diff --git a/go.mod b/go.mod index 8d5a62d..67534a0 100644 --- a/go.mod +++ b/go.mod @@ -5,7 +5,6 @@ go 1.19 require ( fyne.io/fyne/v2 v2.2.4 github.com/20after4/configdir v0.1.1 - github.com/bluele/gcache v0.0.2 github.com/dweymouth/go-subsonic v0.0.0-20221214005741-bd8048fa1863 github.com/google/uuid v1.3.0 github.com/pelletier/go-toml v1.9.3 diff --git a/go.sum b/go.sum index d3de868..9145ccc 100644 --- a/go.sum +++ b/go.sum @@ -53,8 +53,6 @@ github.com/armon/go-metrics v0.0.0-20180917152333-f0300d1749da/go.mod h1:Q73ZrmV github.com/armon/go-radix v0.0.0-20180808171621-7fddfc383310/go.mod h1:ufUuZ+zHj4x4TnLV4JWEpy2hxWSpsRywHrMgIH9cCH8= github.com/bgentry/speakeasy v0.1.0/go.mod h1:+zsyZBPWlz7T6j88CTgSN5bM796AkVf0kBD4zp0CCIs= github.com/bketelsen/crypt v0.0.4/go.mod h1:aI6NrJ0pMGgvZKL1iVgXLnfIFJtfV+bKCoqOes/6LfM= -github.com/bluele/gcache v0.0.2 h1:WcbfdXICg7G/DGBh1PFfcirkWOQV+v077yF1pSy3DGw= -github.com/bluele/gcache v0.0.2/go.mod h1:m15KV+ECjptwSPxKhOhQoAFQVtUFjTVkc3H8o0t/fp0= github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU= github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI= github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI= diff --git a/ui/bottompanel.go b/ui/bottompanel.go index c6d0ec8..f1f430d 100644 --- a/ui/bottompanel.go +++ b/ui/bottompanel.go @@ -8,6 +8,7 @@ import ( "supersonic/ui/browsing" "supersonic/ui/layouts" "supersonic/ui/widgets" + "time" "fyne.io/fyne/v2" "fyne.io/fyne/v2/container" @@ -89,7 +90,12 @@ func (bp *BottomPanel) onSongChange(song *subsonic.Child) { } else { var im image.Image if bp.ImageManager != nil { - im, _ = bp.ImageManager.GetAlbumThumbnail(song.AlbumID) + // set image to expire not long after the length of the song + // if song is played through without much pausing, image will still + // be in cache for the next song if it's from the same album, or + // if the user navigates to the album page for the track + imgTTLSec := song.Duration + 30 + im, _ = bp.ImageManager.GetAlbumThumbnailWithTTL(song.AlbumID, time.Duration(imgTTLSec)*time.Second) } bp.NowPlaying.Update(song.Title, song.Artist, song.Album, im) }