mirror of
https://github.com/navidrome/navidrome.git
synced 2026-10-15 05:47:13 +02:00
The deleted artwork.go carried blank imports for image/gif and x/image/webp. WebP came back via resize.go's gen2brain/webp, which self-registers, but GIF did not: core/artwork claims GIF support in mimeForFormat and extForMime while relying on an unrelated server package to have imported the decoder. The guard lives in the e2e suite because that test binary has no other image/gif importer; a spec in core/artwork would pass regardless, since animation_test.go imports the package non-blank.
249 lines
8.3 KiB
Go
249 lines
8.3 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"
|
|
"time"
|
|
|
|
"github.com/navidrome/navidrome/core/agents"
|
|
"github.com/navidrome/navidrome/core/artwork/blurhash"
|
|
"github.com/navidrome/navidrome/core/ffmpeg"
|
|
"github.com/navidrome/navidrome/log"
|
|
"github.com/navidrome/navidrome/model"
|
|
"github.com/navidrome/navidrome/utils/cache"
|
|
xdraw "golang.org/x/image/draw"
|
|
)
|
|
|
|
// outcome tells the worker what to do with the queue row: found/absent
|
|
// delete it, failed reschedules it via MarkFailed.
|
|
type outcome int
|
|
|
|
const (
|
|
outcomeFound outcome = iota
|
|
// outcomeFoundStale: state was written and is served, but a higher-priority external
|
|
// step failed, so the row must retry (via MarkFailed) to give that source another chance.
|
|
outcomeFoundStale
|
|
outcomeAbsent
|
|
outcomeFailed
|
|
)
|
|
|
|
// 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, and 20MB is generous for any real cover.
|
|
const maxImageBytes = 20 << 20
|
|
|
|
// maxImagePixels caps declared dimensions: a tiny compressed file can declare a
|
|
// huge canvas that image.Decode would expand into gigabytes (decompression bomb).
|
|
const maxImagePixels = 64 << 20
|
|
|
|
// workerDeps are the collaborators processItem needs; gate is set by NewWorker in
|
|
// production and nil only in tests, where resolveItem falls back to a plain passthrough.
|
|
type workerDeps struct {
|
|
ds model.DataStore
|
|
store *ImageStore
|
|
agents *agents.Agents
|
|
ffmpeg ffmpeg.FFmpeg
|
|
cache cache.FileCache
|
|
gate gateFunc
|
|
}
|
|
|
|
// processItem resolves one queue item end to end: find an image, hash/decode/
|
|
// blurhash it, place its bytes, and persist the resulting state.
|
|
func processItem(ctx context.Context, deps *workerDeps, item model.ArtworkQueueItem) outcome {
|
|
repo := deps.ds.Artwork(ctx)
|
|
|
|
res, err := resolveItem(ctx, deps.ds, deps.agents, deps.ffmpeg, item, deps.gate)
|
|
if err != nil {
|
|
log.Warn(ctx, "artwork: could not resolve item", "kind", item.ItemKind, "id", item.ItemID, err)
|
|
return outcomeFailed
|
|
}
|
|
if res.reader == nil {
|
|
if res.extError {
|
|
// An external source errored/timed out: never settle on absent, keep serving old state.
|
|
return outcomeFailed
|
|
}
|
|
return writeAbsent(ctx, repo, item)
|
|
}
|
|
defer res.reader.Close()
|
|
|
|
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
|
|
}
|
|
log.Debug(ctx, "artwork: read resolved image", "kind", item.ItemKind, "id", item.ItemID, "source", res.source, "bytes", len(data))
|
|
|
|
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
|
|
}
|
|
|
|
art, err := repo.GetImage(hash)
|
|
switch {
|
|
case err == nil:
|
|
// Dedup hit: identical bytes already known, reuse dims/mime/blurhash.
|
|
case errors.Is(err, model.ErrNotFound):
|
|
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
|
|
}
|
|
default:
|
|
log.Warn(ctx, "artwork: failed to look up image hash", "kind", item.ItemKind, "id", item.ItemID, err)
|
|
return outcomeFailed
|
|
}
|
|
art.SizeBytes = int64(len(data))
|
|
|
|
sourcePath, refMtime, err := placeBytes(deps.store, art, res, data)
|
|
if err != nil {
|
|
log.Warn(ctx, "artwork: failed to write image store", "kind", item.ItemKind, "id", item.ItemID, err)
|
|
return outcomeFailed
|
|
}
|
|
if err := repo.PutImage(art); err != nil {
|
|
log.Warn(ctx, "artwork: failed to persist artwork image", "kind", item.ItemKind, "id", item.ItemID, err)
|
|
return outcomeFailed
|
|
}
|
|
if err := repo.PutItemArtwork(&model.ItemArtwork{
|
|
ItemKind: item.ItemKind,
|
|
ItemID: item.ItemID,
|
|
ImageType: item.ImageType,
|
|
Hash: hash,
|
|
Source: res.source,
|
|
SourcePath: sourcePath,
|
|
RefMtime: refMtime,
|
|
AttemptedAt: time.Now(),
|
|
}); err != nil {
|
|
log.Warn(ctx, "artwork: failed to persist item artwork state", "kind", item.ItemKind, "id", item.ItemID, err)
|
|
return outcomeFailed
|
|
}
|
|
if res.extError {
|
|
return outcomeFoundStale
|
|
}
|
|
return outcomeFound
|
|
}
|
|
|
|
// writeAbsent records a known-absent state: every local/external 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
|
|
}
|
|
return outcomeAbsent
|
|
}
|
|
|
|
// readCapped reads r, rejecting anything over maxImageBytes.
|
|
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, then decodes.
|
|
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 int64(cfg.Width)*int64(cfg.Height) > maxImagePixels {
|
|
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 a
|
|
// blurhash computed from a downscaled thumbnail.
|
|
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.
|
|
// Images within bounds are returned as-is (no upscaling).
|
|
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 a resolution's bytes already live in a library/upload
|
|
// file, so the acquisition must not duplicate them into the content-addressed store.
|
|
func isFileBacked(source string) bool {
|
|
return source == "folder" || source == "upload"
|
|
}
|
|
|
|
// placeBytes reports the item's backing-file provenance (folder/upload: image, embedded: audio,
|
|
// external/generated: none) and writes the bytes into the store for the non-file-backed sources.
|
|
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
|
|
// in image_store.go performs the inverse for content-addressed file paths.
|
|
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"
|
|
}
|