package artwork
import (
"bytes"
"cmp"
"context"
"encoding/base64"
"errors"
"fmt"
"image"
"image/draw"
_ "image/gif"
"io"
"sync"
"time"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/consts"
"github.com/navidrome/navidrome/core/agents"
"github.com/navidrome/navidrome/core/artwork/blurhash"
"github.com/navidrome/navidrome/core/artwork/dominant"
"github.com/navidrome/navidrome/core/artwork/thumbhash"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model"
xdraw "golang.org/x/image/draw"
)
type outcome int
const (
outcomeFound outcome = iota
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"
}
}
const thumbnailSize = 100
func maxImageBytes() int64 {
return parseSize(conf.Server.MaxImageSize, consts.DefaultMaxImageSize)
}
const maxImagePixels = 64 << 20
type acquired struct {
ia *model.ItemArtwork
mime string
data []byte
}
type processor struct {
ds model.DataStore
store *ImageStore
resolver *resolver
pruneLock sync.Locker
}
func (p *processor) acquire(ctx context.Context, item model.ArtworkQueueItem) (out outcome, got *acquired, retryIn time.Duration) {
repo := p.ds.Artwork()
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 {
traceStage(ctx, "resolve", err)
log.Warn(ctx, "Artwork: Could not resolve item", "kind", item.ItemKind, "id", item.ItemID, err)
return outcomeFailed, nil, 0
}
if retry, ok := errors.AsType[*agents.RetryLaterError](res.extErr); ok {
retryIn = retry.RetryIn
}
if res.reader == nil {
if res.extErr != nil || res.localError {
if t := traceFrom(ctx); len(t.Steps()) == 0 {
outcome := OutcomeError
if res.localError {
outcome = OutcomeUnreadable
}
t.add(TraceStep{Candidate: cmp.Or(res.source, "source"), Outcome: outcome})
}
log.Debug(ctx, "Artwork: No image, but a source faulted; keeping previous state",
"kind", item.ItemKind, "id", item.ItemID, "extErr", res.extErr, "localError", res.localError)
return outcomeFailed, nil, retryIn
}
return writeAbsent(ctx, repo, item), nil, 0
}
defer res.reader.Close()
readStart := time.Now()
data, err := readCapped(res.reader)
if err != nil {
traceStage(ctx, "read", err)
log.Warn(ctx, "Artwork: Failed to read resolved image", "kind", item.ItemKind, "id", item.ItemID, "source", res.source, err)
return outcomeFailed, nil, retryIn
}
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 {
traceStage(ctx, "hash", err)
log.Warn(ctx, "Artwork: Failed to hash image", "kind", item.ItemKind, "id", item.ItemID, err)
return outcomeFailed, nil, retryIn
}
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(ctx, hash)
switch {
case err == nil && art.Width > 0:
log.Debug(ctx, "Artwork: Reusing a known image, skipping decode", "kind", item.ItemKind,
"id", item.ItemID, "hash", hash)
case err == nil, errors.Is(err, model.ErrNotFound):
decodeStart := time.Now()
art, err = decodeArtwork(ctx, hash, data)
if errors.Is(err, image.ErrFormat) && len(data) > 0 && isLocalSource(res.source) {
log.Debug(ctx, "Artwork: No decoder for this image format, storing it without placeholders",
"kind", item.ItemKind, "id", item.ItemID, "source", res.source, "bytes", len(data))
art, err = undecodedArtwork(hash), nil
}
if err != nil {
traceStage(ctx, "decode", err)
log.Warn(ctx, "Artwork: Failed to decode resolved image", "kind", item.ItemKind, "id", item.ItemID, err)
return outcomeFailed, nil, retryIn
}
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:
traceStage(ctx, "lookup", err)
log.Warn(ctx, "Artwork: Failed to look up image hash", "kind", item.ItemKind, "id", item.ItemID, err)
return outcomeFailed, nil, retryIn
}
art.SizeBytes = int64(len(data))
ia, err := p.persist(ctx, repo, item, art, res, data)
if err != nil {
traceStage(ctx, "store", err)
log.Warn(ctx, "Artwork: Failed to persist resolved image", "kind", item.ItemKind, "id", item.ItemID, err)
return outcomeFailed, nil, retryIn
}
got = &acquired{ia: ia, mime: art.Mime, data: data}
if res.extErr != nil {
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, retryIn
}
return outcomeFound, got, retryIn
}
func (p *processor) persist(ctx context.Context, 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(ctx, 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(),
Trace: traceFrom(ctx).encode(sourcePath),
}
if err := repo.PutItemArtwork(ctx, ia); err != nil {
return nil, fmt.Errorf("persisting item artwork state: %w", err)
}
return ia, nil
}
func writeAbsent(ctx context.Context, repo model.ArtworkRepository, item model.ArtworkQueueItem) outcome {
err := repo.PutItemArtwork(ctx, &model.ItemArtwork{
ItemKind: item.ItemKind,
ItemID: item.ItemID,
ImageType: item.ImageType,
AttemptedAt: time.Now(),
Trace: traceFrom(ctx).encode(""),
})
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) {
limit := maxImageBytes()
data, err := io.ReadAll(io.LimitReader(r, limit+1))
if err != nil {
return nil, err
}
if int64(len(data)) > limit {
return nil, fmt.Errorf("image exceeds size cap %d", limit)
}
return data, nil
}
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)
}
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
}
func undecodedArtwork(hash string) *model.Artwork {
return &model.Artwork{Hash: hash, Mime: mimeForFormat("")}
}
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 = ""
}
var th string
if raw, err := thumbhash.Encode(thumb); err != nil {
log.Warn(ctx, "Artwork: Thumbhash encoding failed", "hash", hash, err)
} else {
th = base64.StdEncoding.EncodeToString(raw)
}
return &model.Artwork{
Hash: hash,
Mime: mimeForFormat(format),
Width: img.Bounds().Dx(),
Height: img.Bounds().Dy(),
BlurHash: bh,
ThumbHash: th,
DominantColor: dominant.Color(thumb),
}, nil
}
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.NewNRGBA(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
}
func isFileBacked(source string) bool {
return source == "folder" || source == "upload"
}
func isLocalSource(source string) bool {
return isFileBacked(source) || source == "embedded"
}
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))
}
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"
}