mirror of
https://github.com/navidrome/navidrome.git
synced 2026-10-12 20:37:12 +02:00
Resized animated GIF covers were converted to animated WebP by ffmpeg inside the image request, bound to its 10s timeout. On slow CPUs large GIFs never finished: ffmpeg was killed, nothing was cached, and every request repeated the work. With ffmpeg 5.1 the conversion also failed mid-stream when reading the GIF from a pipe, after the stream was already handed to the client, so there was no fallback. Requests now get a static stand-in right away, served with no-store and no validators, while the conversion runs in the background. A single process-wide slot bounds these conversions, since they outlive the request throttle; when it is busy nothing is queued and a later request retries. The result, or a static fallback if ffmpeg fails, is cached under the same key. ffmpeg output is now buffered so mid-stream failures fall back to a static resize. The file cache gains cache.Transient for loader results that must be served but not stored. Without an available cache, conversion stays inline.
98 lines
2.8 KiB
Go
98 lines
2.8 KiB
Go
package artwork
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"io"
|
|
|
|
"github.com/navidrome/navidrome/conf"
|
|
"github.com/navidrome/navidrome/consts"
|
|
"github.com/navidrome/navidrome/core/ffmpeg"
|
|
"github.com/navidrome/navidrome/utils/cache"
|
|
"github.com/navidrome/navidrome/utils/singleton"
|
|
)
|
|
|
|
// artworkReader is the cache.Item the image cache loader dispatches on: Reader
|
|
// produces the (possibly resized) bytes to store under Key.
|
|
type artworkReader interface {
|
|
cache.Item
|
|
Reader(ctx context.Context) (io.ReadCloser, error)
|
|
}
|
|
|
|
type imageCache struct {
|
|
cache.FileCache
|
|
}
|
|
|
|
func GetImageCache() cache.FileCache {
|
|
return singleton.GetInstance(func() *imageCache {
|
|
return &imageCache{
|
|
FileCache: cache.NewFileCache("Image", conf.Server.ImageCacheSize, consts.ImageCacheDir, consts.DefaultImageCacheMaxItems,
|
|
func(ctx context.Context, arg cache.Item) (io.Reader, error) {
|
|
return arg.(artworkReader).Reader(ctx)
|
|
}),
|
|
}
|
|
})
|
|
}
|
|
|
|
// resizedItem is an artworkReader that resizes bytes opened by open() and caches the
|
|
// result under a hash-derived key.
|
|
type resizedItem struct {
|
|
hash string
|
|
size int
|
|
square bool
|
|
ffmpeg ffmpeg.FFmpeg
|
|
open func() (io.ReadCloser, error)
|
|
// deferAnimated, when set, receives an animated GIF's bytes, and a transient static stand-in is served.
|
|
deferAnimated func(data []byte)
|
|
}
|
|
|
|
// Key is the ETag namespaced for the cache, so the validator a client holds and the entry it
|
|
// validates can never drift apart.
|
|
func (r *resizedItem) Key() string {
|
|
return "h-" + representationTag(r.hash, r.size, r.square)
|
|
}
|
|
|
|
func (r *resizedItem) Reader(ctx context.Context) (io.ReadCloser, error) {
|
|
orig, err := r.open()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
// An open() that reports "no image" as a nil reader would otherwise panic on the Close below.
|
|
if orig == nil {
|
|
return nil, ErrUnavailable
|
|
}
|
|
defer orig.Close()
|
|
data, err := readCapped(orig)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if r.deferAnimated != nil && isAnimatedGIF(data) && r.ffmpeg.IsAvailable() {
|
|
r.deferAnimated(data)
|
|
static, _, err := resizeStaticImage(data, r.size, r.square)
|
|
if err != nil || static == nil {
|
|
static = bytes.NewReader(data)
|
|
}
|
|
return cache.Transient(io.NopCloser(static)), nil
|
|
}
|
|
resized, _, err := resizeImageData(ctx, r.ffmpeg, data, r.size, r.square)
|
|
if err != nil || resized == nil {
|
|
// Resize failed or image already within bounds: serve the original bytes.
|
|
return io.NopCloser(bytes.NewReader(data)), nil
|
|
}
|
|
if rc, ok := resized.(io.ReadCloser); ok {
|
|
return rc, nil
|
|
}
|
|
return io.NopCloser(resized), nil
|
|
}
|
|
|
|
// storedItem caches bytes produced outside the cache.
|
|
type storedItem struct {
|
|
key string
|
|
data []byte
|
|
}
|
|
|
|
func (i *storedItem) Key() string { return i.key }
|
|
|
|
func (i *storedItem) Reader(context.Context) (io.ReadCloser, error) {
|
|
return io.NopCloser(bytes.NewReader(i.data)), nil
|
|
}
|