mirror of
https://github.com/navidrome/navidrome.git
synced 2026-10-08 02:17:25 +02:00
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:
parent
7f97107d45
commit
0950f939fb
6 changed files with 324 additions and 17 deletions
164
cmd/artwork.go
164
cmd/artwork.go
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue