feat(gallery): caching

This commit is contained in:
schreifuchs committed 2026-06-01 12:38:09 +02:00
1 parent 8a72b96e66
commit efcc32b522
12 files changed
+140 -60

No files matched your search

+12 -48
View File
@@ -2,11 +2,8 @@ package gallery
import ( import (
"context" "context"
"log/slog"
"math" "math"
"path"
"git.schreifuchs.ch/schreifuchs/schreifuchs.ch/internal/components/images"
"github.com/InfinityTools/go-binpack2d" "github.com/InfinityTools/go-binpack2d"
) )
@@ -16,32 +13,6 @@ type Gallery struct {
Images []Image Images []Image
} }
type Image struct {
UID string
X int
Y int
Width int
Height int
}
func (s *Service) GetImages(ctx context.Context) (imgs []images.Image, err error) {
files, err := s.fs.ReadDir(s.cfg.Path)
if err != nil {
return
}
for _, file := range files {
imagePath := path.Join(s.cfg.Path, file.Name())
img, err := s.img.GetImage(imagePath)
if err != nil || img.Image == nil {
continue
}
imgs = append(imgs, img)
}
return
}
func (s *Service) GetGallery(ctx context.Context, width int) (gallery Gallery, err error) { func (s *Service) GetGallery(ctx context.Context, width int) (gallery Gallery, err error) {
imgs, err := s.GetImages(ctx) imgs, err := s.GetImages(ctx)
if err != nil { if err != nil {
@@ -51,25 +22,19 @@ func (s *Service) GetGallery(ctx context.Context, width int) (gallery Gallery, e
packer := binpack2d.Create(50, 999999) packer := binpack2d.Create(50, 999999)
for len(imgs) > 0 { for len(imgs) > 0 {
unused := make([]images.Image, 0, len(imgs)) unused := make([]Image, 0, len(imgs))
for _, img := range imgs { for _, img := range imgs {
width, height := adjustImageSize(img, 200) adjustImageSize(&img, 200)
rect, ok := packer.Insert(width, height, binpack2d.RULE_BEST_AREA_FIT) rect, ok := packer.Insert(img.Width, img.Height, binpack2d.RULE_BEST_SHORT_SIDE_FIT)
if !ok { if !ok {
unused = append(unused, img) unused = append(unused, img)
continue continue
} }
uid, err := img.UID()
if err != nil { img.X = rect.X
return gallery, err img.Y = rect.Y
}
gallery.Images = append(gallery.Images, Image{ gallery.Images = append(gallery.Images, img)
UID: uid,
X: rect.X,
Y: rect.Y,
Width: width,
Height: height,
})
} }
imgs = unused imgs = unused
} }
@@ -79,21 +44,20 @@ func (s *Service) GetGallery(ctx context.Context, width int) (gallery Gallery, e
gallery.GridHeight = packer.GetHeight() gallery.GridHeight = packer.GetHeight()
gallery.GridWith = packer.GetWidth() gallery.GridWith = packer.GetWidth()
slog.Info("serving gallery", "g", gallery)
return return
} }
// adjustImageSize adjusts the image size so that the aspect ratio stays the same // adjustImageSize adjusts the image size so that the aspect ratio stays the same
// but the image area is the given parameter. // but the image area is the given parameter.
func adjustImageSize(img images.Image, area int) (width, height int) { func adjustImageSize(img *Image, area int) {
// Calculate the original aspect ratio (W / H) // Calculate the original aspect ratio (W / H)
aspectRatio := float64(img.Bounds().Dx()) / float64(img.Bounds().Dy()) aspectRatio := float64(img.Width) / float64(img.Height)
// Calculate new height and width using the derived formulas // Calculate new height and width using the derived formulas
newHeight := math.Sqrt(float64(area) / aspectRatio) newHeight := math.Sqrt(float64(area) / aspectRatio)
newWidth := newHeight * aspectRatio newWidth := newHeight * aspectRatio
// Round to the nearest integer to minimize precision loss // Round to the nearest integer to minimize precision loss
return int(math.Round(newWidth)), int(math.Round(newHeight)) img.Width = int(math.Round(newWidth))
img.Height = int(math.Round(newHeight))
} }
+111
View File
@@ -0,0 +1,111 @@
package gallery
import (
"bytes"
"context"
"fmt"
"hash/fnv"
"log/slog"
"os"
"path"
"strings"
"time"
"github.com/valkey-io/valkey-go"
)
type Image struct {
UID string
X int
Y int
Width int
Height int
}
const (
imagesKey = "gallery:images:list"
hashKey = "gallery:images:hash"
)
func (s *Service) autoRefresh() {
t := time.NewTicker(time.Minute)
for {
ctx, cancle := context.WithTimeout(context.Background(), time.Minute)
if err := s.assembleImageList(ctx); err != nil {
slog.Error("error while refreshing gallery", "err", err)
}
cancle()
<-t.C
}
}
func (s *Service) assembleImageList(ctx context.Context) error {
files, err := s.fs.ReadDir(s.cfg.Path)
if err != nil {
return fmt.Errorf("could not read contents of %s: %w", s.cfg.Path, err)
}
savedHash, _ := s.cache.Do(ctx, s.cache.B().Get().Key(hashKey).Build()).AsBytes()
newHash := hashImageList(files)
if bytes.Equal(savedHash, newHash) {
slog.Debug("images up to date no refreshing of gallery", "savedHash", savedHash, "newHash", newHash)
return nil
}
slog.Info("starting gallery refresh")
defer slog.Info("gallery refresh completed")
imgs := make([]Image, 0, len(files))
for _, file := range files {
if strings.HasSuffix(file.Name(), ".md") {
continue
}
imagePath := path.Join(s.cfg.Path, file.Name())
img, err := s.img.GetImage(imagePath)
if err != nil || img.Image == nil {
continue
}
uid, err := img.UID()
if err != nil {
continue
}
imgs = append(imgs, Image{
UID: uid,
Width: img.Bounds().Dx(),
Height: img.Bounds().Dy(),
})
}
if err = s.cache.Do(ctx, s.cache.B().Set().Key(hashKey).Value(valkey.BinaryString(newHash)).Build()).Error(); err != nil {
return err
}
if err = s.cache.Do(ctx, s.cache.B().Set().Key(imagesKey).Value(valkey.JSON(imgs)).Build()).Error(); err != nil {
return err
}
return nil
}
func hashImageList(files []os.FileInfo) []byte {
h := fnv.New128()
for _, file := range files {
fmt.Fprint(h, file.ModTime())
fmt.Fprint(h, file.Size())
fmt.Fprint(h, file.Name())
}
return h.Sum([]byte{})
}
func (s *Service) GetImages(ctx context.Context) (images []Image, err error) {
err = s.cache.Do(ctx, s.cache.B().Get().Key(imagesKey).Build()).DecodeJSON(&images)
return
}
+12 -2
View File
@@ -1,9 +1,12 @@
package gallery package gallery
import ( import (
"time"
"git.schreifuchs.ch/schreifuchs/schreifuchs.ch/internal/components/images" "git.schreifuchs.ch/schreifuchs/schreifuchs.ch/internal/components/images"
"git.schreifuchs.ch/schreifuchs/schreifuchs.ch/internal/pkg/config" "git.schreifuchs.ch/schreifuchs/schreifuchs.ch/internal/pkg/config"
"git.schreifuchs.ch/schreifuchs/schreifuchs.ch/internal/pkg/filesystem" "git.schreifuchs.ch/schreifuchs/schreifuchs.ch/internal/pkg/filesystem"
"github.com/valkey-io/valkey-go"
) )
type getImager interface { type getImager interface {
@@ -14,12 +17,19 @@ type Service struct {
fs filesystem.FS fs filesystem.FS
cfg *config.Gallery cfg *config.Gallery
img getImager img getImager
cache valkey.Client
refreshIntervall time.Duration
} }
func New(fs filesystem.FS, cfg *config.Gallery, img getImager) *Service { func New(fs filesystem.FS, cfg *config.Gallery, img getImager, cache valkey.Client) *Service {
return &Service{ s := &Service{
fs: fs, fs: fs,
cfg: cfg, cfg: cfg,
img: img, img: img,
cache: cache,
} }
go s.autoRefresh()
return s
} }
+1 -1
View File
@@ -8,7 +8,7 @@ import (
) )
func (h *Handler) gallery(w http.ResponseWriter, r *http.Request) { func (h *Handler) gallery(w http.ResponseWriter, r *http.Request) {
imgs, err := h.srv.GetGallery(r.Context(), 5) imgs, err := h.srv.GetGallery(r.Context(), 0)
if err != nil { if err != nil {
slog.Error("could not get images", "err", err) slog.Error("could not get images", "err", err)
w.WriteHeader(http.StatusInternalServerError) w.WriteHeader(http.StatusInternalServerError)
@@ -8,7 +8,6 @@ import (
"net/url" "net/url"
"git.schreifuchs.ch/schreifuchs/schreifuchs.ch/internal/components/gallery" "git.schreifuchs.ch/schreifuchs/schreifuchs.ch/internal/components/gallery"
"git.schreifuchs.ch/schreifuchs/schreifuchs.ch/internal/components/images"
"git.schreifuchs.ch/schreifuchs/schreifuchs.ch/internal/pkg/config" "git.schreifuchs.ch/schreifuchs/schreifuchs.ch/internal/pkg/config"
"github.com/a-h/templ" "github.com/a-h/templ"
) )
@@ -34,7 +33,6 @@ func New(renderer renderer, srv galleryService, cfg *config.Gallery) *Handler {
} }
type galleryService interface { type galleryService interface {
GetImages(ctx context.Context) (imgs []images.Image, err error)
GetGallery(ctx context.Context, width int) (gallery gallery.Gallery, err error) GetGallery(ctx context.Context, width int) (gallery gallery.Gallery, err error)
} }
type renderer interface { type renderer interface {
+1 -1
View File
@@ -31,7 +31,7 @@ type Valkey struct {
Password string `env:"PASSWORD"` Password string `env:"PASSWORD"`
RefreshTime time.Duration `env:"REFRESH_TIME"` RefreshTime time.Duration `env:"REFRESH_TIME"`
LifeTime time.Duration `env:"REFRESH_TIME"` LifeTime time.Duration `env:"LIFE_TIME"`
// InitAddress point to valkey nodes. // InitAddress point to valkey nodes.
// Valkey will connect to them one by one and issue a CLUSTER SLOT command to initialize the cluster client until success. // Valkey will connect to them one by one and issue a CLUSTER SLOT command to initialize the cluster client until success.
-1
View File
@@ -26,7 +26,6 @@ func (c *CachedClient) Read(path string) ([]byte, error) {
if err == nil && len(cached) > 0 { if err == nil && len(cached) > 0 {
c.readHits.Add(1) c.readHits.Add(1)
c.hits.Add(1) c.hits.Add(1)
slog.Debug("cache hit", "key", key)
var file readFile var file readFile
err := json.Unmarshal(cached, &file) err := json.Unmarshal(cached, &file)
-1
View File
@@ -22,7 +22,6 @@ func (c *CachedClient) ReadDir(path string) ([]os.FileInfo, error) {
if err := json.Unmarshal([]byte(val), &cached); err == nil { if err := json.Unmarshal([]byte(val), &cached); err == nil {
c.readDirHits.Add(1) c.readDirHits.Add(1)
c.hits.Add(1) c.hits.Add(1)
slog.Debug("cache hit", "key", key)
infos := make([]os.FileInfo, len(cached)) infos := make([]os.FileInfo, len(cached))
for i, f := range cached { for i, f := range cached {
infos[i] = f infos[i] = f
-1
View File
@@ -19,7 +19,6 @@ func (c *CachedClient) Stat(path string) (info os.FileInfo, err error) {
var f File var f File
if err := json.Unmarshal([]byte(val), &f); err == nil { if err := json.Unmarshal([]byte(val), &f); err == nil {
c.hits.Add(1) c.hits.Add(1)
slog.Debug("cache hit", "key", key)
return f, nil return f, nil
} }
+1 -1
View File
@@ -77,7 +77,7 @@ func (s *Server) dynamicRoutes() (h http.Handler, err error) {
func() http.Handler { func() http.Handler {
mux := http.NewServeMux() mux := http.NewServeMux()
mux.Handle("/gallery", restgallery.New(renderer, gallery.New(fs, s.cfg.Gallery, imageSrv), s.cfg.Gallery)) mux.Handle("/gallery", restgallery.New(renderer, gallery.New(fs, s.cfg.Gallery, imageSrv, valkeyClient), s.cfg.Gallery))
mux.Handle("/", resthome.New(renderer, page.New(fs))) mux.Handle("/", resthome.New(renderer, page.New(fs)))
return mux return mux
+1 -1
View File
@@ -10,7 +10,7 @@ templ Gallery(g gallery.Gallery) {
> >
for _, img := range g.Images { for _, img := range g.Images {
<div <div
style={ fmt.Sprintf("grid-column: %d / span %d; grid-row: %d / span %d;", img.X+1, img.Width, img.Y+1, img.Height) } style={ fmt.Sprintf("grid-column: %d / span %d; grid-row: %d / span %d;aspect-ratio: %d / %d;", img.X+1, img.Width, img.Y+1, img.Height, img.Width, img.Height) }
class="overflow-hidden shadow-sm" class="overflow-hidden shadow-sm"
> >
<img <img
+1 -1
View File
@@ -1,2 +1,2 @@
/*! tailwindcss v4.1.18 | MIT License | https://tailwindcss.com */ /*! tailwindcss v4.1.18 | MIT License | https://tailwindcss.com */
@layer properties{@supports (((-webkit-hyphens:none)) and (not (margin-trim:inline))) or ((-moz-orient:inline) and (not (color:rgb(from red r g b)))){*,:before,:after,::backdrop{--tw-font-weight:initial;--tw-shadow:0 0 #0000;--tw-shadow-color:initial;--tw-shadow-alpha:100%;--tw-inset-shadow:0 0 #0000;--tw-inset-shadow-color:initial;--tw-inset-shadow-alpha:100%;--tw-ring-color:initial;--tw-ring-shadow:0 0 #0000;--tw-inset-ring-color:initial;--tw-inset-ring-shadow:0 0 #0000;--tw-ring-inset:initial;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-offset-shadow:0 0 #0000;--tw-text-shadow-color:initial;--tw-text-shadow-alpha:100%}}}@layer theme{:root,:host{--font-sans:"Outfit","outfit",sans-serif;--font-mono:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,"Liberation Mono","Courier New",monospace;--color-black:#000;--color-white:#fff;--spacing:.25rem;--container-6xl:72rem;--text-2xl:1.5rem;--text-2xl--line-height:calc(2/1.5);--text-3xl:1.875rem;--text-3xl--line-height:calc(2.25/1.875);--font-weight-bold:700;--text-shadow-lg:0px 0px 20px #000;--default-transition-duration:.15s;--default-transition-timing-function:cubic-bezier(.4,0,.2,1);--default-font-family:var(--font-sans);--default-mono-font-family:var(--font-mono)}}@layer base{*,:after,:before,::backdrop{box-sizing:border-box;border:0 solid;margin:0;padding:0}::file-selector-button{box-sizing:border-box;border:0 solid;margin:0;padding:0}html,:host{-webkit-text-size-adjust:100%;tab-size:4;line-height:1.5;font-family:var(--default-font-family,ui-sans-serif,system-ui,sans-serif,"Apple Color Emoji","Segoe UI Emoji","Segoe UI Symbol","Noto Color Emoji");font-feature-settings:var(--default-font-feature-settings,normal);font-variation-settings:var(--default-font-variation-settings,normal);-webkit-tap-highlight-color:transparent}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;-webkit-text-decoration:inherit;-webkit-text-decoration:inherit;-webkit-text-decoration:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,samp,pre{font-family:var(--default-mono-font-family,ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,"Liberation Mono","Courier New",monospace);font-feature-settings:var(--default-mono-font-feature-settings,normal);font-variation-settings:var(--default-mono-font-variation-settings,normal);font-size:1em}small{font-size:80%}sub,sup{vertical-align:baseline;font-size:75%;line-height:0;position:relative}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}:-moz-focusring{outline:auto}progress{vertical-align:baseline}summary{display:list-item}ol,ul,menu{list-style:none}img,svg,video,canvas,audio,iframe,embed,object{vertical-align:middle;display:block}img,video{max-width:100%;height:auto}button,input,select,optgroup,textarea{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}::file-selector-button{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}:where(select:is([multiple],[size])) optgroup{font-weight:bolder}:where(select:is([multiple],[size])) optgroup option{padding-inline-start:20px}::file-selector-button{margin-inline-end:4px}::placeholder{opacity:1}@supports (not ((-webkit-appearance:-apple-pay-button))) or (contain-intrinsic-size:1px){::placeholder{color:currentColor}@supports (color:color-mix(in lab, red, red)){::placeholder{color:color-mix(in oklab,currentcolor 50%,transparent)}}}textarea{resize:vertical}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-date-and-time-value{min-height:1lh;text-align:inherit}::-webkit-datetime-edit{display:inline-flex}::-webkit-datetime-edit-fields-wrapper{padding:0}::-webkit-datetime-edit{padding-block:0}::-webkit-datetime-edit-year-field{padding-block:0}::-webkit-datetime-edit-month-field{padding-block:0}::-webkit-datetime-edit-day-field{padding-block:0}::-webkit-datetime-edit-hour-field{padding-block:0}::-webkit-datetime-edit-minute-field{padding-block:0}::-webkit-datetime-edit-second-field{padding-block:0}::-webkit-datetime-edit-millisecond-field{padding-block:0}::-webkit-datetime-edit-meridiem-field{padding-block:0}::-webkit-calendar-picker-indicator{line-height:1}:-moz-ui-invalid{box-shadow:none}button,input:where([type=button],[type=reset],[type=submit]){appearance:button}::file-selector-button{appearance:button}::-webkit-inner-spin-button{height:auto}::-webkit-outer-spin-button{height:auto}[hidden]:where(:not([hidden=until-found])){display:none!important}}@layer components{.prose img{max-height:90vh;margin-left:auto;margin-right:auto;display:block}}@layer utilities{.pointer-events-none{pointer-events:none}.absolute{position:absolute}.fixed{positLine truncated @layer properties{@supports (((-webkit-hyphens:none)) and (not (margin-trim:inline))) or ((-moz-orient:inline) and (not (color:rgb(from red r g b)))){*,:before,:after,::backdrop{--tw-font-weight:initial;--tw-shadow:0 0 #0000;--tw-shadow-color:initial;--tw-shadow-alpha:100%;--tw-inset-shadow:0 0 #0000;--tw-inset-shadow-color:initial;--tw-inset-shadow-alpha:100%;--tw-ring-color:initial;--tw-ring-shadow:0 0 #0000;--tw-inset-ring-color:initial;--tw-inset-ring-shadow:0 0 #0000;--tw-ring-inset:initial;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-offset-shadow:0 0 #0000;--tw-text-shadow-color:initial;--tw-text-shadow-alpha:100%}}}@layer theme{:root,:host{--font-sans:"Outfit","outfit",sans-serif;--font-mono:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,"Liberation Mono","Courier New",monospace;--color-black:#000;--color-white:#fff;--spacing:.25rem;--container-6xl:72rem;--text-2xl:1.5rem;--text-2xl--line-height:calc(2/1.5);--text-3xl:1.875rem;--text-3xl--line-height:calc(2.25/1.875);--font-weight-bold:700;--text-shadow-lg:0px 0px 20px #000;--default-transition-duration:.15s;--default-transition-timing-function:cubic-bezier(.4,0,.2,1);--default-font-family:var(--font-sans);--default-mono-font-family:var(--font-mono)}}@layer base{*,:after,:before,::backdrop{box-sizing:border-box;border:0 solid;margin:0;padding:0}::file-selector-button{box-sizing:border-box;border:0 solid;margin:0;padding:0}html,:host{-webkit-text-size-adjust:100%;tab-size:4;line-height:1.5;font-family:var(--default-font-family,ui-sans-serif,system-ui,sans-serif,"Apple Color Emoji","Segoe UI Emoji","Segoe UI Symbol","Noto Color Emoji");font-feature-settings:var(--default-font-feature-settings,normal);font-variation-settings:var(--default-font-variation-settings,normal);-webkit-tap-highlight-color:transparent}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;-webkit-text-decoration:inherit;-webkit-text-decoration:inherit;-webkit-text-decoration:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,samp,pre{font-family:var(--default-mono-font-family,ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,"Liberation Mono","Courier New",monospace);font-feature-settings:var(--default-mono-font-feature-settings,normal);font-variation-settings:var(--default-mono-font-variation-settings,normal);font-size:1em}small{font-size:80%}sub,sup{vertical-align:baseline;font-size:75%;line-height:0;position:relative}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}:-moz-focusring{outline:auto}progress{vertical-align:baseline}summary{display:list-item}ol,ul,menu{list-style:none}img,svg,video,canvas,audio,iframe,embed,object{vertical-align:middle;display:block}img,video{max-width:100%;height:auto}button,input,select,optgroup,textarea{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}::file-selector-button{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}:where(select:is([multiple],[size])) optgroup{font-weight:bolder}:where(select:is([multiple],[size])) optgroup option{padding-inline-start:20px}::file-selector-button{margin-inline-end:4px}::placeholder{opacity:1}@supports (not ((-webkit-appearance:-apple-pay-button))) or (contain-intrinsic-size:1px){::placeholder{color:currentColor}@supports (color:color-mix(in lab, red, red)){::placeholder{color:color-mix(in oklab,currentcolor 50%,transparent)}}}textarea{resize:vertical}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-date-and-time-value{min-height:1lh;text-align:inherit}::-webkit-datetime-edit{display:inline-flex}::-webkit-datetime-edit-fields-wrapper{padding:0}::-webkit-datetime-edit{padding-block:0}::-webkit-datetime-edit-year-field{padding-block:0}::-webkit-datetime-edit-month-field{padding-block:0}::-webkit-datetime-edit-day-field{padding-block:0}::-webkit-datetime-edit-hour-field{padding-block:0}::-webkit-datetime-edit-minute-field{padding-block:0}::-webkit-datetime-edit-second-field{padding-block:0}::-webkit-datetime-edit-millisecond-field{padding-block:0}::-webkit-datetime-edit-meridiem-field{padding-block:0}::-webkit-calendar-picker-indicator{line-height:1}:-moz-ui-invalid{box-shadow:none}button,input:where([type=button],[type=reset],[type=submit]){appearance:button}::file-selector-button{appearance:button}::-webkit-inner-spin-button{height:auto}::-webkit-outer-spin-button{height:auto}[hidden]:where(:not([hidden=until-found])){display:none!important}}@layer components{.prose img{max-height:90vh;margin-left:auto;margin-right:auto;display:block}}@layer utilities{.pointer-events-none{pointer-events:none}.absolute{position:absolute}.fixed{positLine truncated