diff --git a/backend/app.go b/backend/app.go index 37a662f..fab167f 100644 --- a/backend/app.go +++ b/backend/app.go @@ -103,6 +103,8 @@ func StartupApp(appName, displayAppName, appVersionTag, configFile, latestReleas a.ServerManager = NewServerManager(appName, a.Config) a.PlaybackManager = NewPlaybackManager(a.bgrndCtx, a.ServerManager, a.Player, &a.Config.Scrobbling) 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.ImageManager.GetCoverThumbnail(coverID) }) diff --git a/backend/config.go b/backend/config.go index 6026114..a745580 100644 --- a/backend/config.go +++ b/backend/config.go @@ -22,14 +22,15 @@ type ServerConfig struct { } type AppConfig struct { - WindowWidth int - WindowHeight int - LastCheckedVersion string - EnableSystemTray bool - CloseToSystemTray bool - StartupPage string - SettingsTab string - AllowMultiInstance bool + WindowWidth int + WindowHeight int + LastCheckedVersion string + EnableSystemTray bool + CloseToSystemTray bool + StartupPage string + SettingsTab string + AllowMultiInstance bool + MaxImageCacheSizeMB int // Experimental - may be removed in future FontNormalTTF string @@ -119,14 +120,15 @@ var SupportedStartupPages = []string{"Albums", "Favorites", "Playlists"} func DefaultConfig(appVersionTag string) *Config { return &Config{ Application: AppConfig{ - WindowWidth: 1000, - WindowHeight: 800, - LastCheckedVersion: appVersionTag, - EnableSystemTray: true, - CloseToSystemTray: false, - StartupPage: "Albums", - SettingsTab: "General", - AllowMultiInstance: false, + WindowWidth: 1000, + WindowHeight: 800, + LastCheckedVersion: appVersionTag, + EnableSystemTray: true, + CloseToSystemTray: false, + StartupPage: "Albums", + SettingsTab: "General", + AllowMultiInstance: false, + MaxImageCacheSizeMB: 50, }, AlbumPage: AlbumPageConfig{ TracklistColumns: []string{"Artist", "Time", "Plays", "Favorite", "Rating"}, diff --git a/backend/imagemanager.go b/backend/imagemanager.go index 24268ba..f9f0c5f 100644 --- a/backend/imagemanager.go +++ b/backend/imagemanager.go @@ -7,10 +7,13 @@ import ( "fmt" "image" "image/jpeg" + "io/fs" "log" "os" "path" "path/filepath" + "sort" + "strings" "time" "fyne.io/fyne/v2" @@ -23,8 +26,13 @@ const CachedImageValidTime = 24 * time.Hour const ( coverArtThumbnailSize = 300 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 { s *ServerManager baseCacheDir string @@ -33,6 +41,9 @@ type ImageManager struct { cachedFullSizeCover image.Image cachedFullSizeCoverID string cachedFullSizeCoverAccessedAt int64 // unixMillis + + maxOnDiskCacheSizeBytes int64 + filesWrittenSinceLastPrune bool } 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, DefaultTTL: 1 * time.Minute, }, + maxOnDiskCacheSizeBytes: defaultDiskCacheSizeBytes, } s.OnLogout(func() { i.thumbnailCache.Clear() i.clearFullSizeCover() }) - i.thumbnailCache.OnEvictTaskRan = i.clearFullSizeCoverIfExpired + i.thumbnailCache.OnEvictTaskRan = func() { + i.clearFullSizeCoverIfExpired() + i.pruneOnDiskCache() + } i.thumbnailCache.Init(ctx, 2*time.Minute) return i } +func (i *ImageManager) SetMaxOnDiskCacheSizeBytes(size int64) { + i.maxOnDiskCacheSizeBytes = size +} + func (i *ImageManager) GetCoverThumbnailFromCache(coverID string) (image.Image, bool) { img, err := i.thumbnailCache.GetExtendTTL(coverID, i.thumbnailCache.DefaultTTL) if err == nil && img != nil { @@ -214,6 +233,7 @@ func (i *ImageManager) writeJpeg(img image.Image, path string) error { return err } } + i.filesWrittenSinceLastPrune = true return err } @@ -238,3 +258,47 @@ func (i *ImageManager) clearFullSizeCover() { i.cachedFullSizeCoverID = "" 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 +}