mirror of
https://github.com/navidrome/navidrome.git
synced 2026-08-01 07:21:17 +00:00
Comments only; no executable code changed. Verified by comparing the Go
token stream of every touched file before and after: identical.
Removes 375 of the 1104 comment lines this branch added, targeting content
that belongs in a commit message or PR body rather than in the code:
rejected alternatives ("DeleteIfUnchanged, not Delete", "Waking all beats
routing by kind"), refactor history ("as the legacy reader did"), issue
references (#5798, #5597, #5376), benchmark numbers (~400ms, ~16k allocs),
and four persistence doc comments that duplicated the interface godoc in
model/artwork.go verbatim.
Comments predating this branch are left untouched.
The ASCII fixture trees in the e2e suites are deliberately kept above the
line budget: they diagram the fixture layout with its expected outcomes,
and every pre-existing block in those files carries one.
298 lines
9.9 KiB
Go
298 lines
9.9 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)
|
|
xComp, yComp := blurhash.Components(thumb.Bounds().Dx(), thumb.Bounds().Dy())
|
|
bh, err := blurhash.Encode(thumb, xComp, yComp)
|
|
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"
|
|
}
|