mirror of
https://github.com/navidrome/navidrome.git
synced 2026-08-31 07:30:32 +00:00
* refactor(artwork): move artworkItemName into core/artwork as ItemName * feat(external): add RefreshInfo to force an external info refresh RefreshInfo re-fetches and re-saves external info for one artist or album, bypassing the TTL check that UpdateArtistInfo/UpdateAlbumInfo use. It is synchronous; callers that must not block detach it themselves. Also makes MockArtistRepo/MockAlbumRepo.UpdateExternalInfo persist to Data (previously a no-op) and adds the new method to the e2e noopProvider, both required so the interface addition compiles and is observable in tests. * feat(external): broadcast RefreshResource after external info is saved populateArtistInfo and populateAlbumInfo now emit the same RefreshResource event the artwork worker uses, so the UI learns about both foreground and background metadata refreshes. * feat(nativeapi): replace artwork refresh endpoint with metadata refresh * feat(ui): add refreshMetadata to the data provider * feat(ui): add a Refresh Metadata item to the album and artist context menus * fix(ui): re-fetch artist info when the record is refreshed * test: fix mislabeled spec, add kind-gate negative case, guard nil mock maps - Rename the RefreshInfo spec that claimed to cover the save-failure/broadcast path: SetError(true) fails Get too, so it only proves RefreshInfo bails out early at getArtist. - Add a spec proving playlist refreshes skip the external-info step, since that asymmetry (al/ar only) was documented but unasserted. - Add lazy nil-map init to MockAlbumRepo/MockArtistRepo.UpdateExternalInfo so a composite-literal-constructed mock doesn't panic on first save. * test: relocate discArtworkName specs from cmd to core/artwork artworkItemName moved into core/artwork as ItemName in an earlier commit, but its disc-name specs stayed behind in cmd/artwork_test.go, reaching across packages. Move them to core/artwork/item_name_test.go where the code now lives. * fix(ui): shape refreshMetadata like a react-admin response react-admin validates custom dataProvider methods and rejects any response without a `data` key, so the raw httpClient promise made every click surface an error toast instead of the success message. The unit test mocked useDataProvider, which skips that validation. Also folds "which kinds have external info" into external.HasInfo so the handler stops restating it, drops the nil-broker guard that only existed for tests, and delegates the mocks' UpdateExternalInfo to Put. * refactor(external): unexport infoKinds Only HasInfo is used outside the package, so the slice itself does not need to be exported. * refactor(artwork): fold ItemName into housekeeping.go next to Refresh ItemName exists to guard Refresh from ids that would orphan a queue row, and both callers invoke them back to back. A separate file hid that pairing; it was only split out to keep the move out of cmd/ legible in review. * fix(nativeapi): return 500 when the refresh lookup fails for a non-ErrNotFound reason A transient repository error told the admin the id did not exist, and the error was dropped without a log line, so nothing pointed at the real cause. Also drops the inherited claim that clearing artwork state shows a placeholder. Reads fall back to local resolution, so that only holds when there is no local art. * fix(ui): move Refresh Metadata above Get Info in the context menu Menu order follows key insertion order in the options object, so the new spec pins the position rather than leaving it to be shuffled by the next addition.
237 lines
8.4 KiB
Go
237 lines
8.4 KiB
Go
package artwork
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"slices"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/navidrome/navidrome/conf"
|
|
"github.com/navidrome/navidrome/consts"
|
|
"github.com/navidrome/navidrome/core/auth"
|
|
"github.com/navidrome/navidrome/log"
|
|
"github.com/navidrome/navidrome/model"
|
|
"github.com/navidrome/navidrome/utils/slice"
|
|
"github.com/zeebo/xxh3"
|
|
)
|
|
|
|
// StaleAbsentAge is how long an absent state is trusted before a recheck retries it.
|
|
const StaleAbsentAge = 30 * 24 * time.Hour
|
|
|
|
// StaleAbsentRecheckBatch caps how many absent states each hourly tick re-queues per kind,
|
|
// oldest first, so external agents see a flat drip instead of a daily burst.
|
|
const StaleAbsentRecheckBatch = 100
|
|
|
|
// RecheckKinds omits media files: they resolve embedded-only, at scan or on view.
|
|
var RecheckKinds = []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 RecheckKinds but belong here: the worker
|
|
// resolves them, it just never revisits them on its own.
|
|
var RefreshableKinds = append(slices.Clone(RecheckKinds), model.KindMediaFileArtwork)
|
|
|
|
// hasRecheckPath reports whether a periodic job will revisit this kind, making an absent settle recoverable.
|
|
func hasRecheckPath(prefix string) bool {
|
|
kind, ok := model.ParseKind(prefix)
|
|
return ok && slices.Contains(RecheckKinds, kind)
|
|
}
|
|
|
|
// 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)))
|
|
}
|
|
|
|
// backfillSummary is what a backfill enqueued. MaxExternalLookups is an upper estimate for one
|
|
// attempt per item, not a bound: a local hit ends the walk, and a retry asks the agents again.
|
|
type backfillSummary struct {
|
|
Ran bool
|
|
PerKind map[string]int64
|
|
Items int64
|
|
MaxExternalLookups int64
|
|
}
|
|
|
|
// backfill enqueues artwork resolution for every entity when the config fingerprint changed.
|
|
func backfill(ctx context.Context, ds model.DataStore, agentCount func() ImageAgentCount) (backfillSummary, error) {
|
|
start := time.Now()
|
|
ctx = auth.WithAdminUser(ctx, ds)
|
|
current := ConfigFingerprint()
|
|
props := ds.Property(ctx)
|
|
stored, err := props.DefaultGet(consts.ArtConfFingerprintPropertyKey, "")
|
|
if err != nil {
|
|
return backfillSummary{}, err
|
|
}
|
|
if stored == current {
|
|
return backfillSummary{}, nil
|
|
}
|
|
|
|
// Artists first: few entities, most external-dependent, so they get a queue headstart.
|
|
kinds := []struct {
|
|
kind model.Kind
|
|
fetch func() ([]string, error)
|
|
}{
|
|
{model.KindArtistArtwork, func() ([]string, error) { return ds.Artist(ctx).GetAllIDs() }},
|
|
{model.KindAlbumArtwork, func() ([]string, error) { return ds.Album(ctx).GetAllIDs() }},
|
|
{model.KindPlaylistArtwork, func() ([]string, error) { return ds.Playlist(ctx).GetAllIDs() }},
|
|
{model.KindRadioArtwork, func() ([]string, error) { return ds.Radio(ctx).GetAllIDs() }},
|
|
}
|
|
// Counted here, not by the caller: building the agent list constructs every enabled agent, and
|
|
// an unchanged fingerprint returns above without ever needing the number.
|
|
agents := agentCount()
|
|
summary := backfillSummary{Ran: true, PerKind: map[string]int64{}}
|
|
for _, k := range kinds {
|
|
ids, err := k.fetch()
|
|
if err != nil {
|
|
return backfillSummary{}, err
|
|
}
|
|
if err := enqueueBackfillKind(ctx, ds, k.kind, ids); err != nil {
|
|
return backfillSummary{}, err
|
|
}
|
|
n := int64(len(ids))
|
|
summary.PerKind[k.kind.Prefix()] = n
|
|
summary.Items += n
|
|
summary.MaxExternalLookups += n * ExternalLookupsPerItem(k.kind, agents)
|
|
}
|
|
|
|
if err := props.Put(consts.ArtConfFingerprintPropertyKey, current); err != nil {
|
|
return backfillSummary{}, err
|
|
}
|
|
log.Info(ctx, "Artwork: Config fingerprint changed, backfill enqueued", "items", summary.Items,
|
|
"byKind", summary.PerKind, "maxExternalLookups", summary.MaxExternalLookups,
|
|
"elapsed", time.Since(start))
|
|
return summary, nil
|
|
}
|
|
|
|
func enqueueBackfillKind(ctx context.Context, ds model.DataStore, kind model.Kind, ids []string) error {
|
|
if len(ids) == 0 {
|
|
return nil
|
|
}
|
|
items := slice.Map(ids, func(id string) model.ArtworkQueueItem {
|
|
return model.ArtworkQueueItem{
|
|
ItemKind: kind.Prefix(), ItemID: id, ImageType: model.ImageTypePrimary, Priority: model.ArtworkPriorityBackfill,
|
|
}
|
|
})
|
|
return ds.ArtworkQueue(ctx).Enqueue(items...)
|
|
}
|
|
|
|
func enqueueStaleAbsentAll(ctx context.Context, ds model.DataStore) error {
|
|
cutoff := time.Now().Add(-StaleAbsentAge)
|
|
queue := ds.ArtworkQueue(ctx)
|
|
for _, kind := range RecheckKinds {
|
|
if _, err := queue.EnqueueStaleAbsent(kind, cutoff, StaleAbsentRecheckBatch); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// 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(ctx)
|
|
for _, kind := range RecheckKinds {
|
|
if _, err := queue.EnqueueAllMissing(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(ctx).Get(id)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
return ar.Name, nil
|
|
case model.KindAlbumArtwork:
|
|
al, err := ds.Album(ctx).Get(id)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
return al.Name, nil
|
|
case model.KindPlaylistArtwork:
|
|
pls, err := ds.Playlist(ctx).Get(id)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
return pls.Name, nil
|
|
case model.KindRadioArtwork:
|
|
rd, err := ds.Radio(ctx).Get(id)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
return rd.Name, nil
|
|
case model.KindMediaFileArtwork:
|
|
mf, err := ds.MediaFile(ctx).Get(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(ctx).Get(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(ctx).DeleteForItems(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(ctx).Enqueue(item); err != nil {
|
|
return fmt.Errorf("enqueuing artwork refresh: %w", err)
|
|
}
|
|
return nil
|
|
}
|