navidrome/server/backgrounds/handler.go
Deluan 31e003e6f3 feat(ui): use webp for login backgrounds
Signed-off-by: Deluan <deluan@navidrome.org>
2025-03-06 23:32:52 -05:00

140 lines
3.9 KiB
Go

package backgrounds
import (
"context"
"encoding/base64"
"errors"
"fmt"
"io"
"net/http"
"strings"
"time"
"github.com/navidrome/navidrome/consts"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/utils/cache"
"github.com/navidrome/navidrome/utils/random"
"gopkg.in/yaml.v3"
)
const (
//imageHostingUrl = "https://unsplash.com/photos/%s/download?fm=jpg&w=1600&h=900&fit=max"
imageHostingUrl = "https://www.navidrome.org/images/%s.webp"
imageListURL = "https://www.navidrome.org/images/index.yml"
imageListTTL = 24 * time.Hour
imageCacheDir = "backgrounds"
imageCacheSize = "100MB"
imageCacheMaxItems = 1000
imageRequestTimeout = 5 * time.Second
)
type Handler struct {
httpClient *cache.HTTPClient
cache cache.FileCache
}
func NewHandler() *Handler {
h := &Handler{}
h.httpClient = cache.NewHTTPClient(&http.Client{Timeout: 5 * time.Second}, imageListTTL)
h.cache = cache.NewFileCache(imageCacheDir, imageCacheSize, imageCacheDir, imageCacheMaxItems, h.serveImage)
go func() {
_, _ = h.getImageList(log.NewContext(context.Background()))
}()
return h
}
type cacheKey string
func (k cacheKey) Key() string {
return string(k)
}
func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
image, err := h.getRandomImage(r.Context())
if err != nil {
h.serveDefaultImage(w)
return
}
s, err := h.cache.Get(r.Context(), cacheKey(image))
if err != nil {
h.serveDefaultImage(w)
return
}
defer s.Close()
w.Header().Set("content-type", "image/webp")
_, _ = io.Copy(w, s.Reader)
}
func (h *Handler) serveDefaultImage(w http.ResponseWriter) {
defaultImage, _ := base64.StdEncoding.DecodeString(consts.DefaultUILoginBackgroundOffline)
w.Header().Set("content-type", "image/png")
_, _ = w.Write(defaultImage)
}
func (h *Handler) serveImage(ctx context.Context, item cache.Item) (io.Reader, error) {
start := time.Now()
image := item.Key()
if image == "" {
return nil, errors.New("empty image name")
}
c := http.Client{Timeout: imageRequestTimeout}
req, _ := http.NewRequestWithContext(ctx, http.MethodGet, imageURL(image), nil)
resp, err := c.Do(req) //nolint:bodyclose // No need to close resp.Body, it will be closed via the CachedStream wrapper
if errors.Is(err, context.DeadlineExceeded) {
defaultImage, _ := base64.StdEncoding.DecodeString(consts.DefaultUILoginBackgroundOffline)
return strings.NewReader(string(defaultImage)), nil
}
if err != nil {
return nil, fmt.Errorf("could not get background image from hosting service: %w", err)
}
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("unexpected status code getting background image from hosting service: %d", resp.StatusCode)
}
log.Debug(ctx, "Got background image from hosting service", "image", image, "elapsed", time.Since(start))
return resp.Body, nil
}
func (h *Handler) getRandomImage(ctx context.Context) (string, error) {
list, err := h.getImageList(ctx)
if err != nil {
return "", err
}
if len(list) == 0 {
return "", errors.New("no images available")
}
rnd := random.Int64N(len(list))
return list[rnd], nil
}
func (h *Handler) getImageList(ctx context.Context) ([]string, error) {
start := time.Now()
req, _ := http.NewRequestWithContext(ctx, http.MethodGet, imageListURL, nil)
resp, err := h.httpClient.Do(req)
if err != nil {
log.Warn(ctx, "Could not get background images from image service", err)
return nil, err
}
defer resp.Body.Close()
var list []string
dec := yaml.NewDecoder(resp.Body)
err = dec.Decode(&list)
if err != nil {
log.Warn(ctx, "Could not decode background images from image service", err)
return nil, err
}
log.Debug(ctx, "Loaded background images from image service", "total", len(list), "elapsed", time.Since(start))
return list, nil
}
func imageURL(imageName string) string {
// Discard extension
parts := strings.Split(imageName, ".")
if len(parts) > 1 {
imageName = parts[0]
}
return fmt.Sprintf(imageHostingUrl, imageName)
}