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.
126 lines
5.7 KiB
Go
126 lines
5.7 KiB
Go
package imghttp_test
|
|
|
|
import (
|
|
"io"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/navidrome/navidrome/core/artwork"
|
|
"github.com/navidrome/navidrome/server/imghttp"
|
|
. "github.com/onsi/ginkgo/v2"
|
|
. "github.com/onsi/gomega"
|
|
)
|
|
|
|
const testHash = "0123456789abcdef"
|
|
const testRepTag = testHash + ".300.false.q75"
|
|
|
|
var lastMod = time.Date(2024, 1, 2, 3, 4, 5, 0, time.UTC)
|
|
|
|
func found() *artwork.Image {
|
|
return &artwork.Image{
|
|
ReadCloser: io.NopCloser(strings.NewReader("IMG")),
|
|
Hash: testHash,
|
|
LastUpdated: lastMod,
|
|
}
|
|
}
|
|
|
|
func placeholder() *artwork.Image {
|
|
return &artwork.Image{ReadCloser: io.NopCloser(strings.NewReader("PH")), Placeholder: true}
|
|
}
|
|
|
|
// A re-encoded response carries a representation ETag distinct from the pixel hash, so the
|
|
// validator versions with the encode settings.
|
|
func resized() *artwork.Image {
|
|
return &artwork.Image{
|
|
ReadCloser: io.NopCloser(strings.NewReader("IMG")),
|
|
Hash: testHash,
|
|
ETag: testRepTag,
|
|
LastUpdated: lastMod,
|
|
}
|
|
}
|
|
|
|
func transient() *artwork.Image {
|
|
img := resized()
|
|
img.Transient = true
|
|
return img
|
|
}
|
|
|
|
func unvalidated() *artwork.Image {
|
|
return &artwork.Image{ReadCloser: io.NopCloser(strings.NewReader("IMG")), LastUpdated: lastMod}
|
|
}
|
|
|
|
var _ = Describe("WriteImageHeaders", func() {
|
|
type testCase struct {
|
|
img *artwork.Image
|
|
requestedHash string
|
|
ifNoneMatch string
|
|
want304 bool
|
|
wantCache string
|
|
wantETag string
|
|
wantLastMod bool
|
|
}
|
|
|
|
DescribeTable("applies the artwork caching contract",
|
|
func(c testCase) {
|
|
w := httptest.NewRecorder()
|
|
r := httptest.NewRequest("GET", "/img", nil)
|
|
if c.ifNoneMatch != "" {
|
|
r.Header.Set("If-None-Match", c.ifNoneMatch)
|
|
}
|
|
|
|
Expect(imghttp.WriteImageHeaders(w, r, c.img, c.requestedHash)).To(Equal(c.want304))
|
|
|
|
h := w.Header()
|
|
Expect(h.Get("Cache-Control")).To(Equal(c.wantCache))
|
|
Expect(h.Get("ETag")).To(Equal(c.wantETag))
|
|
if c.img.Placeholder {
|
|
Expect(h.Get("ETag")).To(BeEmpty(), "placeholder must not set an ETag")
|
|
Expect(h.Get("Last-Modified")).To(BeEmpty(), "placeholder must not set Last-Modified")
|
|
}
|
|
Expect(h.Get("Last-Modified") != "").To(Equal(c.wantLastMod))
|
|
if c.want304 {
|
|
Expect(w.Code).To(Equal(http.StatusNotModified))
|
|
Expect(w.Body.Len()).To(BeZero(), "a 304 must have an empty body")
|
|
}
|
|
},
|
|
Entry("placeholder is never cached and carries no validators",
|
|
testCase{img: placeholder(), wantCache: "no-store"}),
|
|
Entry("found with matching requested hash is immutable",
|
|
testCase{img: found(), requestedHash: testHash, wantCache: "public, max-age=31536000, immutable", wantETag: `"` + testHash + `"`, wantLastMod: true}),
|
|
Entry("found with bare id revalidates via no-cache",
|
|
testCase{img: found(), wantCache: "public, no-cache", wantETag: `"` + testHash + `"`, wantLastMod: true}),
|
|
Entry("found with mismatched requested hash revalidates",
|
|
testCase{img: found(), requestedHash: "ffffffffffffffff", wantCache: "public, no-cache", wantETag: `"` + testHash + `"`, wantLastMod: true}),
|
|
Entry("resized keeps pixel-hash immutable but serves the representation ETag",
|
|
testCase{img: resized(), requestedHash: testHash, wantCache: "public, max-age=31536000, immutable", wantETag: `"` + testRepTag + `"`, wantLastMod: true}),
|
|
Entry("resized 304s on the representation ETag, not the pixel hash",
|
|
testCase{img: resized(), ifNoneMatch: `"` + testRepTag + `"`, want304: true, wantCache: "public, no-cache", wantETag: `"` + testRepTag + `"`, wantLastMod: true}),
|
|
Entry("resized does not 304 on a stale pixel-hash validator (config changed)",
|
|
testCase{img: resized(), ifNoneMatch: `"` + testHash + `"`, want304: false, wantCache: "public, no-cache", wantETag: `"` + testRepTag + `"`, wantLastMod: true}),
|
|
Entry("If-None-Match matching the hash yields 304",
|
|
testCase{img: found(), requestedHash: testHash, ifNoneMatch: `"` + testHash + `"`, want304: true, wantCache: "public, max-age=31536000, immutable", wantETag: `"` + testHash + `"`, wantLastMod: true}),
|
|
Entry("weak If-None-Match matches (weak comparison)",
|
|
testCase{img: found(), ifNoneMatch: `W/"` + testHash + `"`, want304: true, wantCache: "public, no-cache", wantETag: `"` + testHash + `"`, wantLastMod: true}),
|
|
Entry("If-None-Match with multiple values matches one",
|
|
testCase{img: found(), ifNoneMatch: `"deadbeefdeadbeef", W/"` + testHash + `", "cafecafecafecafe"`, want304: true, wantCache: "public, no-cache", wantETag: `"` + testHash + `"`, wantLastMod: true}),
|
|
Entry("If-None-Match star matches any current representation",
|
|
testCase{img: found(), ifNoneMatch: "*", want304: true, wantCache: "public, no-cache", wantETag: `"` + testHash + `"`, wantLastMod: true}),
|
|
Entry("non-matching If-None-Match serves body",
|
|
testCase{img: found(), ifNoneMatch: `"deadbeefdeadbeef"`, want304: false, wantCache: "public, no-cache", wantETag: `"` + testHash + `"`, wantLastMod: true}),
|
|
Entry("placeholder ignores If-None-Match and never 304s",
|
|
testCase{img: placeholder(), ifNoneMatch: "*", want304: false, wantCache: "no-store"}),
|
|
// A stand-in shares the final representation's ETag inputs, so a validator would pin it.
|
|
Entry("transient stand-in is never cached and carries no validators",
|
|
testCase{img: transient(), ifNoneMatch: `"` + testRepTag + `"`, want304: false, wantCache: "no-store"}),
|
|
|
|
// With no validator, an emitted ETag would be the same empty tag on every such response,
|
|
// and matching it would 304 changed bytes.
|
|
Entry("omits the ETag entirely when there is no validator",
|
|
testCase{img: unvalidated(), wantCache: "public, no-cache", wantLastMod: true}),
|
|
Entry("never 304s an empty validator echoed back by the client",
|
|
testCase{img: unvalidated(), ifNoneMatch: `""`, want304: false,
|
|
wantCache: "public, no-cache", wantLastMod: true}),
|
|
)
|
|
})
|