diff --git a/core/artwork/artwork.go b/core/artwork/artwork.go index 077ca31d4..9b89230b9 100644 --- a/core/artwork/artwork.go +++ b/core/artwork/artwork.go @@ -104,9 +104,10 @@ func (a *artwork) Get(ctx context.Context, artID model.ArtworkID, size int, squa if a.blurHashes != nil && size == 0 && !square && eligibleKind(artID) { // Tee the served bytes: the blurhash is computed from exactly what the client downloads, so it // changes precisely when the served cover changes. Placeholder bytes (playlist fallback) clear. + // The tee wraps r directly, so Close reaches the underlying stream (no fd leak). version := capAtNow(artReader.LastUpdated()) - reader = &teeCachedStream{CachedStream: r, tee: newTeeReader(io.NopCloser(r), maxTeeBytes, - func(data []byte) { a.blurHashes.EnqueueBytes(artID, data, version) })} + reader = newTeeReader(r, maxTeeBytes, + func(data []byte) { a.blurHashes.EnqueueBytes(artID, data, version) }) } return reader, artReader.LastUpdated(), nil } diff --git a/core/artwork/blurhash_tee.go b/core/artwork/tee_reader.go similarity index 61% rename from core/artwork/blurhash_tee.go rename to core/artwork/tee_reader.go index 60c0d3661..e55592ab0 100644 --- a/core/artwork/blurhash_tee.go +++ b/core/artwork/tee_reader.go @@ -3,13 +3,11 @@ package artwork import ( "bytes" "io" - - "github.com/navidrome/navidrome/utils/cache" ) // teeReader mirrors bytes read from src into buf, and on Close invokes onComplete with the captured // bytes only if the stream was fully consumed (EOF) and stayed within maxBytes. Partial reads and -// oversized streams are skipped, so a hash is only ever computed from a complete, bounded image. +// oversized streams are skipped, so the callback only ever receives a complete, bounded payload. type teeReader struct { src io.ReadCloser buf bytes.Buffer @@ -48,14 +46,3 @@ func (t *teeReader) Close() error { } return err } - -// teeCachedStream wraps a *cache.CachedStream so reads are teed for blurhash capture while callers -// still see a ReadCloser. Seek is intentionally dropped: blurhash-eligible serves are full reads -// (every artwork handler does io.Copy), so no caller Seeks a teed stream. -type teeCachedStream struct { - *cache.CachedStream - tee *teeReader -} - -func (t *teeCachedStream) Read(p []byte) (int, error) { return t.tee.Read(p) } -func (t *teeCachedStream) Close() error { return t.tee.Close() } diff --git a/core/artwork/blurhash_tee_internal_test.go b/core/artwork/tee_reader_internal_test.go similarity index 73% rename from core/artwork/blurhash_tee_internal_test.go rename to core/artwork/tee_reader_internal_test.go index 595bbb099..64cfecf19 100644 --- a/core/artwork/blurhash_tee_internal_test.go +++ b/core/artwork/tee_reader_internal_test.go @@ -8,7 +8,23 @@ import ( . "github.com/onsi/gomega" ) +type closeSpy struct { + io.Reader + closed bool +} + +func (c *closeSpy) Close() error { c.closed = true; return nil } + var _ = Describe("teeReader", func() { + It("closes the underlying source exactly once", func() { + src := &closeSpy{Reader: bytes.NewReader([]byte("hello"))} + tr := newTeeReader(src, 1024, func([]byte) {}) + _, err := io.ReadAll(tr) + Expect(err).ToNot(HaveOccurred()) + Expect(tr.Close()).To(Succeed()) + Expect(src.closed).To(BeTrue(), "the source stream must be closed, or its fd leaks") + }) + It("calls onComplete with the full bytes after a complete read+close", func() { var got []byte src := io.NopCloser(bytes.NewReader([]byte("hello world")))