Fix #227: add limit to on-disk image cache size, periodic pruning
This commit is contained in:
@@ -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)
|
||||||
})
|
})
|
||||||
|
|||||||
+18
-16
@@ -22,14 +22,15 @@ type ServerConfig struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type AppConfig struct {
|
type AppConfig struct {
|
||||||
WindowWidth int
|
WindowWidth int
|
||||||
WindowHeight int
|
WindowHeight int
|
||||||
LastCheckedVersion string
|
LastCheckedVersion string
|
||||||
EnableSystemTray bool
|
EnableSystemTray bool
|
||||||
CloseToSystemTray bool
|
CloseToSystemTray bool
|
||||||
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
|
||||||
@@ -119,14 +120,15 @@ var SupportedStartupPages = []string{"Albums", "Favorites", "Playlists"}
|
|||||||
func DefaultConfig(appVersionTag string) *Config {
|
func DefaultConfig(appVersionTag string) *Config {
|
||||||
return &Config{
|
return &Config{
|
||||||
Application: AppConfig{
|
Application: AppConfig{
|
||||||
WindowWidth: 1000,
|
WindowWidth: 1000,
|
||||||
WindowHeight: 800,
|
WindowHeight: 800,
|
||||||
LastCheckedVersion: appVersionTag,
|
LastCheckedVersion: appVersionTag,
|
||||||
EnableSystemTray: true,
|
EnableSystemTray: true,
|
||||||
CloseToSystemTray: false,
|
CloseToSystemTray: false,
|
||||||
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
@@ -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
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user