navidrome/server/imghttp/headers.go
Deluan bc16cafb91 fix(artwork): convert animated GIF covers in the background
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.
2026-09-14 19:36:50 -04:00

67 lines
2.2 KiB
Go

// Package imghttp holds the HTTP caching contract shared by the subsonic, public, and jellyfin
// image handlers, so they apply identical headers without importing each other.
package imghttp
import (
"net/http"
"strings"
"github.com/navidrome/navidrome/core/artwork"
)
// WriteImageHeaders applies the artwork caching contract and reports whether a 304 was written
// (the caller must then not write a body). requestedHash is the hash the client asserted, or "".
func WriteImageHeaders(w http.ResponseWriter, r *http.Request, img *artwork.Image, requestedHash string) (wrote304 bool) {
h := w.Header()
// Placeholders and transient stand-ins must not outlive what they stand in for: never cached, no validators.
if img.Placeholder || img.Transient {
h.Set("Cache-Control", "no-store")
return false
}
// The ETag versions the served representation, so a re-encoding config change invalidates a
// revalidating client's cache; full-size originals are their own pixel hash.
etag := img.ETag
if etag == "" {
etag = img.Hash
}
// An empty validator would be shared by every such response, 304ing clients that echo it back.
if etag != "" {
h.Set("ETag", `"`+etag+`"`)
}
if !img.LastUpdated.IsZero() {
h.Set("Last-Modified", img.LastUpdated.UTC().Format(http.TimeFormat))
}
// Immutable only when the client asked for the current pixel hash; anything else revalidates,
// so art that gets re-resolved is never pinned.
if requestedHash != "" && requestedHash == img.Hash {
h.Set("Cache-Control", "public, max-age=31536000, immutable")
} else {
h.Set("Cache-Control", "public, no-cache")
}
if etag != "" && ifNoneMatch(r.Header.Get("If-None-Match"), etag) {
w.WriteHeader(http.StatusNotModified)
return true
}
return false
}
// ifNoneMatch reports whether If-None-Match asserts hash, using RFC 9110 weak comparison.
func ifNoneMatch(header, hash string) bool {
header = strings.TrimSpace(header)
if header == "" {
return false
}
if header == "*" {
return true
}
for tag := range strings.SplitSeq(header, ",") {
tag = strings.TrimSpace(tag)
tag = strings.TrimPrefix(tag, "W/")
if strings.Trim(tag, `"`) == hash {
return true
}
}
return false
}