feat(cli): add artwork status

Reports the queue, where artwork currently resolves from, absent counts against the 24h
recheck window, and the stored config fingerprint versus the current one — the line that
turns 'why is my server re-resolving everything?' into one command.

fingerprint() and staleAbsentAge are exported so the CLI reports the values backfill
itself compares, instead of a second copy of the formula that can silently drift.
This commit is contained in:
Deluan 2026-08-14 14:17:32 -04:00
commit 0950f939fb
6 changed files with 324 additions and 17 deletions

View file

@ -8,11 +8,13 @@ import (
"io"
"os"
"slices"
"strconv"
"strings"
"text/tabwriter"
"time"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/consts"
"github.com/navidrome/navidrome/core/artwork"
"github.com/navidrome/navidrome/db"
"github.com/navidrome/navidrome/log"
@ -50,6 +52,7 @@ func init() {
artworkCmd.AddCommand(artworkExplainCmd)
artworkCmd.AddCommand(artworkRefreshCmd)
artworkCmd.AddCommand(artworkReprocessCmd)
artworkCmd.AddCommand(artworkStatusCmd)
rootCmd.AddCommand(artworkCmd)
}
@ -93,6 +96,167 @@ var artworkReprocessCmd = &cobra.Command{
},
}
var artworkStatusCmd = &cobra.Command{
Use: "status",
Short: "Report the artwork queue, where artwork resolves from, and the backfill state",
Args: cobra.NoArgs,
Run: func(cmd *cobra.Command, args []string) {
runStatus(cmd.Context())
},
}
func runStatus(ctx context.Context) {
defer db.Init(ctx)()
ds, ctx := getAdminContext(ctx)
rep, err := collectStatus(ctx, ds)
if err != nil {
log.Fatal(ctx, err)
}
fmt.Print(formatStatus(rep))
}
type sourceCount struct {
kind model.Kind
source string
count int64
}
type absentCount struct {
kind model.Kind
model.ArtworkAbsentStat
}
type statusReport struct {
queueTotal int64
queue []model.ArtworkQueueStat
sources []sourceCount
absent []absentCount
stored string
current string
}
// backfillQueued counts what a backfill still has to drain, the cost a config change is charging now.
func (r statusReport) backfillQueued() int64 {
var n int64
for _, s := range r.queue {
if s.Priority == model.ArtworkPriorityBackfill {
n += s.Count
}
}
return n
}
func collectStatus(ctx context.Context, ds model.DataStore) (statusReport, error) {
q := ds.ArtworkQueue(ctx)
var rep statusReport
var err error
if rep.queueTotal, err = q.Count(); err != nil {
return rep, fmt.Errorf("counting the artwork queue: %w", err)
}
if rep.queue, err = q.CountByKindAndPriority(); err != nil {
return rep, fmt.Errorf("breaking the artwork queue down by kind: %w", err)
}
cutoff := time.Now().Add(-artwork.StaleAbsentAge)
for _, k := range artworkKinds {
sources, err := q.SourcesInUse(k)
if err != nil {
return rep, fmt.Errorf("listing the sources in use by %s artwork: %w", k, err)
}
slices.Sort(sources)
for _, s := range sources {
n, err := q.CountBySource(k, []string{s})
if err != nil {
return rep, fmt.Errorf("counting %s artwork resolved from %s: %w", k, displaySource(s), err)
}
rep.sources = append(rep.sources, sourceCount{kind: k, source: s, count: n})
}
stat, err := q.CountAbsent(k, cutoff)
if err != nil {
return rep, fmt.Errorf("counting absent %s artwork: %w", k, err)
}
rep.absent = append(rep.absent, absentCount{kind: k, ArtworkAbsentStat: stat})
}
rep.current = artwork.ConfigFingerprint()
if rep.stored, err = ds.Property(ctx).DefaultGet(consts.ArtConfFingerprintPropertyKey, ""); err != nil {
return rep, fmt.Errorf("reading the stored artwork fingerprint: %w", err)
}
return rep, nil
}
func formatStatus(rep statusReport) string {
var sb strings.Builder
w := tabwriter.NewWriter(&sb, 0, 4, 2, ' ', 0)
fmt.Fprintln(w, "Queue")
if len(rep.queue) == 0 {
fmt.Fprintln(w, " (empty)")
} else {
fmt.Fprintln(w, " KIND\tPRIORITY\tITEMS")
for _, s := range rep.queue {
fmt.Fprintf(w, " %s\t%s\t%d\n", kindName(s.ItemKind), priorityName(s.Priority), s.Count)
}
fmt.Fprintf(w, " TOTAL\t\t%d\n", rep.queueTotal)
}
fmt.Fprintln(w, "\nSources")
fmt.Fprintln(w, " KIND\tSOURCE\tITEMS")
for _, s := range rep.sources {
fmt.Fprintf(w, " %s\t%s\t%d\n", s.kind, displaySource(s.source), s.count)
}
fmt.Fprintln(w, "\nAbsent (resolved, no image found)")
fmt.Fprintln(w, " KIND\tABSENT\tDUE FOR RECHECK")
for _, a := range rep.absent {
fmt.Fprintf(w, " %s\t%d\t%d\n", a.kind, a.Total, a.Stale)
}
fmt.Fprintf(w, " (rechecked once the last attempt is older than %gh)\n", artwork.StaleAbsentAge.Hours())
fmt.Fprintln(w, "\nBackfill")
fmt.Fprintf(w, " Stored fingerprint:\t%s\n", cmp.Or(rep.stored, "(none)"))
fmt.Fprintf(w, " Current fingerprint:\t%s\n", rep.current)
fmt.Fprintf(w, " State:\t%s\n", backfillState(rep))
w.Flush()
return sb.String()
}
// backfillState turns "why is my server re-resolving everything?" into a line: a stored fingerprint
// that differs is a pending re-resolve of the whole library, and backfill rows are one already running.
func backfillState(rep statusReport) string {
state := "up to date"
if rep.stored != rep.current {
state = "fingerprint changed — every artist, album, playlist and radio will be re-enqueued on the next startup"
}
if n := rep.backfillQueued(); n > 0 {
state += fmt.Sprintf("; %d items still queued at backfill priority", n)
}
return state
}
func kindName(prefix string) string {
if k, ok := model.ParseKind(prefix); ok {
return k.String()
}
return prefix
}
func priorityName(p int) string {
switch p {
case model.ArtworkPriorityRecheck:
return "recheck"
case model.ArtworkPriorityBackfill:
return "backfill"
case model.ArtworkPriorityScan:
return "scan"
case model.ArtworkPriorityBump:
return "bump"
}
return strconv.Itoa(p)
}
func runReprocess(ctx context.Context) {
kinds, err := selectedKinds(reprocessKinds, reprocessSelectsAll(reprocessKinds, reprocessSources, reprocessAll))
if err != nil {

View file

@ -7,6 +7,7 @@ import (
"strings"
"time"
"github.com/navidrome/navidrome/consts"
"github.com/navidrome/navidrome/core/artwork"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/tests"
@ -440,6 +441,146 @@ var _ = Describe("reprocessArtwork", func() {
})
})
var _ = Describe("artwork status command", func() {
It("takes no arguments", func() {
Expect(artworkStatusCmd.Args(artworkStatusCmd, []string{})).ToNot(HaveOccurred())
Expect(artworkStatusCmd.Args(artworkStatusCmd, []string{"x"})).To(HaveOccurred())
})
})
var _ = Describe("collectStatus", func() {
var ds *tests.MockDataStore
var art *tests.MockArtworkRepo
var queue *tests.MockArtworkQueueRepo
ctx := context.Background()
BeforeEach(func() {
ds = &tests.MockDataStore{}
art = ds.Artwork(ctx).(*tests.MockArtworkRepo)
queue = ds.ArtworkQueue(ctx).(*tests.MockArtworkQueueRepo)
put := func(kind model.Kind, id, source, hash string, attempted time.Time) {
Expect(art.PutItemArtwork(&model.ItemArtwork{ItemKind: kind.Prefix(), ItemID: id,
ImageType: model.ImageTypePrimary, Source: source, Hash: hash, AttemptedAt: attempted})).To(Succeed())
}
put(model.KindArtistArtwork, "ar-1", "external:deezer", "h1", time.Now())
put(model.KindArtistArtwork, "ar-2", "", "", time.Now().Add(-48*time.Hour))
put(model.KindArtistArtwork, "ar-3", "", "", time.Now())
put(model.KindAlbumArtwork, "al-1", "folder", "h2", time.Now())
Expect(queue.Enqueue(model.ArtworkQueueItem{ItemKind: "ar", ItemID: "ar-9",
ImageType: model.ImageTypePrimary, Priority: model.ArtworkPriorityBackfill})).To(Succeed())
})
It("reports the queue, the source distribution and the absent ages", func() {
rep, err := collectStatus(ctx, ds)
Expect(err).ToNot(HaveOccurred())
Expect(rep.queueTotal).To(Equal(int64(1)))
Expect(rep.queue).To(ConsistOf(model.ArtworkQueueStat{ItemKind: "ar",
Priority: model.ArtworkPriorityBackfill, Count: 1}))
Expect(rep.sources).To(ContainElements(
sourceCount{kind: model.KindArtistArtwork, source: "external:deezer", count: 1},
sourceCount{kind: model.KindArtistArtwork, source: "", count: 2},
sourceCount{kind: model.KindAlbumArtwork, source: "folder", count: 1},
))
Expect(rep.absent).To(ContainElement(absentCount{kind: model.KindArtistArtwork,
ArtworkAbsentStat: model.ArtworkAbsentStat{Total: 2, Stale: 1}}))
})
It("compares the stored fingerprint against the current one", func() {
Expect(ds.Property(ctx).Put(consts.ArtConfFingerprintPropertyKey, "old-fingerprint")).To(Succeed())
rep, err := collectStatus(ctx, ds)
Expect(err).ToNot(HaveOccurred())
Expect(rep.stored).To(Equal("old-fingerprint"))
Expect(rep.current).To(Equal(artwork.ConfigFingerprint()),
"the CLI must report the value backfill itself compares")
})
It("reports a never-recorded fingerprint as empty instead of failing", func() {
rep, err := collectStatus(ctx, ds)
Expect(err).ToNot(HaveOccurred())
Expect(rep.stored).To(BeEmpty())
})
It("queues nothing", func() {
_, err := collectStatus(ctx, ds)
Expect(err).ToNot(HaveOccurred())
Expect(queue.Count()).To(Equal(int64(1)), "status must not enqueue anything")
})
})
var _ = Describe("formatStatus", func() {
var rep statusReport
BeforeEach(func() {
rep = statusReport{
queueTotal: 3,
queue: []model.ArtworkQueueStat{
{ItemKind: "ar", Priority: model.ArtworkPriorityBackfill, Count: 2},
{ItemKind: "al", Priority: model.ArtworkPriorityScan, Count: 1},
},
sources: []sourceCount{
{kind: model.KindArtistArtwork, source: "external:deezer", count: 5},
{kind: model.KindArtistArtwork, source: "", count: 2},
},
absent: []absentCount{
{kind: model.KindArtistArtwork, ArtworkAbsentStat: model.ArtworkAbsentStat{Total: 2, Stale: 1}},
},
stored: "abc123",
current: "abc123",
}
})
It("prints every block", func() {
out := formatStatus(rep)
for _, block := range []string{"Queue", "Sources", "Absent", "Backfill"} {
Expect(out).To(ContainSubstring(block))
}
Expect(out).To(ContainSubstring("backfill"))
Expect(out).To(ContainSubstring("external:deezer"))
Expect(out).To(ContainSubstring("abc123"))
})
It("names the empty source as absent", func() {
Expect(formatStatus(rep)).To(ContainSubstring("absent"))
})
It("states the recheck window the absent counts are bucketed against", func() {
Expect(formatStatus(rep)).To(ContainSubstring("24h"))
})
It("reports a matching fingerprint as up to date, with the backfill still draining", func() {
out := formatStatus(rep)
Expect(out).To(ContainSubstring("up to date"))
Expect(out).To(ContainSubstring("2 items still queued at backfill priority"),
"a drained-down backfill is the signal that a config change flooded the queue")
})
It("reports a changed fingerprint as a pending re-resolve of everything", func() {
rep.stored = "older"
out := formatStatus(rep)
Expect(out).To(ContainSubstring("fingerprint changed"))
Expect(out).ToNot(ContainSubstring("up to date"))
})
It("reports a never-recorded fingerprint without printing an empty value", func() {
rep.stored = ""
out := formatStatus(rep)
Expect(out).To(ContainSubstring("(none)"))
Expect(out).To(ContainSubstring("fingerprint changed"))
})
It("says the queue is empty instead of printing a headless table", func() {
rep.queue, rep.queueTotal = nil, 0
out := formatStatus(rep)
Expect(out).To(ContainSubstring("empty"))
Expect(out).ToNot(ContainSubstring("PRIORITY"))
})
})
var _ = Describe("refreshItems", func() {
var ds *tests.MockDataStore
var queue *tests.MockArtworkQueueRepo

View file

@ -119,7 +119,7 @@ func (s *service) Get(ctx context.Context, artID model.ArtworkID, size int, squa
}
// requestRecheckAge throttles view-triggered rechecks so reopening a genuinely-absent page can't
// hammer external services; below staleAbsentAge to catch younger absences.
// hammer external services; below StaleAbsentAge to catch younger absences.
const requestRecheckAge = time.Hour
func (s *service) serveEntity(ctx context.Context, artID model.ArtworkID, size int, square bool) (*Image, error) {

View file

@ -16,7 +16,8 @@ import (
"github.com/navidrome/navidrome/utils/slice"
)
const staleAbsentAge = 24 * time.Hour
// StaleAbsentAge is how long an absent state is trusted before a recheck retries it.
const StaleAbsentAge = 24 * time.Hour
// recheckKinds omits media files: they resolve embedded-only, at scan or on view.
var recheckKinds = []model.Kind{
@ -32,8 +33,9 @@ func hasRecheckPath(prefix string) bool {
// artworkEpoch invalidates all resolution state when bumped; bump it whenever resolution semantics change.
const artworkEpoch = 1
// fingerprint covers the inputs that affect resolution outcomes; a change invalidates stored state.
func fingerprint() string {
// ConfigFingerprint covers the inputs that affect resolution outcomes; a change invalidates stored state.
// Exported so the CLI reports the value backfill compares instead of computing one that can drift.
func ConfigFingerprint() string {
raw := fmt.Sprintf("%s|%s|%s|%s|%t|%t|%d",
conf.Server.CoverArtPriority, conf.Server.ArtistArtPriority, conf.Server.ArtistImageFolder,
conf.Server.Agents, conf.Server.EnableExternalServices, conf.Server.EnableM3UExternalAlbumArt, artworkEpoch)
@ -45,7 +47,7 @@ func fingerprint() string {
func backfill(ctx context.Context, ds model.DataStore) (bool, error) {
start := time.Now()
ctx = auth.WithAdminUser(ctx, ds)
current := fingerprint()
current := ConfigFingerprint()
props := ds.Property(ctx)
stored, err := props.DefaultGet(consts.ArtConfFingerprintPropertyKey, "")
if err != nil {
@ -95,7 +97,7 @@ func enqueueBackfillKind(ctx context.Context, ds model.DataStore, kind model.Kin
}
func enqueueStaleAbsentAll(ctx context.Context, ds model.DataStore) error {
cutoff := time.Now().Add(-staleAbsentAge)
cutoff := time.Now().Add(-StaleAbsentAge)
queue := ds.ArtworkQueue(ctx)
for _, kind := range recheckKinds {
if _, err := queue.EnqueueStaleAbsent(kind, cutoff); err != nil {

View file

@ -93,32 +93,32 @@ var _ = Describe("Housekeeping", func() {
Describe("Fingerprint", func() {
It("changes when a fingerprint-affecting config value changes", func() {
f1 := fingerprint()
f1 := ConfigFingerprint()
conf.Server.CoverArtPriority = "folder, embedded"
f2 := fingerprint()
f2 := ConfigFingerprint()
Expect(f1).NotTo(Equal(f2))
})
It("changes when ArtistImageFolder changes", func() {
conf.Server.ArtistImageFolder = "/before"
f1 := fingerprint()
f1 := ConfigFingerprint()
conf.Server.ArtistImageFolder = "/after"
Expect(fingerprint()).NotTo(Equal(f1))
Expect(ConfigFingerprint()).NotTo(Equal(f1))
})
It("changes when EnableM3UExternalAlbumArt is toggled", func() {
conf.Server.EnableM3UExternalAlbumArt = false
f1 := fingerprint()
f1 := ConfigFingerprint()
conf.Server.EnableM3UExternalAlbumArt = true
Expect(fingerprint()).NotTo(Equal(f1))
Expect(ConfigFingerprint()).NotTo(Equal(f1))
})
It("does not change when the server version changes", func() {
original := consts.Version
DeferCleanup(func() { consts.Version = original })
f1 := fingerprint()
f1 := ConfigFingerprint()
consts.Version = original + "-next"
Expect(fingerprint()).To(Equal(f1),
Expect(ConfigFingerprint()).To(Equal(f1),
"the version must not invalidate artwork state: it would re-resolve every entity on every build")
})
})
@ -126,7 +126,7 @@ var _ = Describe("Housekeeping", func() {
Describe("Backfill", func() {
It("enqueues nothing and returns false when the stored fingerprint matches", func() {
seedEntities()
Expect(propRepo.Put(consts.ArtConfFingerprintPropertyKey, fingerprint())).To(Succeed())
Expect(propRepo.Put(consts.ArtConfFingerprintPropertyKey, ConfigFingerprint())).To(Succeed())
did, err := backfill(ctx, ds)
Expect(err).ToNot(HaveOccurred())
@ -150,7 +150,7 @@ var _ = Describe("Housekeeping", func() {
stored, err := propRepo.Get(consts.ArtConfFingerprintPropertyKey)
Expect(err).ToNot(HaveOccurred())
Expect(stored).To(Equal(fingerprint()))
Expect(stored).To(Equal(ConfigFingerprint()))
})
It("enqueues a private playlist by resolving it under an admin context", func() {

View file

@ -137,7 +137,7 @@ func (w *Worker) Backfill(ctx context.Context) (bool, error) {
return backfill(ctx, w.proc.ds)
}
// EnqueueStaleAbsentAll requeues known-absent entries older than staleAbsentAge.
// EnqueueStaleAbsentAll requeues known-absent entries older than StaleAbsentAge.
func (w *Worker) EnqueueStaleAbsentAll(ctx context.Context) error {
return enqueueStaleAbsentAll(ctx, w.proc.ds)
}