From 2380caab35ca15f6588f94f9d231e38a8ab45457 Mon Sep 17 00:00:00 2001 From: Drew Weymouth Date: Sun, 19 Mar 2023 11:00:54 -0700 Subject: [PATCH] add caching of artist images --- CHANGELOG.md | 3 ++ backend/imagemanager.go | 87 +++++++++++++++++++++++++++++----- ui/browsing/artistpage.go | 22 +++++---- ui/widgets/imageplaceholder.go | 6 +++ 4 files changed, 95 insertions(+), 23 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2f19453..e277f98 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,8 @@ # Change Log +## Unreleased +- [#39](https://github.com/dweymouth/supersonic/issues/39) Add caching of artist images + ## [0.0.1-alpha2] - 2023-03-17 ### Added diff --git a/backend/imagemanager.go b/backend/imagemanager.go index b281e1a..45f79f4 100644 --- a/backend/imagemanager.go +++ b/backend/imagemanager.go @@ -1,6 +1,7 @@ package backend import ( + "bytes" "context" "fmt" "image" @@ -11,9 +12,12 @@ import ( "path/filepath" "time" + "fyne.io/fyne/v2" "github.com/20after4/configdir" ) +const CachedImageValidTime = 24 * time.Hour + type ImageManager struct { s *ServerManager baseCacheDir string @@ -77,24 +81,60 @@ func (i *ImageManager) GetFullSizeAlbumCover(albumID string) (image.Image, error return im, nil } +func (i *ImageManager) GetCachedArtistImage(artistID string) (image.Image, bool) { + return i.loadLocalImage(i.filePathForArtistImage(artistID)) +} + +func (i *ImageManager) FetchAndCacheArtistImage(artistID string, imgURL string) (image.Image, error) { + im, err := i.fetchRemoteArtistImage(imgURL) + if err != nil { + return nil, err + } + _ = i.writeJpeg(im, i.filePathForArtistImage(artistID)) + return im, nil +} + +func (i *ImageManager) RefreshCachedArtistImageIfExpired(artistID string, imgURL string) error { + stat, err := os.Stat(i.filePathForArtistImage(artistID)) + if err == nil && time.Since(stat.ModTime()) > CachedImageValidTime { + _, err = i.FetchAndCacheArtistImage(artistID, imgURL) + } + return err +} + func (i *ImageManager) ensureCoverCacheDir() string { path := path.Join(i.baseCacheDir, i.s.ServerID.String(), "covers") configdir.MakePath(path) return path } +func (i *ImageManager) ensureArtistCoverCacheDir() string { + path := path.Join(i.baseCacheDir, i.s.ServerID.String(), "artistimages") + configdir.MakePath(path) + return path +} + +func (i *ImageManager) fetchRemoteArtistImage(url string) (image.Image, error) { + res, err := fyne.LoadResourceFromURLString(url) + if err == nil { + im, _, err := image.Decode(bytes.NewReader(res.Content())) + if err == nil { + return im, nil + } + return nil, err + } + return nil, err +} + 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, ttl) - 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 - } + if img, ok := i.loadLocalImage(path); ok { + i.thumbnailCache.SetWithTTL(albumID, img, ttl) + return img, nil } } } @@ -109,19 +149,14 @@ func (i *ImageManager) fetchAndCacheCoverFromServer(albumID string, ttl time.Dur } if i.ensureCoverCacheDir() != "" { path := i.filePathForCover(albumID) - if f, err := os.Create(path); err == nil { - defer f.Close() - if err := jpeg.Encode(f, img, nil /*options*/); err != nil { - log.Printf("failed to cache image: %s", err.Error()) - } - } + _ = i.writeJpeg(img, path) } i.thumbnailCache.SetWithTTL(albumID, img, ttl) return img, nil } func (i *ImageManager) checkRefreshLocalCover(stat os.FileInfo, albumID string, ttl time.Duration) { - if time.Since(stat.ModTime()) > 24*time.Hour { + if time.Since(stat.ModTime()) > CachedImageValidTime { i.fetchAndCacheCoverFromServer(albumID, ttl) } } @@ -129,3 +164,29 @@ func (i *ImageManager) checkRefreshLocalCover(stat os.FileInfo, albumID string, func (i *ImageManager) filePathForCover(albumID string) string { return filepath.Join(i.ensureCoverCacheDir(), fmt.Sprintf("%s.jpg", albumID)) } + +func (i *ImageManager) filePathForArtistImage(id string) string { + return filepath.Join(i.ensureArtistCoverCacheDir(), fmt.Sprintf("%s.jpg", id)) +} + +func (i *ImageManager) writeJpeg(img image.Image, path string) error { + f, err := os.Create(path) + if err == nil { + defer f.Close() + if err := jpeg.Encode(f, img, nil /*options*/); err != nil { + log.Printf("failed to cache image: %s", err.Error()) + return err + } + } + return err +} + +func (i *ImageManager) loadLocalImage(path string) (image.Image, bool) { + if f, err := os.Open(path); err == nil { + defer f.Close() + if img, _, err := image.Decode(f); err == nil { + return img, true + } + } + return nil, false +} diff --git a/ui/browsing/artistpage.go b/ui/browsing/artistpage.go index 95d230f..85ee2f6 100644 --- a/ui/browsing/artistpage.go +++ b/ui/browsing/artistpage.go @@ -1,8 +1,6 @@ package browsing import ( - "bytes" - "image" "log" "strings" "supersonic/backend" @@ -68,6 +66,9 @@ func newArtistPage(artistID string, cfg *backend.ArtistPageConfig, pm *backend.P }} a.ExtendBaseWidget(a) a.header = NewArtistPageHeader(a) + if img, ok := im.GetCachedArtistImage(artistID); ok { + a.header.artistImage.SetImage(img, true /*tappable*/) + } viewToggle := widgets.NewToggleText(0, []string{"Discography", "Top Tracks"}) viewToggle.SetActivatedLabel(a.activeView) viewToggle.OnChanged = a.onViewChange @@ -276,6 +277,7 @@ func (a *ArtistPageHeader) UpdateInfo(info *subsonic.ArtistInfo2) { if info == nil { return } + if info.Biography != "" { segs := util.RichTextSegsFromHTMLString(info.Biography) if len(segs) > 0 { @@ -285,6 +287,7 @@ func (a *ArtistPageHeader) UpdateInfo(info *subsonic.ArtistInfo2) { } } } + a.similarArtists.RemoveAll() for i, art := range info.SimilarArtist { if i == 0 { @@ -302,16 +305,15 @@ func (a *ArtistPageHeader) UpdateInfo(info *subsonic.ArtistInfo2) { a.similarArtists.Add(h) } a.similarArtists.Refresh() + if info.LargeImageUrl != "" { - if res, err := fyne.LoadResourceFromURLString(info.LargeImageUrl); err == nil { - im, _, err := image.Decode(bytes.NewReader(res.Content())) - if err != nil { - return + if a.artistImage.HaveImage() { + _ = a.artistPage.im.RefreshCachedArtistImageIfExpired(a.artistID, info.LargeImageUrl) + } else { + im, err := a.artistPage.im.FetchAndCacheArtistImage(a.artistID, info.LargeImageUrl) + if err == nil { + a.artistImage.SetImage(im, true /*tappable*/) } - a.artistImage.OnTapped = func() { - a.artistPage.contr.ShowPopUpImage(im) - } - a.artistImage.SetImage(im, true /*tappable*/) } } } diff --git a/ui/widgets/imageplaceholder.go b/ui/widgets/imageplaceholder.go index aa1b198..da92502 100644 --- a/ui/widgets/imageplaceholder.go +++ b/ui/widgets/imageplaceholder.go @@ -14,6 +14,7 @@ import ( type ImagePlaceholder struct { widget.BaseWidget container *fyne.Container + haveImage bool minSize float32 OnTapped func() @@ -42,6 +43,10 @@ type CanvasImage interface { SetMinSize(fyne.Size) } +func (i *ImagePlaceholder) HaveImage() bool { + return i.haveImage +} + func (i *ImagePlaceholder) SetImage(img image.Image, tappable bool) { var cIm CanvasImage if tappable { @@ -58,6 +63,7 @@ func (i *ImagePlaceholder) SetImage(img image.Image, tappable bool) { i.container.RemoveAll() i.container.Add(cIm) i.container.Refresh() + i.haveImage = true } func (i *ImagePlaceholder) onTapped() {