navidrome/core/artwork/processor.go
Deluan d4399d9492 refactor(artwork): derive blurhash components inside Encode
Components had a single caller, which only ever fed it the bounds of the image
it then passed to Encode. Exporting it gave callers two ways to get it wrong —
components mismatched with the image, or out of the 1..9 range — in exchange for
a knob nobody turned.

Derive them from img.Bounds() at the top of Encode and unexport the helper. The
counts must come from the pre-downscale bounds: downscale's integer rounding can
shift the ratio across a component boundary, and the hash is a client-side cache
key. Verified byte-identical over 18 hashes spanning 9 aspect ratios.

The out-of-range validation goes with it, being unreachable once the counts are
always derived. The aspect-ratio table now asserts through Encode's size flag,
which encodes (x-1)+(y-1)*9.
2026-07-28 21:39:13 -04:00

297 lines
9.8 KiB
Go

package artwork
import (
"bytes"
"context"
"errors"
"fmt"
"image"
"image/draw"
_ "image/gif" // the only artwork format with no other importer in this package
"io"
"sync"
"time"
"github.com/navidrome/navidrome/core/artwork/blurhash"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model"
xdraw "golang.org/x/image/draw"
)
// outcome tells the worker whether to delete the queue row (found/absent) or reschedule it.
type outcome int
const (
outcomeFound outcome = iota
// outcomeFoundStale: state was written and is served, but a higher-priority external
// source failed, so the row must retry to give it another chance.
outcomeFoundStale
outcomeAbsent
outcomeFailed
)
func (o outcome) String() string {
switch o {
case outcomeFound:
return "found"
case outcomeFoundStale:
return "foundStale"
case outcomeAbsent:
return "absent"
default:
return "failed"
}
}
// thumbnailSize is the max dimension fed to blurhash.
const thumbnailSize = 128
// maxImageBytes caps a resolved image read: a user-editable ExternalImageURL could point at
// an arbitrarily large endpoint.
const maxImageBytes = 20 << 20
// maxImagePixels guards against decompression bombs: a tiny file can declare a canvas that
// image.Decode would expand into gigabytes.
const maxImagePixels = 64 << 20
// acquired is what a successful acquire persisted, handed back so the caller can warm the resize
// cache without re-reading the rows and the file it just wrote.
type acquired struct {
ia *model.ItemArtwork
mime string
data []byte
}
// processor turns one queue item into stored artwork; settling the queue row is the Worker's job.
type processor struct {
ds model.DataStore
store *ImageStore
resolver *resolver
pruneLock sync.Locker
}
// acquire resolves one queue item end to end: find an image, hash/decode/
// blurhash it, place its bytes, and persist the resulting state.
func (p *processor) acquire(ctx context.Context, item model.ArtworkQueueItem) (out outcome, got *acquired) {
repo := p.ds.Artwork(ctx)
start := time.Now()
defer func() {
log.Debug(ctx, "Artwork: Acquisition finished", "kind", item.ItemKind, "id", item.ItemID,
"outcome", out, "elapsed", time.Since(start))
}()
res, err := p.resolver.resolve(ctx, item)
if err != nil {
log.Warn(ctx, "Artwork: Could not resolve item", "kind", item.ItemKind, "id", item.ItemID, err)
return outcomeFailed, nil
}
if res.reader == nil {
if res.extError || res.localError {
// A fault is not a definitive "no image": never settle absent, keep serving old state.
log.Debug(ctx, "Artwork: No image, but a source faulted; keeping previous state",
"kind", item.ItemKind, "id", item.ItemID, "extError", res.extError, "localError", res.localError)
return outcomeFailed, nil
}
return writeAbsent(ctx, repo, item), nil
}
defer res.reader.Close()
readStart := time.Now()
data, err := readCapped(res.reader)
if err != nil {
log.Warn(ctx, "Artwork: Failed to read resolved image", "kind", item.ItemKind, "id", item.ItemID, "source", res.source, err)
return outcomeFailed, nil
}
log.Debug(ctx, "Artwork: Read resolved image", "kind", item.ItemKind, "id", item.ItemID,
"source", res.source, "bytes", len(data), "elapsed", time.Since(readStart))
hashStart := time.Now()
hash, err := hashImage(bytes.NewReader(data))
if err != nil {
log.Warn(ctx, "Artwork: Failed to hash image", "kind", item.ItemKind, "id", item.ItemID, err)
return outcomeFailed, nil
}
log.Trace(ctx, "Artwork: Hashed image", "kind", item.ItemKind, "id", item.ItemID,
"hash", hash, "bytes", len(data), "elapsed", time.Since(hashStart))
art, err := repo.GetImage(hash)
switch {
case err == nil:
log.Debug(ctx, "Artwork: Reusing a known image, skipping decode", "kind", item.ItemKind,
"id", item.ItemID, "hash", hash)
case errors.Is(err, model.ErrNotFound):
decodeStart := time.Now()
art, err = decodeArtwork(ctx, hash, data)
if err != nil {
log.Warn(ctx, "Artwork: Failed to decode resolved image", "kind", item.ItemKind, "id", item.ItemID, err)
return outcomeFailed, nil
}
log.Debug(ctx, "Artwork: Decoded new image", "kind", item.ItemKind, "id", item.ItemID, "hash", hash,
"width", art.Width, "height", art.Height, "mime", art.Mime, "elapsed", time.Since(decodeStart))
default:
log.Warn(ctx, "Artwork: Failed to look up image hash", "kind", item.ItemKind, "id", item.ItemID, err)
return outcomeFailed, nil
}
art.SizeBytes = int64(len(data))
ia, err := p.persist(repo, item, art, res, data)
if err != nil {
log.Warn(ctx, "Artwork: Failed to persist resolved image", "kind", item.ItemKind, "id", item.ItemID, err)
return outcomeFailed, nil
}
got = &acquired{ia: ia, mime: art.Mime, data: data}
if res.extError {
log.Debug(ctx, "Artwork: Serving a lower-priority source after an external failure",
"kind", item.ItemKind, "id", item.ItemID, "source", res.source)
return outcomeFoundStale, got
}
return outcomeFound, got
}
// persist places the bytes and commits the rows referencing them, excluding Prune for that
// window only so a slow resolution can never hold it off.
func (p *processor) persist(repo model.ArtworkRepository, item model.ArtworkQueueItem,
art *model.Artwork, res resolution, data []byte,
) (*model.ItemArtwork, error) {
if p.pruneLock != nil {
p.pruneLock.Lock()
defer p.pruneLock.Unlock()
}
sourcePath, refMtime, err := placeBytes(p.store, art, res, data)
if err != nil {
return nil, fmt.Errorf("writing image store: %w", err)
}
if err := repo.PutImage(art); err != nil {
return nil, fmt.Errorf("persisting artwork image: %w", err)
}
ia := &model.ItemArtwork{
ItemKind: item.ItemKind,
ItemID: item.ItemID,
ImageType: item.ImageType,
Hash: art.Hash,
Source: res.source,
SourcePath: sourcePath,
RefMtime: refMtime,
AttemptedAt: time.Now(),
}
// PutItemArtwork stamps UpdatedAt on ia, so the returned struct matches the persisted row.
if err := repo.PutItemArtwork(ia); err != nil {
return nil, fmt.Errorf("persisting item artwork state: %w", err)
}
return ia, nil
}
// writeAbsent records a known-absent state: every source answered definitively "no".
func writeAbsent(ctx context.Context, repo model.ArtworkRepository, item model.ArtworkQueueItem) outcome {
err := repo.PutItemArtwork(&model.ItemArtwork{
ItemKind: item.ItemKind,
ItemID: item.ItemID,
ImageType: item.ImageType,
AttemptedAt: time.Now(),
})
if err != nil {
log.Warn(ctx, "Artwork: Failed to persist absent state", "kind", item.ItemKind, "id", item.ItemID, err)
return outcomeFailed
}
log.Debug(ctx, "Artwork: Settled absent, every source answered definitively",
"kind", item.ItemKind, "id", item.ItemID)
return outcomeAbsent
}
func readCapped(r io.Reader) ([]byte, error) {
data, err := io.ReadAll(io.LimitReader(r, maxImageBytes+1))
if err != nil {
return nil, err
}
if len(data) > maxImageBytes {
return nil, fmt.Errorf("image exceeds size cap %d", maxImageBytes)
}
return data, nil
}
// decodeCapped rejects declared dimensions over maxImagePixels before the full-decode allocation.
func decodeCapped(data []byte) (image.Image, string, error) {
cfg, format, err := image.DecodeConfig(bytes.NewReader(data))
if err != nil {
return nil, "", fmt.Errorf("decode image config: %w", err)
}
// Compared by division so the cap cannot be defeated by an int64 overflow.
if cfg.Width <= 0 || cfg.Height <= 0 || cfg.Width > maxImagePixels/cfg.Height {
return nil, "", fmt.Errorf("image dimensions %dx%d exceed pixel cap %d", cfg.Width, cfg.Height, maxImagePixels)
}
img, _, err := image.Decode(bytes.NewReader(data))
if err != nil {
return nil, "", fmt.Errorf("decode image: %w", err)
}
return img, format, nil
}
// decodeArtwork builds a new Artwork row from raw bytes: dimensions, mime and blurhash.
func decodeArtwork(ctx context.Context, hash string, data []byte) (*model.Artwork, error) {
img, format, err := decodeCapped(data)
if err != nil {
return nil, err
}
thumb := makeThumbnail(img, thumbnailSize)
bh, err := blurhash.Encode(thumb)
if err != nil {
log.Warn(ctx, "Artwork: Blurhash encoding failed", "hash", hash, err)
bh = ""
}
return &model.Artwork{
Hash: hash,
Mime: mimeForFormat(format),
Width: img.Bounds().Dx(),
Height: img.Bounds().Dy(),
BlurHash: bh,
}, nil
}
// makeThumbnail downscales img to fit within maxSize on its longest side; it never upscales.
func makeThumbnail(img image.Image, maxSize int) image.Image {
b := img.Bounds()
w, h := b.Dx(), b.Dy()
if w <= maxSize && h <= maxSize {
return toFastScaleType(img)
}
scale := float64(maxSize) / float64(max(w, h))
dst := image.NewRGBA(image.Rect(0, 0, max(1, int(float64(w)*scale)), max(1, int(float64(h)*scale))))
xdraw.CatmullRom.Scale(dst, dst.Bounds(), toFastScaleType(img), b, draw.Src, nil)
return dst
}
// isFileBacked reports whether the bytes already live in a library/upload file, so the
// content-addressed store must not duplicate them.
func isFileBacked(source string) bool {
return source == "folder" || source == "upload"
}
// placeBytes reports the item's backing-file provenance and writes the bytes into the store
// for the sources that have none.
func placeBytes(store *ImageStore, art *model.Artwork, res resolution, data []byte) (sourcePath string, refMtime int64, err error) {
if isFileBacked(res.source) {
return res.sourcePath, res.refMtime, nil
}
if res.source == "embedded" {
sourcePath, refMtime = res.sourcePath, res.refMtime
}
return sourcePath, refMtime, store.Write(art.Hash, art.Mime, bytes.NewReader(data))
}
// mimeForFormat maps an image.Decode format name to its MIME type; extForMime is the inverse.
func mimeForFormat(format string) string {
switch format {
case "jpeg":
return "image/jpeg"
case "png":
return "image/png"
case "gif":
return "image/gif"
case "webp":
return "image/webp"
}
return "application/octet-stream"
}