🏡 index : quine-global/quintodrome.git

package artwork

import (
	"bytes"
	"context"
	"errors"
	"fmt"
	"io"
	"os"
	"time"

	"github.com/navidrome/navidrome/conf"
	"github.com/navidrome/navidrome/consts"
	"github.com/navidrome/navidrome/core/agents"
	"github.com/navidrome/navidrome/core/ffmpeg"
	"github.com/navidrome/navidrome/log"
	"github.com/navidrome/navidrome/model"
	"github.com/navidrome/navidrome/resources"
	"github.com/navidrome/navidrome/utils/cache"
)

var ErrUnavailable = errors.New("artwork unavailable")

// errStaleSource means the backing file's mtime no longer matches RefMtime, so the stored hash may be stale.
var errStaleSource = errors.New("artwork: source file changed since resolution")

// Image is one servable artwork response.
type Image struct {
	io.ReadCloser
	Hash        string // pixel identity; "" for placeholders
	ETag        string // representation validator; "" means Hash applies (full-size original)
	LastUpdated time.Time
	Placeholder bool
}

// representationTag varies with dimensions and encode settings, so a config change invalidates
// a revalidating client's cache even though the pixel hash is unchanged.
func representationTag(hash string, size int, square bool) string {
	return fmt.Sprintf("%s.%d.%v.%s", hash, size, square, formatQualityTag())
}

type Artwork interface {
	// Get returns ErrUnavailable when there is nothing to serve and model.ErrNotFound when
	// the id resolves to nothing, so the caller can pick placeholder vs 404.
	Get(ctx context.Context, artID model.ArtworkID, size int, square bool) (*Image, error)
	// GetOrPlaceholder accepts an artwork token or a raw entity id, falling back to the
	// kind's placeholder image (never resized, Placeholder=true).
	GetOrPlaceholder(ctx context.Context, id string, size int, square bool) (*Image, error)
}

func NewArtwork(ds model.DataStore, cache cache.FileCache, store *ImageStore, ffm ffmpeg.FFmpeg) Artwork {
	return &service{ds: ds, cache: cache, store: store, ffmpeg: ffm}
}

// entityExists reports whether the entity an artwork id points at is still there: state rows
// outlive a deleted entity until the next prune, so a servable row is not evidence of its owner.
func entityExists(ctx context.Context, ds model.DataStore, artID model.ArtworkID) bool {
	var found bool
	var err error
	switch artID.Kind {
	case model.KindArtistArtwork:
		found, err = ds.Artist().Exists(ctx, artID.ID)
	case model.KindAlbumArtwork:
		found, err = ds.Album().Exists(ctx, artID.ID)
	case model.KindMediaFileArtwork:
		found, err = ds.MediaFile().Exists(ctx, artID.ID)
	case model.KindPlaylistArtwork:
		found, err = ds.Playlist().Exists(ctx, artID.ID)
	case model.KindRadioArtwork:
		found, err = ds.Radio().Exists(ctx, artID.ID)
	case model.KindDiscArtwork:
		albumID, _, perr := model.ParseDiscArtworkID(artID.ID)
		if perr != nil {
			return false
		}
		found, err = ds.Album().Exists(ctx, albumID)
	default:
		return false
	}
	return err == nil && found
}

type service struct {
	ds     model.DataStore
	cache  cache.FileCache
	store  *ImageStore
	ffmpeg ffmpeg.FFmpeg
}

func (s *service) GetOrPlaceholder(ctx context.Context, id string, size int, square bool) (*Image, error) {
	artID, err := s.parseArtworkID(ctx, id)
	var img *Image
	if err == nil {
		img, err = s.Get(ctx, artID, size, square)
	}
	// Only a resolvable entity with no art gets a placeholder; an unknown id must stay
	// ErrNotFound so callers can still answer 404 / Subsonic error 70.
	if errors.Is(err, ErrUnavailable) {
		return placeholderImage(artID.Kind), nil
	}
	return img, err
}

func (s *service) Get(ctx context.Context, artID model.ArtworkID, size int, square bool) (*Image, error) {
	if artID.ID == "" {
		return nil, ErrUnavailable
	}
	if size < 0 {
		size = 0 // a negative size means full-size, not a giant (OOM) resize rectangle
	}
	switch artID.Kind {
	case model.KindDiscArtwork:
		return s.serveDisc(ctx, artID, size, square)
	case model.KindMediaFileArtwork:
		return s.serveMediaFile(ctx, artID, size, square)
	default:
		return s.serveEntity(ctx, artID, size, square)
	}
}

func (s *service) serveEntity(ctx context.Context, artID model.ArtworkID, size int, square bool) (*Image, error) {
	ia, err := s.ds.Artwork().GetItemArtwork(ctx, artID.Kind, artID.ID, model.ImageTypePrimary)
	switch {
	case errors.Is(err, model.ErrNotFound):
		return s.provisional(ctx, artID, size, square)
	case err != nil:
		return nil, err
	case ia.Hash == "":
		// Settled absent: only an explicit reprocess or refresh retries it.
		return nil, ErrUnavailable
	default:
		return s.serveHash(ctx, artID, ia, size, square)
	}
}

// serveSource is the one place bytes become an Image. hash is the pixel identity ("" for disc art)
// and doubles as the full-size validator, so an ETag is only needed when resized or hash is "".
func (s *service) serveSource(ctx context.Context, key, hash string, lastUpdate time.Time,
	size int, square bool, open func() (io.ReadCloser, error),
) (*Image, error) {
	if size == 0 && !square {
		rc, err := open()
		if err != nil {
			return nil, err
		}
		if rc == nil {
			return nil, ErrUnavailable
		}
		img := &Image{ReadCloser: rc, Hash: hash, LastUpdated: lastUpdate}
		if hash == "" {
			img.ETag = representationTag(key, size, square)
		}
		return img, nil
	}
	stream, err := s.cache.Get(ctx, &resizedItem{
		hash: key, size: size, square: square, ffmpeg: s.ffmpeg, open: open,
	})
	if err != nil {
		return nil, err
	}
	return &Image{ReadCloser: stream, Hash: hash, ETag: representationTag(key, size, square), LastUpdated: lastUpdate}, nil
}

// serveHash serves the bytes of a found state row. A mismatch/open error is dangling, but a
// cancelled request is not: it must not enqueue a re-resolution.
func (s *service) serveHash(ctx context.Context, artID model.ArtworkID, ia *model.ItemArtwork, size int, square bool) (*Image, error) {
	// Only this path can hand back a deleted entity's bytes; the others load their entity anyway.
	if !entityExists(ctx, s.ds, artID) {
		return nil, ErrUnavailable
	}
	// Checked here, not in openOriginal: a resize-cache hit never opens the source.
	if isFileBacked(ia.Source) && !model.IsImageFile(ia.SourcePath) {
		log.Warn(ctx, "Artwork: Stored source is not an image file, re-resolving", "artID", artID, "path", ia.SourcePath)
		return s.dangling(ctx, artID)
	}
	art, err := s.ds.Artwork().GetImage(ctx, ia.Hash)
	if err != nil {
		if errors.Is(err, model.ErrNotFound) {
			return s.dangling(ctx, artID)
		}
		return nil, err
	}
	img, err := s.serveSource(ctx, ia.Hash, ia.Hash, ia.UpdatedAt, size, square,
		func() (io.ReadCloser, error) { return openOriginal(ia, art.Mime, s.store) })
	if err != nil {
		if errors.Is(err, context.Canceled) {
			return nil, err
		}
		log.Warn(ctx, "Artwork: Could not serve image", "artID", artID, "size", size, err)
		return s.dangling(ctx, artID)
	}
	return img, nil
}

// openOriginal enforces the mtime invariant: bytes are never served under a hash they no longer match.
func openOriginal(ia *model.ItemArtwork, mime string, store *ImageStore) (io.ReadCloser, error) {
	if isFileBacked(ia.Source) {
		f, err := os.Open(ia.SourcePath)
		if err != nil {
			return nil, err
		}
		info, err := f.Stat()
		if err != nil {
			f.Close()
			return nil, err
		}
		if ia.RefMtime != 0 && info.ModTime().UnixNano() != ia.RefMtime {
			f.Close()
			log.Debug("Artwork: Backing file changed since resolution", "path", ia.SourcePath,
				"hash", ia.Hash, "resolvedMtime", ia.RefMtime, "currentMtime", info.ModTime().UnixNano())
			return nil, errStaleSource
		}
		return f, nil
	}
	// Store-backed bytes still carry the source's mtime, to detect edits to embedded art.
	if ia.SourcePath != "" && ia.RefMtime != 0 {
		info, err := os.Stat(ia.SourcePath)
		if err != nil {
			return nil, err
		}
		if info.ModTime().UnixNano() != ia.RefMtime {
			log.Debug("Artwork: Source file changed since resolution", "path", ia.SourcePath,
				"hash", ia.Hash, "resolvedMtime", ia.RefMtime, "currentMtime", info.ModTime().UnixNano())
			return nil, errStaleSource
		}
	}
	return store.Open(ia.Hash, mime)
}

// provisional serves local bytes for an entity with no state row, enqueuing the worker but
// never writing a state row itself.
func (s *service) provisional(ctx context.Context, artID model.ArtworkID, size int, square bool) (*Image, error) {
	item := model.ArtworkQueueItem{ItemKind: artID.Kind.Prefix(), ItemID: artID.ID, ImageType: model.ImageTypePrimary}
	res, err := newLocalResolver(s.ds, s.ffmpeg).resolve(ctx, item)
	if err != nil {
		return nil, err
	}
	s.enqueue(ctx, artID, model.ArtworkPriorityBump)
	log.Debug(ctx, "Artwork: Provisional read-through, no state row yet", "artID", artID,
		"source", res.source, "hit", res.reader != nil)
	return s.serveResolution(ctx, res, size, square)
}

// serveResolution turns a local resolution's bytes into a servable Image (byte-hash only, no decode).
func (s *service) serveResolution(ctx context.Context, res resolution, size int, square bool) (*Image, error) {
	if res.reader == nil {
		return nil, ErrUnavailable
	}
	defer res.reader.Close()
	data, err := readCapped(res.reader)
	if err != nil {
		return nil, ErrUnavailable
	}
	hash, err := hashImage(bytes.NewReader(data))
	if err != nil {
		return nil, ErrUnavailable
	}
	// Keyed by the byte-hash, so the entry lines up with the worker's eventual store entry.
	return s.serveSource(ctx, hash, hash, unixMtime(res.refMtime), size, square,
		func() (io.ReadCloser, error) { return io.NopCloser(bytes.NewReader(data)), nil })
}

func (s *service) serveMediaFile(ctx context.Context, artID model.ArtworkID, size int, square bool) (*Image, error) {
	// The setting is not in the config fingerprint, so honor it at serve time: a direct mf- URL
	// must fall back to disc/album instead of serving stale persisted embedded art.
	if !conf.Server.EnableMediaFileCoverArt {
		mf, err := s.ds.MediaFile().Get(ctx, artID.ID)
		if err != nil {
			return nil, err
		}
		return s.Get(ctx, mf.DiscCoverArtID(), size, square)
	}
	ia, err := s.ds.Artwork().GetItemArtwork(ctx, model.KindMediaFileArtwork, artID.ID, model.ImageTypePrimary)
	switch {
	case err == nil && ia.Hash != "":
		return s.serveHash(ctx, artID, ia, size, square)
	case err == nil:
		// absent row: fall through
	case errors.Is(err, model.ErrNotFound):
		// no row: fall through
	default:
		return nil, err
	}
	noRow := errors.Is(err, model.ErrNotFound)

	mf, err := s.ds.MediaFile().Get(ctx, artID.ID)
	if err != nil {
		return nil, err
	}
	if noRow && conf.Server.EnableMediaFileCoverArt && mf.HasCoverArt {
		return s.provisionalEmbedded(ctx, artID, *mf, size, square)
	}
	// Mirror MediaFile.CoverArtID: a track defers to its disc art, which falls back to the album.
	return s.Get(ctx, mf.DiscCoverArtID(), size, square)
}

// provisionalEmbedded serves a track's embedded art immediately, leaving the state row to the worker.
func (s *service) provisionalEmbedded(ctx context.Context, artID model.ArtworkID, mf model.MediaFile, size int, square bool) (*Image, error) {
	lib, err := loadLibraryView(ctx, s.ds, mf.LibraryID)
	if err != nil {
		return nil, err
	}
	res, ok := resolveEmbedded(ctx, lib, s.ffmpeg, mf.Path)
	s.enqueue(ctx, artID, model.ArtworkPriorityBump)
	if !ok {
		// Eligible but unextractable: fall back the way CoverArtID does, not to a placeholder.
		return s.Get(ctx, mf.DiscCoverArtID(), size, square)
	}
	return s.serveResolution(ctx, res, size, square)
}

// serveDisc reads disc art through with no state row and no enqueue, falling back to the album cover.
func (s *service) serveDisc(ctx context.Context, artID model.ArtworkID, size int, square bool) (*Image, error) {
	dr, err := newDiscArtworkReader(ctx, s.ds, artID)
	if err != nil {
		return nil, err
	}
	// Single-disc albums run the chain too: a disc can carry art distinct from the album cover.
	selectImage := func() (io.ReadCloser, error) {
		res, err := dr.selectImage(ctx, s.ffmpeg, conf.Server.DiscArtPriority, &chainState{})
		return res.reader, err
	}
	albumArtID := model.ArtworkID{Kind: model.KindAlbumArtwork, ID: dr.album.ID}
	// Disc art has no state row, hence no content hash: keying on id, album mtime and
	// DiscArtPriority lets a warm cache answer without running the chain or touching the disk.
	key := fmt.Sprintf("%s|%d|%s", artID.ID, dr.cacheTime().UnixNano(), conf.Server.DiscArtPriority)
	img, err := s.serveSource(ctx, key, "", dr.cacheTime(), size, square, selectImage)
	if err != nil {
		if errors.Is(err, context.Canceled) {
			return nil, err
		}
		return s.Get(ctx, albumArtID, size, square)
	}
	return img, nil
}

// dangling enqueues a re-resolution and reports unavailable, leaving the state row untouched.
func (s *service) dangling(ctx context.Context, artID model.ArtworkID) (*Image, error) {
	log.Debug(ctx, "Artwork: State row points at bytes we cannot serve, re-resolving", "artID", artID)
	s.enqueue(ctx, artID, model.ArtworkPriorityScan)
	return nil, ErrUnavailable
}

func (s *service) enqueue(ctx context.Context, artID model.ArtworkID, priority int) {
	err := s.ds.ArtworkQueue().EnqueuePreservingBackoff(ctx, model.ArtworkQueueItem{
		ItemKind:  artID.Kind.Prefix(),
		ItemID:    artID.ID,
		ImageType: model.ImageTypePrimary,
		Priority:  priority,
	})
	if err != nil {
		log.Warn(ctx, "Artwork: Could not enqueue re-resolution", "artID", artID, err)
	}
}

func placeholderImage(kind model.Kind) *Image {
	path := consts.PlaceholderAlbumArt
	if kind == model.KindArtistArtwork {
		path = consts.PlaceholderArtistArt
	}
	r, _ := resources.FS().Open(path)
	return &Image{ReadCloser: r, Placeholder: true}
}

type coverArtIDGetter interface {
	CoverArtID() model.ArtworkID
}

// parseArtworkID accepts an artwork token or a raw entity id, resolving the latter to its CoverArtID.
func (s *service) parseArtworkID(ctx context.Context, id string) (model.ArtworkID, error) {
	if id == "" {
		return model.ArtworkID{}, ErrUnavailable
	}
	if artID, err := model.ParseArtworkID(id); err == nil {
		return artID, nil
	}
	entity, err := model.GetEntityByID(ctx, s.ds, id)
	if err != nil {
		return model.ArtworkID{}, err
	}
	if e, ok := entity.(coverArtIDGetter); ok {
		return e.CoverArtID(), nil
	}
	return model.ArtworkID{}, model.ErrNotFound
}

// TracingResolver is the CLI's read-only view of resolution: it walks the priority chain, records
// the walk and reports the winning source, without ever writing artwork state.
type TracingResolver struct {
	inner *resolver
	trace *ChainTrace
}

// NewTracingResolver builds a TracingResolver that records its priority-chain walk. Without live
// it gets no agents at all, so neither a chain nor any fallback added later can reach a provider;
// with it, one item is at most one call per agent, so the rate limiter and breaker are bypassed.
func NewTracingResolver(ds model.DataStore, ag *agents.Agents, ffm ffmpeg.FFmpeg, t *ChainTrace, live bool) *TracingResolver {
	inner := newLocalResolver(ds, ffm)
	if live {
		inner = newResolver(ds, ag, ffm, passthroughGate)
	}
	return &TracingResolver{inner: inner, trace: t}
}

// Resolve walks kind's sources for id, recording the walk, and reports the winning source
// ("" when none produced an image).
func (r *TracingResolver) Resolve(ctx context.Context, kind model.Kind, id string) (string, error) {
	switch kind {
	case model.KindArtistArtwork:
		return r.explain(ctx, r.inner.resolveArtist, id)
	case model.KindAlbumArtwork:
		return r.explain(ctx, r.inner.resolveAlbum, id)
	case model.KindDiscArtwork:
		return r.explain(ctx, r.inner.resolveDisc, id)
	case model.KindMediaFileArtwork:
		return r.explain(ctx, r.inner.resolveMediaFile, id)
	}
	return "", fmt.Errorf("artwork: %s artwork has no chain to explain", kind)
}

// explain discards the bytes: nothing downstream persists this resolution, so nothing else
// would close the reader either.
func (r *TracingResolver) explain(ctx context.Context, resolve func(context.Context, string) (resolution, error), id string) (string, error) {
	res, err := resolve(withTrace(ctx, r.trace), id)
	if err != nil {
		return "", err
	}
	if res.reader != nil {
		_ = res.reader.Close()
	}
	return res.source, nil
}

func unixMtime(mtime int64) time.Time {
	if mtime <= 0 {
		return time.Time{}
	}
	return time.Unix(0, mtime) // RefMtime is unix-nanoseconds
}