🏡 index : quine-global/quintodrome.git

package artwork

import (
	"context"
	"fmt"
	"slices"
	"strconv"
	"strings"

	"github.com/navidrome/navidrome/conf"
	"github.com/navidrome/navidrome/consts"
	"github.com/navidrome/navidrome/log"
	"github.com/navidrome/navidrome/model"
	"github.com/navidrome/navidrome/utils/slice"
	"github.com/zeebo/xxh3"
)

// ReprocessKinds omits media files: they resolve embedded-only, at scan or on view. Artists lead
// so bulk enqueues give the most external-dependent kind a queue headstart.
var ReprocessKinds = []model.Kind{
	model.KindArtistArtwork, model.KindAlbumArtwork, model.KindPlaylistArtwork, model.KindRadioArtwork,
}

// KeepsState reports whether a kind is recorded in item_artwork and the artwork queue. Disc
// artwork is read through on every request and cached by content key, so it has neither.
func KeepsState(kind model.Kind) bool { return kind != model.KindDiscArtwork }

// RefreshableKinds is every kind Refresh can clear and re-queue, so it holds exactly the kinds
// KeepsState admits. Media files are absent from ReprocessKinds but belong here: the worker
// resolves them, it just never enumerates them in bulk.
var RefreshableKinds = append(slices.Clone(ReprocessKinds), model.KindMediaFileArtwork)

// settlesAbsentOnGiveUp reports whether an exhausted retry budget records an absent state. Media
// files are excluded because retrying one costs nothing: they resolve embedded-only, from a local
// read, and only a view ever enqueues them.
func settlesAbsentOnGiveUp(prefix string) bool {
	kind, ok := model.ParseKind(prefix)
	return ok && KeepsState(kind) && kind != model.KindMediaFileArtwork
}

// artworkEpoch invalidates all resolution state when bumped; bump it whenever resolution semantics change.
const artworkEpoch = 1

// FingerprintInput is one config value the fingerprint covers, named after the setting it came from.
type FingerprintInput struct {
	Name  string
	Value string
}

// FingerprintInputs is the single listing of what ConfigFingerprint hashes.
func FingerprintInputs() []FingerprintInput {
	return []FingerprintInput{
		{"CoverArtPriority", conf.Server.CoverArtPriority},
		{"ArtistArtPriority", conf.Server.ArtistArtPriority},
		{"ArtistImageFolder", conf.Server.ArtistImageFolder},
		{"Agents", conf.Server.Agents},
		{"EnableExternalServices", strconv.FormatBool(conf.Server.EnableExternalServices)},
		{"EnableM3UExternalAlbumArt", strconv.FormatBool(conf.Server.EnableM3UExternalAlbumArt)},
	}
}

// ConfigFingerprint covers the inputs that affect resolution outcomes; a change invalidates stored state.
func ConfigFingerprint() string {
	values := slice.Map(FingerprintInputs(), func(i FingerprintInput) string { return i.Value })
	raw := fmt.Sprintf("%s|%d", strings.Join(values, "|"), artworkEpoch)
	return fmt.Sprintf("%016x", xxh3.Hash([]byte(raw)))
}

// ReconcileConfigFingerprint warns when the artwork config changed since the library was last
// resolved under it. Nothing re-resolves on its own; applying a change is an explicit reprocess.
func ReconcileConfigFingerprint(ctx context.Context, ds model.DataStore) error {
	current := ConfigFingerprint()
	stored, err := ds.Property().DefaultGet(ctx, consts.ArtConfFingerprintPropertyKey, "")
	if err != nil {
		return err
	}
	switch stored {
	case current:
	case "":
		// An unset fingerprint counts as current; the alternative warns every upgrading install once.
		return MarkConfigApplied(ctx, ds)
	default:
		log.Warn(ctx, "Artwork: Config changed since the last full reprocess. Stored artwork keeps "+
			"the old resolution; run 'navidrome artwork reprocess --all' to apply the change",
			"stored", stored, "current", current, "inputs", FingerprintInputs())
	}
	return nil
}

// MarkConfigApplied records the current fingerprint as the one the library is resolved under.
func MarkConfigApplied(ctx context.Context, ds model.DataStore) error {
	return ds.Property().Put(ctx, consts.ArtConfFingerprintPropertyKey, ConfigFingerprint())
}

// enqueueMissingAll is the safety net for entities a scan never enqueued (added between scans, or scanner off).
func enqueueMissingAll(ctx context.Context, ds model.DataStore) error {
	queue := ds.ArtworkQueue()
	for _, kind := range ReprocessKinds {
		if _, err := queue.EnqueueAllMissing(ctx, kind, model.ArtworkPriorityRecheck); err != nil {
			return err
		}
	}
	return nil
}

// ItemName resolves a kind+id to the entity's display name, and errors when the item
// does not exist. Callers use it to reject ids that would otherwise orphan a queue row.
func ItemName(ctx context.Context, ds model.DataStore, kind model.Kind, id string) (string, error) {
	switch kind {
	case model.KindArtistArtwork:
		ar, err := ds.Artist().Get(ctx, id)
		if err != nil {
			return "", err
		}
		return ar.Name, nil
	case model.KindAlbumArtwork:
		al, err := ds.Album().Get(ctx, id)
		if err != nil {
			return "", err
		}
		return al.Name, nil
	case model.KindPlaylistArtwork:
		pls, err := ds.Playlist().Get(ctx, id)
		if err != nil {
			return "", err
		}
		return pls.Name, nil
	case model.KindRadioArtwork:
		rd, err := ds.Radio().Get(ctx, id)
		if err != nil {
			return "", err
		}
		return rd.Name, nil
	case model.KindMediaFileArtwork:
		mf, err := ds.MediaFile().Get(ctx, id)
		if err != nil {
			return "", err
		}
		return mf.Title, nil
	case model.KindDiscArtwork:
		return discArtworkName(ctx, ds, id)
	}
	return "", fmt.Errorf("unsupported kind %q", kind.Prefix())
}

func discArtworkName(ctx context.Context, ds model.DataStore, id string) (string, error) {
	albumID, discNumber, err := model.ParseDiscArtworkID(id)
	if err != nil {
		return "", err
	}
	al, err := ds.Album().Get(ctx, albumID)
	if err != nil {
		return "", err
	}
	name := fmt.Sprintf("%s (disc %d)", al.Name, discNumber)
	// The subtitle is itself a DiscArtPriority candidate, so name it where the chain can be read against it.
	if subtitle := strings.TrimSpace(al.Discs[discNumber]); subtitle != "" {
		name += ": " + subtitle
	}
	return name, nil
}

// Refresh drops an item's resolved artwork state and re-queues it at Bump priority.
func Refresh(ctx context.Context, ds model.DataStore, kind model.Kind, id string) error {
	if err := ds.Artwork().DeleteForItems(ctx, kind, []string{id}); err != nil {
		return fmt.Errorf("clearing artwork state: %w", err)
	}
	item := model.ArtworkQueueItem{ItemKind: kind.Prefix(), ItemID: id, ImageType: model.ImageTypePrimary, Priority: model.ArtworkPriorityBump}
	if err := ds.ArtworkQueue().Enqueue(ctx, item); err != nil {
		return fmt.Errorf("enqueuing artwork refresh: %w", err)
	}
	return nil
}