mirror of
https://github.com/navidrome/navidrome.git
synced 2026-10-12 12:27:09 +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.
67 lines
2.2 KiB
Go
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
|
|
}
|