Fix #227: add limit to on-disk image cache size, periodic pruning

This commit is contained in:
Drew Weymouth
2023-07-28 18:53:45 -07:00
parent 09fcc6dcb8
commit f63fcc14ff
3 changed files with 85 additions and 17 deletions
+2
View File
@@ -103,6 +103,8 @@ func StartupApp(appName, displayAppName, appVersionTag, configFile, latestReleas
a.ServerManager = NewServerManager(appName, a.Config) a.ServerManager = NewServerManager(appName, a.Config)
a.PlaybackManager = NewPlaybackManager(a.bgrndCtx, a.ServerManager, a.Player, &a.Config.Scrobbling) a.PlaybackManager = NewPlaybackManager(a.bgrndCtx, a.ServerManager, a.Player, &a.Config.Scrobbling)
a.ImageManager = NewImageManager(a.bgrndCtx, a.ServerManager, configdir.LocalCache(a.appName)) a.ImageManager = NewImageManager(a.bgrndCtx, a.ServerManager, configdir.LocalCache(a.appName))
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) { a.ServerManager.SetPrefetchAlbumCoverCallback(func(coverID string) {
_, _ = a.ImageManager.GetCoverThumbnail(coverID) _, _ = a.ImageManager.GetCoverThumbnail(coverID)
}) })
+2
View File
@@ -30,6 +30,7 @@ type AppConfig struct {
StartupPage string StartupPage string
SettingsTab string SettingsTab string
AllowMultiInstance bool AllowMultiInstance bool
MaxImageCacheSizeMB int
// Experimental - may be removed in future // Experimental - may be removed in future
FontNormalTTF string FontNormalTTF string
@@ -127,6 +128,7 @@ func DefaultConfig(appVersionTag string) *Config {
StartupPage: "Albums", StartupPage: "Albums",
SettingsTab: "General", SettingsTab: "General",
AllowMultiInstance: false, AllowMultiInstance: false,
MaxImageCacheSizeMB: 50,
}, },
AlbumPage: AlbumPageConfig{ AlbumPage: AlbumPageConfig{
TracklistColumns: []string{"Artist", "Time", "Plays", "Favorite", "Rating"}, TracklistColumns: []string{"Artist", "Time", "Plays", "Favorite", "Rating"},
+65 -1
View File
@@ -7,10 +7,13 @@ import (
"fmt" "fmt"
"image" "image"
"image/jpeg" "image/jpeg"
"io/fs"
"log" "log"
"os" "os"
"path" "path"
"path/filepath" "path/filepath"
"sort"
"strings"
"time" "time"
"fyne.io/fyne/v2" "fyne.io/fyne/v2"
@@ -23,8 +26,13 @@ const CachedImageValidTime = 24 * time.Hour
const ( const (
coverArtThumbnailSize = 300 coverArtThumbnailSize = 300
fullSizeCoverExpires = 5 * time.Minute fullSizeCoverExpires = 5 * time.Minute
defaultDiskCacheSizeBytes = 50 * 1_048_576
) )
// The ImageManager is responsible for retrieving and serving images to the UI layer.
// It maintains an in-memory cache of recently used images for immediate future access,
// and a larger on-disc cache of images that is periodically re-requested from the server.
type ImageManager struct { type ImageManager struct {
s *ServerManager s *ServerManager
baseCacheDir string baseCacheDir string
@@ -33,6 +41,9 @@ type ImageManager struct {
cachedFullSizeCover image.Image cachedFullSizeCover image.Image
cachedFullSizeCoverID string cachedFullSizeCoverID string
cachedFullSizeCoverAccessedAt int64 // unixMillis cachedFullSizeCoverAccessedAt int64 // unixMillis
maxOnDiskCacheSizeBytes int64
filesWrittenSinceLastPrune bool
} }
func NewImageManager(ctx context.Context, s *ServerManager, baseCacheDir string) *ImageManager { func NewImageManager(ctx context.Context, s *ServerManager, baseCacheDir string) *ImageManager {
@@ -48,16 +59,24 @@ func NewImageManager(ctx context.Context, s *ServerManager, baseCacheDir string)
MaxSize: 150, MaxSize: 150,
DefaultTTL: 1 * time.Minute, DefaultTTL: 1 * time.Minute,
}, },
maxOnDiskCacheSizeBytes: defaultDiskCacheSizeBytes,
} }
s.OnLogout(func() { s.OnLogout(func() {
i.thumbnailCache.Clear() i.thumbnailCache.Clear()
i.clearFullSizeCover() i.clearFullSizeCover()
}) })
i.thumbnailCache.OnEvictTaskRan = i.clearFullSizeCoverIfExpired i.thumbnailCache.OnEvictTaskRan = func() {
i.clearFullSizeCoverIfExpired()
i.pruneOnDiskCache()
}
i.thumbnailCache.Init(ctx, 2*time.Minute) i.thumbnailCache.Init(ctx, 2*time.Minute)
return i return i
} }
func (i *ImageManager) SetMaxOnDiskCacheSizeBytes(size int64) {
i.maxOnDiskCacheSizeBytes = size
}
func (i *ImageManager) GetCoverThumbnailFromCache(coverID string) (image.Image, bool) { func (i *ImageManager) GetCoverThumbnailFromCache(coverID string) (image.Image, bool) {
img, err := i.thumbnailCache.GetExtendTTL(coverID, i.thumbnailCache.DefaultTTL) img, err := i.thumbnailCache.GetExtendTTL(coverID, i.thumbnailCache.DefaultTTL)
if err == nil && img != nil { if err == nil && img != nil {
@@ -214,6 +233,7 @@ func (i *ImageManager) writeJpeg(img image.Image, path string) error {
return err return err
} }
} }
i.filesWrittenSinceLastPrune = true
return err return err
} }
@@ -238,3 +258,47 @@ func (i *ImageManager) clearFullSizeCover() {
i.cachedFullSizeCoverID = "" i.cachedFullSizeCoverID = ""
i.cachedFullSizeCover = nil i.cachedFullSizeCover = nil
} }
func (im *ImageManager) pruneOnDiskCache() {
if !im.filesWrittenSinceLastPrune {
return // no new covers cached since last run, no need to walk dir
}
// collect list of all cached covers (across servers)
// we use modTime as a proxy for last accessed time
// since covers are refreshed from the server after a fixed interval,
// modTime is roughly equivalent to last access
type fileInfo struct {
path string
size int64
modTime int64
}
var allCovers []fileInfo
var totalSize int64
filepath.WalkDir(im.baseCacheDir, func(path string, d fs.DirEntry, err error) error {
if err != nil || d.IsDir() || !strings.HasSuffix(path, "jpg") {
return nil
}
if info, err := d.Info(); err == nil {
s := info.Size()
allCovers = append(allCovers,
fileInfo{path: path, size: s, modTime: info.ModTime().UnixMilli()})
totalSize += s
}
return nil
})
if totalSize > im.maxOnDiskCacheSizeBytes {
// sort and then delete from least recently modified until size is under threshold
sort.Slice(allCovers, func(i, j int) bool {
return allCovers[i].modTime < allCovers[j].modTime
})
for i := 0; i < len(allCovers) && totalSize > im.maxOnDiskCacheSizeBytes; i++ {
if err := os.Remove(allCovers[i].path); err == nil {
totalSize -= allCovers[i].size
}
}
}
im.filesWrittenSinceLastPrune = false
}