Merge branch 'master' into feat-podcast-ux-improvements/5420

This commit is contained in:
Jiho Persy Lee 2026-08-18 10:06:35 +09:00 committed by GitHub
commit e35160cb3f
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
86 changed files with 5785 additions and 517 deletions

819
cmd/artwork.go Normal file
View File

@ -0,0 +1,819 @@
package cmd
import (
"cmp"
"context"
"errors"
"fmt"
"io"
"os"
"slices"
"strconv"
"strings"
"time"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/consts"
"github.com/navidrome/navidrome/core/agents"
"github.com/navidrome/navidrome/core/artwork"
"github.com/navidrome/navidrome/db"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/plugins"
"github.com/navidrome/navidrome/utils/slice"
"github.com/spf13/cobra"
)
var explainLive bool
var (
reprocessKinds []string
reprocessSources []string
reprocessAll bool
reprocessDryRun bool
reprocessYes bool
)
func init() {
artworkExplainCmd.Flags().BoolVar(&explainLive, "live", false,
"perform real external lookups instead of reporting what would be tried; "+
"also initializes plugin agents, which may open external connections")
artworkReprocessCmd.Flags().StringSliceVar(&reprocessKinds, "kind", nil,
"kinds to reprocess ("+kindPrefixes(artwork.RecheckKinds)+"); repeatable")
artworkReprocessCmd.Flags().StringSliceVar(&reprocessSources, "source", nil,
"only items currently resolved from these sources (e.g. folder, external:deezer, absent)")
artworkReprocessCmd.Flags().BoolVar(&reprocessAll, "all", false, "reprocess every kind")
artworkReprocessCmd.Flags().BoolVar(&reprocessDryRun, "dry-run", false,
"report what would be queued and exit without queueing")
artworkReprocessCmd.Flags().BoolVarP(&reprocessYes, "yes", "y", false, "skip the confirmation prompt")
artworkCmd.AddCommand(artworkExplainCmd)
artworkCmd.AddCommand(artworkRefreshCmd)
artworkCmd.AddCommand(artworkReprocessCmd)
artworkCmd.AddCommand(artworkStatusCmd)
rootCmd.AddCommand(artworkCmd)
}
var artworkCmd = &cobra.Command{
Use: "artwork",
Short: "Inspect and re-resolve artwork",
}
var artworkExplainCmd = &cobra.Command{
Use: "explain <kind> <id>",
Short: "Explain why an item's artwork resolved the way it did",
Long: "Explain why an item's artwork resolved the way it did.\n\n" +
"<kind> is one of: " + kindPrefixes(explainKinds) + ".\n" +
"A disc artwork id is the album id and the disc number, joined by a colon: <albumID>:2",
Args: cobra.ExactArgs(2),
Run: func(cmd *cobra.Command, args []string) {
kind, err := parseArtworkKind(args[0], explainKinds)
if err != nil {
log.Fatal(cmd.Context(), err)
}
runExplain(cmd.Context(), kind, args[1])
},
}
var artworkRefreshCmd = &cobra.Command{
Use: "refresh <kind> <id>...",
Short: "Clear an item's artwork state and re-resolve it",
Args: cobra.MinimumNArgs(2),
Run: func(cmd *cobra.Command, args []string) {
kind, err := parseArtworkKind(args[0], artwork.RefreshableKinds)
if err != nil {
log.Fatal(cmd.Context(), err)
}
runRefresh(cmd.Context(), kind, args[1:])
},
}
var artworkReprocessCmd = &cobra.Command{
Use: "reprocess",
Short: "Re-enqueue artwork in bulk, by kind and/or by the source it currently resolves from",
Args: cobra.NoArgs,
Run: func(cmd *cobra.Command, args []string) {
runReprocess(cmd.Context())
},
}
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 {
queue []model.ArtworkQueueStat
sources []sourceCount
absent []absentCount
inputs []artwork.FingerprintInput
stored string
current string
}
func (r statusReport) queueTotal() int64 {
var n int64
for _, s := range r.queue {
n += s.Count
}
return n
}
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.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 artwork.RecheckKinds {
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, rep.inputs = artwork.ConfigFingerprint(), artwork.FingerprintInputs()
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 := newTabWriter(&sb)
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, " State:\t%s\n", backfillState(rep))
fmt.Fprintf(w, " Stored fingerprint:\t%s\n", cmp.Or(rep.stored, "(none)"))
fmt.Fprintf(w, " Current fingerprint:\t%s\n", rep.current)
if len(rep.inputs) > 0 {
fmt.Fprintln(w, " Fingerprint inputs (changing any of these re-resolves the whole library):")
for _, in := range rep.inputs {
fmt.Fprintf(w, " %s:\t%s\n", in.Name, in.Value)
}
}
w.Flush()
return sb.String()
}
// backfillState leads with the queued backlog: by the time anyone runs this, backfill has usually
// already stored the new fingerprint, and "up to date" would bury the flood it is still working through.
func backfillState(rep statusReport) string {
pending := "fingerprint changed — every artist, album, playlist and radio will be re-enqueued on the next startup"
if n := rep.backfillQueued(); n > 0 {
if rep.stored != rep.current {
return fmt.Sprintf("backfill running: %d items queued, and %s", n, pending)
}
return fmt.Sprintf("backfill running: %d items queued (fingerprint up to date)", n)
}
if rep.stored != rep.current {
return pending
}
return "up to date"
}
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, reprocessSources, reprocessAll)
if err != nil {
log.Fatal(ctx, err)
}
defer db.Init(ctx)()
ds, ctx := getAdminContext(ctx)
// Only a kind that can reach an agent needs the count, and loading a plugin creates its
// services. A preview must not reach the network, so init never runs here.
var imageAgents artwork.ImageAgentCount
if needsImageAgents(kinds) {
mgr := loadPluginAgents(ctx, false)
defer func() { _ = mgr.Stop() }()
imageAgents = imageAgentCount(ds, mgr)
}
if err := reprocessArtwork(ctx, ds, kinds, repositorySources(reprocessSources), imageAgents,
reprocessDryRun, reprocessConfirm(reprocessYes, os.Stdin), os.Stdout); err != nil {
log.Fatal(ctx, err)
}
}
func selectedKinds(kinds, sources []string, all bool) ([]model.Kind, error) {
// A source filter on its own is already a complete selection, so it does not also need a kind.
if all || (len(kinds) == 0 && len(sources) > 0) {
return artwork.RecheckKinds, nil
}
if len(kinds) == 0 {
return nil, fmt.Errorf("no selector given: pass --kind, --source or --all")
}
out := make([]model.Kind, 0, len(kinds))
for _, k := range kinds {
kind, err := parseArtworkKind(k, artwork.RecheckKinds)
if err != nil {
return nil, err
}
out = append(out, kind)
}
// A repeated kind would be counted twice, overstating the cost the operator confirms.
return slice.Unique(out), nil
}
// absentSource is how the stored empty source — resolved, no image — is spelled on the CLI.
const absentSource = "absent"
func repositorySources(sources []string) []string {
return slice.Map(sources, func(s string) string {
if s == absentSource {
return ""
}
return s
})
}
func displaySource(s string) string { return cmp.Or(s, absentSource) }
type confirmFunc func(out io.Writer, total, external int64) bool
func reprocessConfirm(yes bool, in io.Reader) confirmFunc {
if yes {
return func(io.Writer, int64, int64) bool { return true }
}
return promptConfirm(in)
}
// externalEstimate claims no bound: a local hit ends the walk before any agent is asked, and the
// plugin agents it counts are only the ones this process managed to load.
func externalEstimate(n int64) string {
if n == 0 {
return "none"
}
return fmt.Sprintf("~%d estimated (plugin agents counted only when they load; local hits may need fewer)", n)
}
func externalLookupLine(n int64) string {
return fmt.Sprintf("External lookups: %s.", externalEstimate(n))
}
func imageAgentCount(ds model.DataStore, mgr *plugins.Manager) artwork.ImageAgentCount {
ag := agents.GetAgents(ds, mgr)
return artwork.ImageAgentCount{Artist: len(ag.ArtistImageAgents()), Album: len(ag.AlbumImageAgents())}
}
// loadPluginAgents loads the plugins named in Agents, so the CLI resolves through the same agents a
// running server would. A load failure is reported, not fatal: the built-in agents still answer.
func loadPluginAgents(ctx context.Context, runInit bool) *plugins.Manager {
mgr := getPluginManager()
if err := mgr.LoadPlugins(ctx, configuredAgents(), runInit); err != nil {
log.Warn(ctx, "Could not load plugins; plugin-provided agents will be missing", err)
}
return mgr
}
// needsImageAgents asks exactly what ExternalLookupsPerItem asks, so the gate cannot disagree with
// the estimate it guards. Playlists count: their generated grid resolves album art through agents.
func needsImageAgents(kinds []model.Kind) bool {
return slices.ContainsFunc(kinds, artwork.MayFetchExternal)
}
// configuredAgents names the agents in priority order; one absent from it can never supply an image.
func configuredAgents() []string {
var names []string
for name := range strings.SplitSeq(conf.Server.Agents, ",") {
if name = strings.TrimSpace(name); name != "" {
names = append(names, name)
}
}
return names
}
func promptConfirm(in io.Reader) confirmFunc {
return func(out io.Writer, total, external int64) bool {
var cost string
if external > 0 {
cost = fmt.Sprintf(" %s", externalLookupLine(external))
}
fmt.Fprintf(out, "\nThis will re-resolve %d items.%s Continue? [y/N] ", total, cost)
var answer string
if _, err := fmt.Fscanln(in, &answer); err != nil {
return false
}
answer = strings.ToLower(strings.TrimSpace(answer))
return answer == "y" || answer == "yes"
}
}
// validateSources rejects a typo'd source: matching nothing silently reads as "nothing to do" when
// it means the filter was wrong. Checked table-wide, so a filter is never a typo for one --kind only.
func validateSources(q model.ArtworkQueueRepository, sources []string) error {
if len(sources) == 0 {
return nil
}
var inUse []string
for _, k := range artwork.RecheckKinds {
found, err := q.SourcesInUse(k)
if err != nil {
return fmt.Errorf("listing the sources in use by %s artwork: %w", k, err)
}
inUse = slice.Unique(append(inUse, found...))
}
var unknown []string
for _, s := range sources {
if s != "" && !slices.Contains(inUse, s) { // the reserved absent source is valid even when nothing is absent
unknown = append(unknown, displaySource(s))
}
}
if len(unknown) == 0 {
return nil
}
valid := slice.Map(inUse, displaySource)
slices.Sort(valid)
return fmt.Errorf("no artwork resolves from %s; sources in use: %s",
strings.Join(unknown, ", "), cmp.Or(strings.Join(valid, ", "), "(none)"))
}
// reprocessArtwork previews from CountBySource — rows matched — then reports what EnqueueBySource
// actually inserted; the two differ because an already-queued row is left untouched.
func reprocessArtwork(ctx context.Context, ds model.DataStore, kinds []model.Kind, sources []string,
imageAgents artwork.ImageAgentCount, dryRun bool, confirm confirmFunc, out io.Writer) error {
q := ds.ArtworkQueue(ctx)
if err := validateSources(q, sources); err != nil {
return err
}
matched := make([]int64, len(kinds))
var total, external int64
for i, k := range kinds {
n, err := q.CountBySource(k, sources)
if err != nil {
return fmt.Errorf("counting %s artwork: %w", k, err)
}
matched[i] = n
total += n
external += n * artwork.ExternalLookupsPerItem(k, imageAgents)
}
printReprocessPreview(out, kinds, matched, total, external, sources)
switch {
case dryRun:
fmt.Fprintln(out, "\nDry run: nothing was queued.")
return nil
case total == 0:
fmt.Fprintln(out, "Nothing was queued.")
return nil
case !confirm(out, total, external):
fmt.Fprintln(out, "Aborted: nothing was queued.")
return nil
}
var queued int64
for i, k := range kinds {
if matched[i] == 0 {
continue
}
n, err := q.EnqueueBySource(k, sources, model.ArtworkPriorityRecheck)
if err != nil {
return fmt.Errorf("queueing %s artwork: %w", k, err)
}
queued += n
fmt.Fprintf(out, "%s: %d queued\n", k, n)
}
fmt.Fprintf(out, "Queued %d of %d matched items.\n", queued, total)
if skipped := total - queued; skipped > 0 {
fmt.Fprintf(out, "Already queued, left unchanged: %d (priority and retry backoff untouched).\n", skipped)
}
return nil
}
// printReprocessPreview also states the external estimate, which --dry-run must show because it
// skips the prompt that would otherwise carry it.
func printReprocessPreview(out io.Writer, kinds []model.Kind, matched []int64, total, external int64, sources []string) {
w := newTabWriter(out)
shown := slice.Map(sources, displaySource)
fmt.Fprintf(w, "Sources:\t%s\n\n", cmp.Or(strings.Join(shown, ", "), "(any)"))
fmt.Fprintln(w, "KIND\tMATCHED")
for i, k := range kinds {
fmt.Fprintf(w, "%s\t%d\n", k, matched[i])
}
fmt.Fprintf(w, "TOTAL\t%d\n", total)
w.Flush()
fmt.Fprintf(out, "\n%s\n", externalLookupLine(external))
if total == 0 {
fmt.Fprintln(out, "\nNothing matches this selection.")
}
}
func runRefresh(ctx context.Context, kind model.Kind, ids []string) {
defer db.Init(ctx)()
ds, ctx := getAdminContext(ctx)
if failed := refreshItems(ctx, ds, kind, ids, os.Stdout); failed > 0 {
log.Fatal(ctx, "Failed to refresh artwork", "kind", kind, "failed", failed, "total", len(ids))
}
}
// refreshItems keeps going after a failure — the ids are independent — and returns how many failed.
func refreshItems(ctx context.Context, ds model.DataStore, kind model.Kind, ids []string, out io.Writer) int {
var failed int
for _, id := range ids {
// artwork.Refresh would happily queue an id that does not exist, orphaning a queue row.
if _, err := artworkItemName(ctx, ds, kind, id); err != nil {
log.Error(ctx, "Item not found", "kind", kind, "id", id, err)
failed++
continue
}
if err := artwork.Refresh(ctx, ds, kind, id); err != nil {
log.Error(ctx, "Error refreshing artwork", "kind", kind, "id", id, err)
failed++
continue
}
fmt.Fprintf(out, "%s/%s: queued\n", kind.Prefix(), id)
}
return failed
}
// explainKinds is every kind explain accepts: it reports stored state and config too, so a kind
// with no chain to walk still has something to answer with.
var explainKinds = []model.Kind{
model.KindArtistArtwork, model.KindAlbumArtwork, model.KindDiscArtwork,
model.KindMediaFileArtwork, model.KindPlaylistArtwork, model.KindRadioArtwork,
}
func kindPrefixes(kinds []model.Kind) string {
return strings.Join(slice.Map(kinds, func(k model.Kind) string { return k.Prefix() }), ", ")
}
func parseArtworkKind(s string, valid []model.Kind) (model.Kind, error) {
kind, ok := model.ParseKind(s)
if ok && slices.Contains(valid, kind) {
return kind, nil
}
return kind, fmt.Errorf("invalid kind %q, expected one of: %s", s, kindPrefixes(valid))
}
// explainAgents accounts for every configured agent: one the CLI cannot construct (a plugin, or a
// built-in missing its credentials) never reaches the Chain, so the raw list alone overstates it.
func explainAgents(configured string, available []string) string {
if strings.TrimSpace(configured) == "" {
return "(none)"
}
var unavailable bool
names := slice.Map(strings.Split(configured, ","), func(name string) string {
name = strings.TrimSpace(name)
if slices.Contains(available, name) {
return name
}
unavailable = true
return name + "*"
})
line := strings.Join(names, ", ")
if unavailable {
line += " (* not available to the CLI)"
}
return line
}
// availableImageAgents names the agents that can actually supply an image for kind.
func availableImageAgents(ds model.DataStore, mgr *plugins.Manager, kind model.Kind) []string {
ag := agents.GetAgents(ds, mgr)
if kind == model.KindArtistArtwork {
return slice.Map(ag.ArtistImageAgents(), func(a agents.ArtistImageAgent) string { return a.Name })
}
return slice.Map(ag.AlbumImageAgents(), func(a agents.AlbumImageAgent) string { return a.Name })
}
// explainResult states the verdict of the walk. A skipped or failed external tier, or a local
// candidate that would not open, leaves the outcome unknown: nothing observed that there is no artwork.
func explainResult(source string, steps []artwork.TraceStep) string {
if source != "" {
for _, s := range steps {
if s.Outcome == artwork.OutcomeHit {
break
}
if s.Outcome == artwork.OutcomeWouldTry {
return "resolved from " + source +
" (offline: a higher-priority external candidate was not tried; re-run with --live)"
}
// An external winner discards the earlier error, so the resolver settles it with no retry.
if s.Outcome == artwork.OutcomeError && strings.HasPrefix(s.Candidate, artwork.ExternalPrefix) &&
!strings.HasPrefix(source, artwork.ExternalPrefix) {
return "resolved from " + source +
" (indeterminate: a higher-priority external lookup failed; this may resolve differently on a retry)"
}
}
return "resolved from " + source
}
for _, s := range steps {
switch {
case s.Outcome == artwork.OutcomeWouldTry:
return "indeterminate (external agents not called; re-run with --live)"
case s.Outcome == artwork.OutcomeError && strings.HasPrefix(s.Candidate, artwork.ExternalPrefix):
return "indeterminate (an external lookup failed; the item may resolve on a later attempt)"
// The worker treats an unreadable local candidate exactly as it treats a failed external one:
// it retries instead of settling absent, so the verdict must not read as a clean miss.
case s.Outcome == artwork.OutcomeUnreadable:
return "indeterminate (a candidate exists but could not be read; the worker retries rather than settling absent)"
}
}
return "not resolved"
}
// explainConfig names the setting that decides where a kind's artwork comes from, and its value.
func explainConfig(kind model.Kind) (name, value string) {
switch kind {
case model.KindArtistArtwork:
return "ArtistArtPriority", conf.Server.ArtistArtPriority
case model.KindAlbumArtwork:
return "CoverArtPriority", conf.Server.CoverArtPriority
case model.KindDiscArtwork:
return "DiscArtPriority", conf.Server.DiscArtPriority
case model.KindMediaFileArtwork:
return "EnableMediaFileCoverArt", strconv.FormatBool(conf.Server.EnableMediaFileCoverArt)
}
return "", ""
}
type explainReport struct {
kind model.Kind
id string
name string
stored *model.ItemArtwork
queued *model.ArtworkQueueItem
agents string
steps []artwork.TraceStep
source string
resolveErr error
}
func formatExplain(rep explainReport) string {
var sb strings.Builder
w := newTabWriter(&sb)
explainable := artwork.Explainable(rep.kind)
stateful := artwork.KeepsState(rep.kind)
fmt.Fprintln(w, "Item")
fmt.Fprintf(w, " Kind:\t%s (%s)\n", rep.kind, rep.kind.Prefix())
fmt.Fprintf(w, " ID:\t%s\n", rep.id)
fmt.Fprintf(w, " Name:\t%s\n", rep.name)
fmt.Fprintln(w, "\nStored")
switch {
case !stateful:
fmt.Fprintf(w, " (%s artwork is resolved on every request and never recorded)\n", rep.kind)
case rep.stored == nil:
fmt.Fprintln(w, " (no artwork state recorded)")
default:
fmt.Fprintf(w, " Source:\t%s\n", displaySource(rep.stored.Source))
fmt.Fprintf(w, " Hash:\t%s\n", cmp.Or(rep.stored.Hash, "(absent)"))
if rep.stored.SourcePath != "" {
fmt.Fprintf(w, " Source path:\t%s\n", rep.stored.SourcePath)
}
fmt.Fprintf(w, " Attempted at:\t%s\n", formatTime(rep.stored.AttemptedAt))
}
fmt.Fprintln(w, "\nQueue")
switch {
case !stateful:
fmt.Fprintln(w, " (never queued)")
case rep.queued == nil:
fmt.Fprintln(w, " (not queued)")
default:
fmt.Fprintf(w, " Priority:\t%s (%d)\n", priorityName(rep.queued.Priority), rep.queued.Priority)
fmt.Fprintf(w, " Attempts:\t%d\n", rep.queued.Attempts)
fmt.Fprintf(w, " Retry at:\t%s\n", formatTime(rep.queued.RetryAt))
}
fmt.Fprintln(w, "\nConfig")
if setting, value := explainConfig(rep.kind); setting == "" {
fmt.Fprintln(w, " (no artwork source configuration applies)")
} else {
fmt.Fprintf(w, " %s:\t%s\n", setting, value)
if rep.agents != "" {
fmt.Fprintf(w, " Agents:\t%s\n", rep.agents)
}
}
fmt.Fprintln(w, "\nChain")
if !explainable {
fmt.Fprintf(w, " (%s artwork does not walk a priority chain)\n", rep.kind)
} else {
fmt.Fprintln(w, " CANDIDATE\tOUTCOME\tDETAIL")
for _, s := range rep.steps {
// A row with an empty last cell would end tabwriter's column block, breaking alignment.
fmt.Fprintf(w, " %s\t%s\t%s\n", s.Candidate, s.Outcome, cmp.Or(s.Detail, "-"))
}
}
fmt.Fprintln(w, "\nResult")
switch {
case rep.resolveErr != nil:
fmt.Fprintf(w, " resolution failed: %s\n", rep.resolveErr)
case !explainable:
fmt.Fprintln(w, " not evaluated (no chain was walked; see Stored above)")
default:
fmt.Fprintf(w, " %s\n", explainResult(rep.source, rep.steps))
}
w.Flush()
return sb.String()
}
func formatTime(t time.Time) string {
if t.IsZero() {
return "-"
}
return t.Format(time.RFC3339)
}
func runExplain(ctx context.Context, kind model.Kind, id string) {
defer db.Init(ctx)()
ds, ctx := getAdminContext(ctx)
name, err := artworkItemName(ctx, ds, kind, id)
if err != nil {
log.Fatal(ctx, "Item not found", "kind", kind, "id", id, err)
}
rep := explainReport{kind: kind, id: id, name: name}
if artwork.KeepsState(kind) {
rep.stored, err = ds.Artwork(ctx).GetItemArtwork(kind, id, model.ImageTypePrimary)
if err != nil && !errors.Is(err, model.ErrNotFound) {
log.Fatal(ctx, "Failed to read artwork state", "kind", kind, "id", id, err)
}
rep.queued, err = ds.ArtworkQueue(ctx).Get(kind, id, model.ImageTypePrimary)
if err != nil && !errors.Is(err, model.ErrNotFound) {
log.Fatal(ctx, "Failed to read the artwork queue", "kind", kind, "id", id, err)
}
}
if artwork.Explainable(kind) {
// Only artist and album reach an agent, and the load must precede the resolver, which reads
// the same manager.
if kind == model.KindArtistArtwork || kind == model.KindAlbumArtwork {
mgr := loadPluginAgents(ctx, explainLive)
defer func() { _ = mgr.Stop() }()
rep.agents = explainAgents(conf.Server.Agents, availableImageAgents(ds, mgr, kind))
}
trace := &artwork.ChainTrace{}
rep.source, rep.resolveErr = CreateArtworkResolver(trace, explainLive).Resolve(ctx, kind, id)
rep.steps = trace.Steps()
}
fmt.Print(formatExplain(rep))
// The steps taken before a failed walk are the diagnosis, so report them before exiting.
if rep.resolveErr != nil {
log.Fatal(ctx, "Failed to resolve artwork", "kind", kind, "id", id, rep.resolveErr)
}
}
// artworkItemName looks the entity up under its own kind, so a mismatched kind/id pair is
// reported as not found instead of silently explaining another entity's artwork.
func artworkItemName(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
}

946
cmd/artwork_test.go Normal file
View File

@ -0,0 +1,946 @@
package cmd
import (
"context"
"errors"
"io"
"strings"
"time"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/conf/configtest"
"github.com/navidrome/navidrome/consts"
"github.com/navidrome/navidrome/core/artwork"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/tests"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
var _ = Describe("parseArtworkKind", func() {
It("accepts a supported kind", func() {
k, err := parseArtworkKind("ar", artwork.RecheckKinds)
Expect(err).ToNot(HaveOccurred())
Expect(k).To(Equal(model.KindArtistArtwork))
})
It("rejects an unknown kind and lists the valid ones", func() {
_, err := parseArtworkKind("zz", artwork.RecheckKinds)
Expect(err).To(HaveOccurred())
Expect(err.Error()).To(ContainSubstring("ar"))
Expect(err.Error()).To(ContainSubstring("al"))
})
It("rejects a known kind the command does not accept", func() {
_, err := parseArtworkKind("mf", artwork.RecheckKinds)
Expect(err).To(HaveOccurred())
})
DescribeTable("accepts the kinds each command supports",
func(prefix string, valid []model.Kind) {
_, err := parseArtworkKind(prefix, valid)
Expect(err).ToNot(HaveOccurred())
},
Entry("explain reads disc artwork", "dc", explainKinds),
Entry("explain reads media file artwork", "mf", explainKinds),
// Disc artwork has no state to clear and the worker cannot resolve it, so refresh must not
// accept it: the queue row would be rejected on every drain.
Entry("refresh re-queues media files", "mf", artwork.RefreshableKinds),
)
It("rejects disc artwork for refresh", func() {
_, err := parseArtworkKind("dc", artwork.RefreshableKinds)
Expect(err).To(HaveOccurred())
})
})
var _ = Describe("explainResult", func() {
It("reports the winning source", func() {
steps := []artwork.TraceStep{{Candidate: "folder", Outcome: "hit", Detail: "/music/a.jpg"}}
Expect(explainResult("folder", steps)).To(ContainSubstring("resolved from folder"))
})
It("reports not resolved when every candidate was tried and missed", func() {
steps := []artwork.TraceStep{
{Candidate: "artist.*", Outcome: "miss"},
{Candidate: "external:deezer", Outcome: "miss"},
}
Expect(explainResult("", steps)).To(Equal("not resolved"))
})
It("reports indeterminate when a local candidate exists but could not be read", func() {
steps := []artwork.TraceStep{
{Candidate: "cover.*", Outcome: "miss"},
{Candidate: "embedded", Outcome: "unreadable"},
}
Expect(explainResult("", steps)).To(ContainSubstring("indeterminate"),
"the worker retries an unreadable candidate instead of settling absent, so this is not a clean miss")
})
It("does not qualify a hit that an earlier unreadable candidate preceded", func() {
// chainState.try stamps only the external error onto a hit and drops the local one, so the
// worker settles this as found; warning about it would be a false alarm.
steps := []artwork.TraceStep{
{Candidate: "embedded", Outcome: "unreadable"},
{Candidate: "cover.*", Outcome: "hit", Detail: "/music/cover.jpg"},
}
Expect(explainResult("folder", steps)).To(Equal("resolved from folder"))
})
It("reports indeterminate when an external lookup failed transiently", func() {
steps := []artwork.TraceStep{
{Candidate: "artist.*", Outcome: "miss"},
{Candidate: "external:deezer", Outcome: "error", Detail: "context deadline exceeded"},
}
Expect(explainResult("", steps)).To(ContainSubstring("indeterminate"),
"a failed network call is not evidence that the item has no artwork")
})
It("qualifies a win a skipped higher-priority external candidate could have taken", func() {
steps := []artwork.TraceStep{
{Candidate: "external:deezer", Outcome: "would-try"},
{Candidate: "artist.*", Outcome: "hit", Detail: "/music/artist.jpg"},
}
res := explainResult("artist.*", steps)
Expect(res).To(ContainSubstring("resolved from artist.*"))
Expect(res).To(ContainSubstring("--live"),
"offline, the winner is only the winner because the external tier was skipped")
})
It("does not qualify a win that no skipped candidate outranked", func() {
steps := []artwork.TraceStep{
{Candidate: "artist.*", Outcome: "hit"},
{Candidate: "external:deezer", Outcome: "would-try"},
}
Expect(explainResult("artist.*", steps)).To(Equal("resolved from artist.*"))
})
It("reports indeterminate when external agents were never called", func() {
steps := []artwork.TraceStep{
{Candidate: "artist.*", Outcome: "miss"},
{Candidate: "external:deezer", Outcome: "would-try"},
}
Expect(explainResult("", steps)).To(ContainSubstring("indeterminate"),
"an offline run must not claim an item is unresolvable when external agents were skipped")
})
It("qualifies a win a failed higher-priority external lookup could have taken", func() {
steps := []artwork.TraceStep{
{Candidate: "external:deezer", Outcome: "error", Detail: "context deadline exceeded"},
{Candidate: "artist.*", Outcome: "hit", Detail: "/music/artist.jpg"},
}
res := explainResult("artist.*", steps)
Expect(res).To(ContainSubstring("resolved from artist.*"))
Expect(res).To(ContainSubstring("indeterminate"),
"the resolver serves this hit but retries later, so the winner is provisional")
})
It("does not qualify an external win that followed a failed external lookup", func() {
steps := []artwork.TraceStep{
{Candidate: "external:deezer", Outcome: "error", Detail: "context deadline exceeded"},
{Candidate: "external:lastfm", Outcome: "hit", Detail: "http://img"},
}
Expect(explainResult("external:lastfm", steps)).To(Equal("resolved from external:lastfm"),
"a later agent supplying the image discards the earlier error, so there is no retry to warn about")
})
It("does not qualify a win that outranked the failed external lookup", func() {
steps := []artwork.TraceStep{
{Candidate: "artist.*", Outcome: "hit"},
{Candidate: "external:deezer", Outcome: "error", Detail: "context deadline exceeded"},
}
Expect(explainResult("artist.*", steps)).To(Equal("resolved from artist.*"))
})
})
var _ = Describe("explainAgents", func() {
It("accounts for every configured agent, marking the ones the CLI could not use", func() {
out := explainAgents("artist-nfo-metadata,apple-music,deezer,lastfm", []string{"deezer"})
for _, name := range []string{"artist-nfo-metadata", "apple-music", "deezer", "lastfm"} {
Expect(out).To(ContainSubstring(name),
"a configured agent missing from this line reads as if it had never been configured")
}
Expect(out).To(ContainSubstring("not available to the CLI"))
})
It("does not mark anything when every configured agent is available", func() {
out := explainAgents("deezer, lastfm", []string{"lastfm", "deezer"})
Expect(out).To(Equal("deezer, lastfm"))
})
It("reports an empty configuration as none, not as an unavailable agent", func() {
Expect(explainAgents("", nil)).To(Equal("(none)"))
})
})
var _ = Describe("formatExplain", func() {
var rep explainReport
BeforeEach(func() {
DeferCleanup(configtest.SetupConfig())
conf.Server.ArtistArtPriority = "external, artist.*"
rep = explainReport{
kind: model.KindArtistArtwork,
id: "ar-1",
name: "Radiohead",
agents: "lastfm,spotify",
steps: []artwork.TraceStep{
{Candidate: "upload", Outcome: "skipped", Detail: "no uploaded image"},
{Candidate: "external:deezer", Outcome: "would-try"},
},
source: "",
}
})
It("reports the item, its config and the chain it walked", func() {
out := formatExplain(rep)
Expect(out).To(ContainSubstring("Radiohead"))
Expect(out).To(ContainSubstring("ar-1"))
Expect(out).To(ContainSubstring("ArtistArtPriority"))
Expect(out).To(ContainSubstring("lastfm,spotify"))
Expect(out).To(ContainSubstring("external:deezer"))
Expect(out).To(ContainSubstring("would-try"))
Expect(out).To(ContainSubstring("indeterminate"))
})
It("reports the absence of stored state and of a queue row", func() {
out := formatExplain(rep)
Expect(out).To(ContainSubstring("no artwork state recorded"))
Expect(out).To(ContainSubstring("not queued"))
})
It("prints the stored state and the queue row when they exist", func() {
attempted := time.Date(2026, 8, 13, 10, 0, 0, 0, time.UTC)
rep.stored = &model.ItemArtwork{Source: "folder", Hash: "abc123",
SourcePath: "/music/cover.jpg", AttemptedAt: attempted}
rep.queued = &model.ArtworkQueueItem{Priority: model.ArtworkPriorityScan, Attempts: 2,
RetryAt: attempted.Add(time.Hour)}
rep.source = "folder"
out := formatExplain(rep)
Expect(out).To(ContainSubstring("abc123"))
Expect(out).To(ContainSubstring("/music/cover.jpg"))
Expect(out).To(ContainSubstring("2026-08-13T10:00:00Z"))
Expect(out).To(ContainSubstring("scan (50)"), "a bare 50 makes the operator look the priority up")
Expect(out).To(ContainSubstring("resolved from folder"))
})
It("marks a known-absent stored state instead of printing an empty hash", func() {
rep.stored = &model.ItemArtwork{AttemptedAt: time.Now()}
Expect(formatExplain(rep)).To(ContainSubstring("absent"))
})
It("reports a failed walk as failed, not as unresolved", func() {
rep.resolveErr = errors.New("no such directory")
out := formatExplain(rep)
Expect(out).To(ContainSubstring("resolution failed: no such directory"))
Expect(out).ToNot(ContainSubstring("indeterminate"))
Expect(out).To(ContainSubstring("would-try"), "the steps taken before the failure still print")
})
It("says a kind that does not walk a chain has no chain, without an empty table", func() {
conf.Server.CoverArtPriority = "cover.*, embedded"
rep.kind = model.KindPlaylistArtwork
rep.steps = nil
rep.agents = ""
out := formatExplain(rep)
Expect(out).To(ContainSubstring("does not walk a priority chain"))
Expect(out).ToNot(ContainSubstring("CANDIDATE"),
"an empty chain table reads as 'nothing was tried', which is false")
Expect(out).ToNot(ContainSubstring("not resolved"),
"nothing was resolved because nothing was attempted")
Expect(out).ToNot(ContainSubstring("CoverArtPriority"),
"the priority chain config does not govern this kind")
})
It("says disc artwork keeps no state instead of reporting it as unresolved state", func() {
conf.Server.DiscArtPriority = "cover.jpg, embedded"
rep = explainReport{
kind: model.KindDiscArtwork, id: "al-1:2", name: "OK Computer (disc 2)",
steps: []artwork.TraceStep{{Candidate: "cover.jpg", Outcome: "hit", Detail: "/music/cover.jpg"}},
source: "folder",
}
out := formatExplain(rep)
Expect(out).To(ContainSubstring("never recorded"))
Expect(out).To(ContainSubstring("never queued"))
Expect(out).ToNot(ContainSubstring("no artwork state recorded"),
"a missing row would read as a lookup that failed, when disc artwork has no row by design")
Expect(out).To(ContainSubstring("DiscArtPriority"))
Expect(out).ToNot(ContainSubstring("Agents:"), "disc artwork never asks an agent")
Expect(out).To(ContainSubstring("resolved from folder"))
})
It("reports the setting that governs media file artwork", func() {
conf.Server.EnableMediaFileCoverArt = false
rep = explainReport{
kind: model.KindMediaFileArtwork, id: "mf-1", name: "Airbag",
steps: []artwork.TraceStep{
{Candidate: "embedded", Outcome: "skipped", Detail: "EnableMediaFileCoverArt is off"},
},
}
out := formatExplain(rep)
Expect(out).To(ContainSubstring("EnableMediaFileCoverArt"))
Expect(out).To(ContainSubstring("false"))
Expect(out).To(ContainSubstring("not resolved"))
Expect(out).To(ContainSubstring("no artwork state recorded"), "media files do keep state")
})
})
var _ = Describe("explainConfig", func() {
BeforeEach(func() {
DeferCleanup(configtest.SetupConfig())
conf.Server.DiscArtPriority = "cover.jpg"
conf.Server.EnableMediaFileCoverArt = true
})
DescribeTable("names the setting that decides where a kind's artwork comes from",
func(kind model.Kind, setting, value string) {
gotSetting, gotValue := explainConfig(kind)
Expect(gotSetting).To(Equal(setting))
Expect(gotValue).To(Equal(value))
},
Entry("disc", model.KindDiscArtwork, "DiscArtPriority", "cover.jpg"),
Entry("media file", model.KindMediaFileArtwork, "EnableMediaFileCoverArt", "true"),
Entry("playlist has none", model.KindPlaylistArtwork, "", ""),
)
})
var _ = Describe("discArtworkName", func() {
var ds *tests.MockDataStore
BeforeEach(func() {
albumRepo := tests.CreateMockAlbumRepo()
albumRepo.SetData(model.Albums{{ID: "al-1", Name: "Sandinista!", Discs: model.Discs{2: "Side Three"}}})
ds = &tests.MockDataStore{MockedAlbum: albumRepo}
})
It("names the album, the disc and its subtitle", func() {
name, err := artworkItemName(context.Background(), ds, model.KindDiscArtwork, "al-1:2")
Expect(err).ToNot(HaveOccurred())
Expect(name).To(Equal("Sandinista! (disc 2): Side Three"))
})
It("omits the subtitle when the disc has none", func() {
name, err := artworkItemName(context.Background(), ds, model.KindDiscArtwork, "al-1:1")
Expect(err).ToNot(HaveOccurred())
Expect(name).To(Equal("Sandinista! (disc 1)"))
})
It("rejects an id that is not <albumID>:<disc>", func() {
_, err := artworkItemName(context.Background(), ds, model.KindDiscArtwork, "al-1")
Expect(err).To(HaveOccurred())
})
})
var _ = Describe("artwork refresh command", func() {
It("requires at least a kind and one id", func() {
Expect(artworkRefreshCmd.Args(artworkRefreshCmd, []string{"ar"})).To(HaveOccurred())
Expect(artworkRefreshCmd.Args(artworkRefreshCmd, []string{"ar", "id1"})).ToNot(HaveOccurred())
Expect(artworkRefreshCmd.Args(artworkRefreshCmd, []string{"ar", "id1", "id2"})).ToNot(HaveOccurred())
})
})
var _ = Describe("artwork reprocess selection", func() {
It("errors when no selector is given", func() {
_, err := selectedKinds(nil, nil, false)
Expect(err).To(HaveOccurred())
Expect(err.Error()).To(ContainSubstring("--all"))
})
It("returns every kind for --all", func() {
ks, err := selectedKinds(nil, nil, true)
Expect(err).ToNot(HaveOccurred())
Expect(ks).To(ConsistOf(artwork.RecheckKinds))
})
It("returns every kind for a source filter given without a kind", func() {
ks, err := selectedKinds(nil, []string{"folder"}, false)
Expect(err).ToNot(HaveOccurred())
Expect(ks).To(ConsistOf(artwork.RecheckKinds), "--source alone is already a complete selection")
})
It("returns only the named kinds", func() {
ks, err := selectedKinds([]string{"ar"}, nil, false)
Expect(err).ToNot(HaveOccurred())
Expect(ks).To(Equal([]model.Kind{model.KindArtistArtwork}))
})
It("keeps a named kind selection alongside a source filter", func() {
ks, err := selectedKinds([]string{"ar"}, []string{"folder"}, false)
Expect(err).ToNot(HaveOccurred())
Expect(ks).To(Equal([]model.Kind{model.KindArtistArtwork}))
})
It("rejects an unknown kind", func() {
_, err := selectedKinds([]string{"zz"}, nil, false)
Expect(err).To(HaveOccurred())
})
It("counts a repeated kind once", func() {
ks, err := selectedKinds([]string{"ar", "ar"}, nil, false)
Expect(err).ToNot(HaveOccurred())
Expect(ks).To(Equal([]model.Kind{model.KindArtistArtwork}))
})
})
var _ = Describe("explain/reprocess source round trip", func() {
ctx := context.Background()
// storedSource reads back the Source line explain printed, as an operator would copy it.
storedSource := func(out string) string {
GinkgoHelper()
for line := range strings.SplitSeq(out, "\n") {
if after, ok := strings.CutPrefix(strings.TrimSpace(line), "Source:"); ok {
return strings.TrimSpace(after)
}
}
Fail("explain printed no Source line")
return ""
}
It("names the absent state as reprocess --source accepts it", func() {
ds := &tests.MockDataStore{}
art := ds.Artwork(ctx).(*tests.MockArtworkRepo)
Expect(art.PutItemArtwork(&model.ItemArtwork{ItemKind: model.KindArtistArtwork.Prefix(),
ItemID: "ar-1", ImageType: model.ImageTypePrimary})).To(Succeed())
shown := storedSource(formatExplain(explainReport{kind: model.KindArtistArtwork, id: "ar-1",
stored: &model.ItemArtwork{AttemptedAt: time.Now()}}))
q := ds.ArtworkQueue(ctx)
Expect(validateSources(q, repositorySources([]string{shown}))).To(Succeed(),
"explain's spelling of a source must be pasteable into --source")
Expect(validateSources(q, repositorySources([]string{"(" + shown + ")"}))).ToNot(Succeed(),
"a parenthesised name would be rejected, so explain must not print one")
})
})
var _ = Describe("repositorySources", func() {
It("maps the user-facing absent name onto the stored empty source", func() {
Expect(repositorySources([]string{"absent", "folder"})).To(Equal([]string{"", "folder"}))
})
It("keeps an empty selection empty, meaning every source", func() {
Expect(repositorySources(nil)).To(BeEmpty())
})
})
var _ = Describe("promptConfirm", func() {
var out strings.Builder
BeforeEach(func() { out.Reset() })
It("states the external cost and accepts an explicit yes", func() {
Expect(promptConfirm(strings.NewReader("y\n"))(&out, 42, 7)).To(BeTrue())
Expect(out.String()).To(ContainSubstring("re-resolve 42 items"))
Expect(out.String()).To(ContainSubstring("External lookups: ~7 estimated"))
})
It("defaults to no on anything else", func() {
Expect(promptConfirm(strings.NewReader("\n"))(&out, 1, 1)).To(BeFalse())
Expect(promptConfirm(strings.NewReader("nope\n"))(&out, 1, 1)).To(BeFalse())
Expect(promptConfirm(strings.NewReader(""))(&out, 1, 1)).To(BeFalse())
})
It("drops the external clause when no lookup will be made", func() {
Expect(promptConfirm(strings.NewReader("y\n"))(&out, 3, 0)).To(BeTrue())
Expect(out.String()).To(ContainSubstring("re-resolve 3 items."))
Expect(out.String()).ToNot(ContainSubstring("External lookups"))
})
})
var _ = Describe("reprocessConfirm", func() {
var out strings.Builder
BeforeEach(func() { out.Reset() })
It("prompts when --yes was not given", func() {
Expect(reprocessConfirm(false, strings.NewReader("n\n"))(&out, 5, 5)).To(BeFalse())
Expect(out.String()).To(ContainSubstring("Continue?"))
})
It("bypasses the prompt only for --yes", func() {
Expect(reprocessConfirm(true, strings.NewReader(""))(&out, 5, 5)).To(BeTrue())
Expect(out.String()).To(BeEmpty(), "--yes must not print a prompt it never reads")
})
})
var _ = Describe("reprocessArtwork", func() {
var ds *tests.MockDataStore
var art *tests.MockArtworkRepo
var queue *tests.MockArtworkQueueRepo
var out strings.Builder
var imageAgents artwork.ImageAgentCount
ctx := context.Background()
kinds := []model.Kind{model.KindArtistArtwork, model.KindAlbumArtwork}
accept := func(io.Writer, int64, int64) bool { return true }
decline := func(io.Writer, int64, int64) bool { return false }
put := func(kind model.Kind, id, source string) {
Expect(art.PutItemArtwork(&model.ItemArtwork{ItemKind: kind.Prefix(), ItemID: id,
ImageType: model.ImageTypePrimary, Hash: "h" + id, Source: source})).To(Succeed())
}
BeforeEach(func() {
DeferCleanup(configtest.SetupConfig())
conf.Server.CoverArtPriority = "cover.*, external"
conf.Server.ArtistArtPriority = "artist.*, external"
conf.Server.EnableM3UExternalAlbumArt = false
imageAgents = artwork.ImageAgentCount{Artist: 1, Album: 1}
ds = &tests.MockDataStore{}
art = ds.Artwork(ctx).(*tests.MockArtworkRepo)
queue = ds.ArtworkQueue(ctx).(*tests.MockArtworkQueueRepo)
out.Reset()
put(model.KindArtistArtwork, "ar-1", "external:deezer")
put(model.KindArtistArtwork, "ar-2", "")
put(model.KindAlbumArtwork, "al-1", "external:deezer")
put(model.KindAlbumArtwork, "al-2", "folder")
})
It("previews the per-kind breakdown and queues nothing on a dry run", func() {
Expect(reprocessArtwork(ctx, ds, kinds, []string{"external:deezer"}, imageAgents, true, accept, &out)).To(Succeed())
Expect(out.String()).To(ContainSubstring("external:deezer"))
Expect(out.String()).To(ContainSubstring("artist"))
Expect(out.String()).To(ContainSubstring("album"))
Expect(out.String()).To(ContainSubstring("TOTAL"))
Expect(out.String()).To(ContainSubstring("Dry run"))
Expect(queue.Count()).To(BeZero())
})
It("queues nothing when the operator declines", func() {
Expect(reprocessArtwork(ctx, ds, kinds, nil, imageAgents, false, decline, &out)).To(Succeed())
Expect(out.String()).To(ContainSubstring("Aborted"))
Expect(queue.Count()).To(BeZero())
})
It("queues the matching items at recheck priority, leaving their artwork state alone", func() {
Expect(reprocessArtwork(ctx, ds, kinds, []string{"external:deezer"}, imageAgents, false, accept, &out)).To(Succeed())
Expect(queue.Count()).To(Equal(int64(2)))
queued, err := queue.Get(model.KindAlbumArtwork, "al-1", model.ImageTypePrimary)
Expect(err).ToNot(HaveOccurred())
Expect(queued.Priority).To(Equal(model.ArtworkPriorityRecheck))
_, err = queue.Get(model.KindAlbumArtwork, "al-2", model.ImageTypePrimary)
Expect(err).To(MatchError(model.ErrNotFound), "a non-matching source must not be queued")
stored, err := art.GetItemArtwork(model.KindAlbumArtwork, "al-1", model.ImageTypePrimary)
Expect(err).ToNot(HaveOccurred())
Expect(stored.Hash).To(Equal("hal-1"), "bulk reprocessing must not blank the current artwork")
})
It("targets the absent state", func() {
Expect(reprocessArtwork(ctx, ds, kinds, []string{""}, imageAgents, false, accept, &out)).To(Succeed())
Expect(queue.Count()).To(Equal(int64(1)))
_, err := queue.Get(model.KindArtistArtwork, "ar-2", model.ImageTypePrimary)
Expect(err).ToNot(HaveOccurred())
})
It("reports matched and queued separately when part of the set is already queued", func() {
Expect(queue.Enqueue(model.ArtworkQueueItem{ItemKind: "ar", ItemID: "ar-1",
ImageType: model.ImageTypePrimary, Priority: model.ArtworkPriorityBump})).To(Succeed())
Expect(reprocessArtwork(ctx, ds, kinds, []string{"external:deezer"}, imageAgents, false, accept, &out)).To(Succeed())
Expect(out.String()).To(ContainSubstring("Queued 1 of 2 matched items"))
Expect(out.String()).To(ContainSubstring("Already queued, left unchanged: 1"))
queued, err := queue.Get(model.KindArtistArtwork, "ar-1", model.ImageTypePrimary)
Expect(err).ToNot(HaveOccurred())
Expect(queued.Priority).To(Equal(model.ArtworkPriorityBump),
"an already-queued row keeps its priority and backoff")
})
It("stops at a selection that matches nothing instead of prompting", func() {
Expect(reprocessArtwork(ctx, ds, []model.Kind{model.KindRadioArtwork}, nil, imageAgents, false,
func(io.Writer, int64, int64) bool {
Fail("must not prompt when there is nothing to queue")
return true
}, &out)).To(Succeed())
Expect(out.String()).To(ContainSubstring("Nothing"))
Expect(queue.Count()).To(BeZero())
})
It("reports an empty selection as a dry run when one was asked for", func() {
Expect(reprocessArtwork(ctx, ds, []model.Kind{model.KindRadioArtwork}, nil, imageAgents, true, accept, &out)).To(Succeed())
Expect(out.String()).To(ContainSubstring("Nothing matches"))
Expect(out.String()).To(ContainSubstring("Dry run"))
})
It("shows the external estimate on a dry run, which never reaches the prompt", func() {
Expect(reprocessArtwork(ctx, ds, []model.Kind{model.KindAlbumArtwork}, nil, imageAgents, true, accept, &out)).To(Succeed())
Expect(out.String()).To(ContainSubstring("External lookups: ~2 estimated"))
})
It("bills every agent per item, not one lookup per item", func() {
imageAgents = artwork.ImageAgentCount{Artist: 2, Album: 3}
var external int64
capture := func(_ io.Writer, _, e int64) bool { external = e; return false }
Expect(reprocessArtwork(ctx, ds, kinds, nil, imageAgents, false, capture, &out)).To(Succeed())
Expect(external).To(Equal(int64(2*2+2*3)), "2 artists at 2 agents plus 2 albums at 3 agents")
Expect(out.String()).To(ContainSubstring("External lookups: ~10 estimated"))
})
It("names the estimate's blind spots instead of claiming a bound it cannot hold", func() {
Expect(reprocessArtwork(ctx, ds, kinds, nil, imageAgents, true, accept, &out)).To(Succeed())
// The count includes plugin agents once they load, so the caveat is about a failed load,
// not about plugins being invisible to the CLI.
Expect(out.String()).To(ContainSubstring("plugin agents counted only when they load"))
Expect(out.String()).To(ContainSubstring("local hits may need fewer"))
Expect(out.String()).ToNot(ContainSubstring("up to"), "plugin agents make any ceiling false")
Expect(out.String()).ToNot(ContainSubstring("at least"), "a local hit makes any floor false")
})
It("says so when the selection needs no external lookup", func() {
conf.Server.CoverArtPriority = "cover.*"
put(model.KindPlaylistArtwork, "pl-1", "playlist")
Expect(reprocessArtwork(ctx, ds, []model.Kind{model.KindPlaylistArtwork}, nil, imageAgents, true, accept, &out)).To(Succeed())
Expect(out.String()).To(ContainSubstring("External lookups: none"))
})
It("counts playlists as external cost when the m3u image fetch is enabled", func() {
conf.Server.CoverArtPriority = "cover.*"
conf.Server.EnableM3UExternalAlbumArt = true
put(model.KindPlaylistArtwork, "pl-1", "playlist")
var external int64
capture := func(_ io.Writer, _, e int64) bool { external = e; return false }
Expect(reprocessArtwork(ctx, ds, []model.Kind{model.KindPlaylistArtwork}, nil, imageAgents, false, capture, &out)).To(Succeed())
Expect(external).To(Equal(int64(1)))
Expect(out.String()).To(ContainSubstring("External lookups: ~1 estimated"))
})
It("bills a playlist for every album its grid samples, at every agent", func() {
imageAgents = artwork.ImageAgentCount{Album: 3}
put(model.KindPlaylistArtwork, "pl-1", "playlist")
var external int64
capture := func(_ io.Writer, _, e int64) bool { external = e; return false }
Expect(reprocessArtwork(ctx, ds, []model.Kind{model.KindPlaylistArtwork}, nil, imageAgents, false, capture, &out)).To(Succeed())
Expect(external).To(Equal(int64(artwork.PlaylistGridSamples*3)),
"one playlist samples 4 albums, each walking all 3 album agents")
})
It("counts only the kinds that call an external agent as external cost", func() {
put(model.KindRadioArtwork, "ra-1", "upload")
var total, external int64
capture := func(_ io.Writer, t, e int64) bool { total, external = t, e; return false }
Expect(reprocessArtwork(ctx, ds, []model.Kind{model.KindAlbumArtwork, model.KindRadioArtwork},
nil, imageAgents, false, capture, &out)).To(Succeed())
Expect(total).To(Equal(int64(3)))
Expect(external).To(Equal(int64(2)), "radio artwork never reaches an external agent")
})
It("rejects an unknown source and names the ones in use", func() {
err := reprocessArtwork(ctx, ds, kinds, []string{"externa:deezer"}, imageAgents, true, accept, &out)
Expect(err).To(HaveOccurred())
Expect(err.Error()).To(ContainSubstring("externa:deezer"))
Expect(err.Error()).To(ContainSubstring("external:deezer"))
Expect(err.Error()).To(ContainSubstring("folder"))
Expect(err.Error()).To(ContainSubstring("absent"), "the empty source prints under its user-facing name")
Expect(queue.Count()).To(BeZero())
})
It("accepts the absent filter with nothing absent, still rejecting a typo", func() {
put(model.KindArtistArtwork, "ar-2", "folder")
Expect(reprocessArtwork(ctx, ds, kinds, repositorySources([]string{absentSource}),
imageAgents, false, accept, &out)).To(Succeed(),
"a reserved source must stay valid once the library has none of it")
Expect(out.String()).To(ContainSubstring("Nothing matches"))
Expect(queue.Count()).To(BeZero())
Expect(reprocessArtwork(ctx, ds, kinds, repositorySources([]string{"absnt"}),
imageAgents, true, accept, &out)).ToNot(Succeed(), "a typo must still be rejected")
})
It("accepts a source another kind uses, letting the empty selection report itself", func() {
Expect(reprocessArtwork(ctx, ds, []model.Kind{model.KindArtistArtwork}, []string{"folder"},
imageAgents, false, decline, &out)).To(Succeed())
Expect(out.String()).To(ContainSubstring("Nothing matches"),
"a well-formed filter must not be reported as a typo because of the kinds selected")
Expect(queue.Count()).To(BeZero())
})
})
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.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("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{
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}},
},
inputs: []artwork.FingerprintInput{{Name: "Agents", Value: "deezer,lastfm"}},
stored: "abc123",
current: "abc123",
}
})
// block isolates one section, so an assertion cannot be satisfied by a coincidence elsewhere.
block := func(out, header string) string {
GinkgoHelper()
_, after, found := strings.Cut(out, header+"\n")
Expect(found).To(BeTrue(), "the %q block must be printed", header)
body, _, _ := strings.Cut(after, "\n\n")
return body
}
It("names the kind and the priority of every queued row", func() {
queue := block(formatStatus(rep), "Queue")
Expect(queue).To(MatchRegexp(`artist\s+backfill\s+2`))
Expect(queue).To(MatchRegexp(`album\s+scan\s+1`))
})
It("totals the queue", func() {
Expect(block(formatStatus(rep), "Queue")).To(MatchRegexp(`TOTAL\s+3`))
})
It("counts each source, naming the empty one absent", func() {
sources := block(formatStatus(rep), "Sources")
Expect(sources).To(MatchRegexp(`artist\s+external:deezer\s+5`))
Expect(sources).To(MatchRegexp(`artist\s+absent\s+2`))
})
It("prints the absent total and how many are due for recheck", func() {
absent := block(formatStatus(rep), "Absent (resolved, no image found)")
Expect(absent).To(MatchRegexp(`artist\s+2\s+1`))
})
It("states the recheck window the absent counts are bucketed against", func() {
Expect(formatStatus(rep)).To(ContainSubstring("24h"))
})
It("leads with the queued backlog, which is the finding, not with the fingerprint verdict", func() {
out := block(formatStatus(rep), "Backfill")
Expect(out).To(MatchRegexp(`State:\s+backfill running: 2 items queued`),
"an operator scanning for trouble must not read 'up to date' while 2 items churn")
Expect(out).To(ContainSubstring("fingerprint up to date"))
})
It("keeps the re-enqueue warning while a backfill is already running", func() {
rep.stored = "older"
out := block(formatStatus(rep), "Backfill")
Expect(out).To(MatchRegexp(`State:\s+backfill running: 2 items queued`))
Expect(out).To(ContainSubstring("re-enqueued"),
"the stored fingerprint is still stale, so a second full re-enqueue is pending on top of this one")
})
It("reports up to date only once the backfill has drained", func() {
rep.queue = []model.ArtworkQueueStat{{ItemKind: "al", Priority: model.ArtworkPriorityScan, Count: 1}}
Expect(block(formatStatus(rep), "Backfill")).To(MatchRegexp(`State:\s+up to date`))
})
It("echoes the config inputs a fingerprint change would have come from", func() {
out := block(formatStatus(rep), "Backfill")
Expect(out).To(MatchRegexp(`Agents:\s+deezer,lastfm`))
Expect(out).To(ContainSubstring("abc123"), "the fingerprint values themselves must be printed")
})
It("reports a changed fingerprint as a pending re-resolve of everything", func() {
rep.stored = "older"
rep.queue = nil
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 = nil
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
var art *tests.MockArtworkRepo
var out strings.Builder
ctx := context.Background()
BeforeEach(func() {
albums := tests.CreateMockAlbumRepo()
albums.SetData(model.Albums{{ID: "al-1"}, {ID: "al-3"}})
ds = &tests.MockDataStore{MockedAlbum: albums}
art = ds.Artwork(ctx).(*tests.MockArtworkRepo)
queue = ds.ArtworkQueue(ctx).(*tests.MockArtworkQueueRepo)
out.Reset()
})
It("clears the stored state and queues each id at Bump priority", func() {
Expect(art.PutItemArtwork(&model.ItemArtwork{ItemKind: model.KindAlbumArtwork.Prefix(),
ItemID: "al-1", ImageType: model.ImageTypePrimary, Hash: "abc123"})).To(Succeed())
Expect(refreshItems(ctx, ds, model.KindAlbumArtwork, []string{"al-1", "al-3"}, &out)).To(BeZero())
_, err := art.GetItemArtwork(model.KindAlbumArtwork, "al-1", model.ImageTypePrimary)
Expect(err).To(MatchError(model.ErrNotFound))
queued, err := queue.Get(model.KindAlbumArtwork, "al-1", model.ImageTypePrimary)
Expect(err).ToNot(HaveOccurred())
Expect(queued.Priority).To(Equal(model.ArtworkPriorityBump))
Expect(out.String()).To(Equal("al/al-1: queued\nal/al-3: queued\n"))
})
It("skips an id that does not exist instead of queuing it", func() {
Expect(refreshItems(ctx, ds, model.KindAlbumArtwork, []string{"al-2"}, &out)).To(Equal(1))
_, err := queue.Get(model.KindAlbumArtwork, "al-2", model.ImageTypePrimary)
Expect(err).To(MatchError(model.ErrNotFound), "a typo must not leave an orphan queue row")
Expect(out.String()).To(BeEmpty())
})
It("continues past a failing id and counts the failures", func() {
Expect(refreshItems(ctx, ds, model.KindAlbumArtwork,
[]string{"al-1", "al-2", "al-3"}, &out)).To(Equal(1))
Expect(out.String()).To(Equal("al/al-1: queued\nal/al-3: queued\n"),
"the ids after a failure are still refreshed")
})
})
var _ = Describe("needsImageAgents", func() {
BeforeEach(func() {
DeferCleanup(configtest.SetupConfig())
conf.Server.CoverArtPriority = "cover.*, external"
conf.Server.ArtistArtPriority = "artist.*, external"
conf.Server.EnableM3UExternalAlbumArt = false
})
It("is false for a selection no agent can serve", func() {
Expect(needsImageAgents([]model.Kind{model.KindRadioArtwork})).To(BeFalse())
})
// The generated playlist grid resolves album art through the image agents, so a playlist
// selection needs the count even though no agent is asked for a playlist image directly.
It("is true for playlists, whose grid tiles resolve through the album chain", func() {
Expect(needsImageAgents([]model.Kind{model.KindPlaylistArtwork})).To(BeTrue())
})
It("is true when any one of several kinds can reach an agent", func() {
Expect(needsImageAgents([]model.Kind{model.KindRadioArtwork, model.KindAlbumArtwork})).To(BeTrue())
})
It("is false once the chains no longer reach an agent", func() {
conf.Server.CoverArtPriority = "cover.*"
conf.Server.ArtistArtPriority = "artist.*"
Expect(needsImageAgents(artwork.RecheckKinds)).To(BeFalse())
})
})
var _ = Describe("configuredAgents", func() {
BeforeEach(func() { DeferCleanup(configtest.SetupConfig()) })
It("splits and trims the configured list", func() {
conf.Server.Agents = "lastfm, spotify ,deezer"
Expect(configuredAgents()).To(Equal([]string{"lastfm", "spotify", "deezer"}))
})
// An empty name would match no plugin, but it also must not make the list look non-empty:
// LoadPlugins treats an empty list as "load nothing".
It("drops empty entries rather than passing a name nothing can match", func() {
conf.Server.Agents = " , ,"
Expect(configuredAgents()).To(BeEmpty())
})
})

View File

@ -9,7 +9,6 @@ import (
"os"
"strconv"
"strings"
"text/tabwriter"
"time"
"github.com/navidrome/navidrome/conf"
@ -314,7 +313,7 @@ func formatPluginList(list model.Plugins, format string) (string, error) {
return sb.String(), w.Error()
case "table":
var sb strings.Builder
w := tabwriter.NewWriter(&sb, 0, 4, 2, ' ', 0)
w := newTabWriter(&sb)
fmt.Fprintln(w, "ID\tNAME\tVERSION\tENABLED\tLAST ERROR")
for _, p := range list {
name, version := manifestSummary(p)

View File

@ -4,6 +4,8 @@ import (
"context"
"errors"
"fmt"
"io"
"text/tabwriter"
"github.com/navidrome/navidrome/core/auth"
"github.com/navidrome/navidrome/db"
@ -13,6 +15,11 @@ import (
"github.com/navidrome/navidrome/persistence"
)
// newTabWriter keeps every CLI table on the same column settings.
func newTabWriter(out io.Writer) *tabwriter.Writer {
return tabwriter.NewWriter(out, 0, 4, 2, ' ', 0)
}
func getAdminContext(ctx context.Context) (model.DataStore, context.Context) {
sqlDB := db.Db()
ds := persistence.New(sqlDB)

View File

@ -225,6 +225,18 @@ func CreateArtworkWorker() *artwork.Worker {
return worker
}
func CreateArtworkResolver(trace *artwork.ChainTrace, live bool) *artwork.TracingResolver {
sqlDB := db.Db()
dataStore := persistence.New(sqlDB)
broker := events.GetBroker()
metricsMetrics := metrics.GetPrometheusInstance(dataStore)
manager := plugins.GetManager(dataStore, broker, metricsMetrics)
agentsAgents := agents.GetAgents(dataStore, manager)
fFmpeg := ffmpeg.New()
tracingResolver := artwork.NewTracingResolver(dataStore, agentsAgents, fFmpeg, trace, live)
return tracingResolver
}
func getPluginManager() *plugins.Manager {
sqlDB := db.Db()
dataStore := persistence.New(sqlDB)

View File

@ -144,6 +144,13 @@ func CreateArtworkWorker() *artwork.Worker {
))
}
func CreateArtworkResolver(trace *artwork.ChainTrace, live bool) *artwork.TracingResolver {
panic(wire.Build(
allProviders,
artwork.NewTracingResolver,
))
}
func getPluginManager() *plugins.Manager {
panic(wire.Build(
allProviders,

View File

@ -47,10 +47,17 @@ func fetchArtistImage(ctx context.Context, ag *agents.Agents, gate gateFunc, ar
// Synthetic artists would otherwise get an unrelated agent result assigned to them.
switch ar.ID {
case consts.UnknownArtistID, consts.VariousArtistsID:
traceFrom(ctx).add(TraceStep{Candidate: externalCandidate, Outcome: OutcomeSkipped, Detail: "synthetic artist"})
return nil, "", false
}
name := externalName(ar.Name)
for _, a := range ag.ArtistImageAgents() {
imageAgents := ag.ArtistImageAgents()
if len(imageAgents) == 0 {
traceFrom(ctx).add(TraceStep{Candidate: externalCandidate, Outcome: OutcomeSkipped,
Detail: "no enabled agent provides artist images"})
return nil, "", false
}
for _, a := range imageAgents {
reader, _, err := gate(a.Name, func() (io.ReadCloser, string, error) {
imgs, err := a.Retriever.GetArtistImages(ctx, ar.ID, name, ar.MbzArtistID)
if err != nil {
@ -76,7 +83,13 @@ func fetchArtistImage(ctx context.Context, ag *agents.Agents, gate gateFunc, ar
// fetchAlbumImage is the album counterpart of fetchArtistImage.
func fetchAlbumImage(ctx context.Context, ag *agents.Agents, gate gateFunc, al model.Album) (r io.ReadCloser, agentName string, extErr bool) {
name, artist := externalName(al.Name), externalName(al.AlbumArtist)
for _, a := range ag.AlbumImageAgents() {
imageAgents := ag.AlbumImageAgents()
if len(imageAgents) == 0 {
traceFrom(ctx).add(TraceStep{Candidate: externalCandidate, Outcome: OutcomeSkipped,
Detail: "no enabled agent provides album images"})
return nil, "", false
}
for _, a := range imageAgents {
reader, _, err := gate(a.Name, func() (io.ReadCloser, string, error) {
imgs, err := a.Retriever.GetAlbumImages(ctx, name, artist, al.MbzAlbumID)
if err != nil {

View File

@ -173,6 +173,30 @@ var _ = Describe("agent images", func() {
Expect(a.artistCalls).To(Equal(0), "synthetic artists never reach the agents")
})
It("records a skipped external candidate when no agent provides artist images", func() {
ag := imageAgents()
t := &ChainTrace{}
r, _, extErr := fetchArtistImage(withTrace(ctx, t), ag, passthroughGate, model.Artist{ID: "ar1"})
Expect(r).To(BeNil())
Expect(extErr).To(BeFalse())
Expect(t.Steps()).To(Equal([]TraceStep{{Candidate: "external", Outcome: OutcomeSkipped,
Detail: "no enabled agent provides artist images"}}),
"a configured external token must never be silently absent from the chain")
})
It("records a skipped external candidate for synthetic artists", func() {
a := &fakeImageAgent{name: "agentA", imgs: []agents.ExternalImage{img("/a", 100)}}
ag := imageAgents(a)
t := &ChainTrace{}
_, _, _ = fetchArtistImage(withTrace(ctx, t), ag, passthroughGate,
model.Artist{ID: consts.VariousArtistsID, Name: "Various Artists"})
Expect(t.Steps()).To(HaveLen(1))
Expect(t.Steps()[0].Outcome).To(Equal(OutcomeSkipped))
Expect(t.Steps()[0].Detail).To(ContainSubstring("synthetic"))
})
It("clears typographic characters from the query name unless preserving unicode", func() {
conf.Server.DevPreserveUnicodeInExternalCalls = false
a := &fakeImageAgent{name: "agentA"}
@ -231,6 +255,18 @@ var _ = Describe("agent images", func() {
Expect(a.albumCalls).To(Equal(1))
})
It("records a skipped external candidate when no agent provides album images", func() {
ag := imageAgents()
t := &ChainTrace{}
r, _, extErr := fetchAlbumImage(withTrace(ctx, t), ag, passthroughGate, model.Album{Name: "Album"})
Expect(r).To(BeNil())
Expect(extErr).To(BeFalse())
Expect(t.Steps()).To(Equal([]TraceStep{{Candidate: "external", Outcome: OutcomeSkipped,
Detail: "no enabled agent provides album images"}}),
"a configured external token must never be silently absent from the chain")
})
It("reports extErr when the only agent fails transiently", func() {
a := &fakeImageAgent{name: "agentA", err: context.DeadlineExceeded}
ag := imageAgents(a)

View File

@ -11,6 +11,7 @@ import (
"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"
@ -118,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) {
@ -317,16 +318,15 @@ func (s *service) serveDisc(ctx context.Context, artID model.ArtworkID, size int
return nil, err
}
// Single-disc albums run the chain too: a disc can carry art distinct from the album cover.
selectImage := func() (io.ReadCloser, string, error) {
funcs := dr.fromDiscArtPriority(ctx, s.ffmpeg, conf.Server.DiscArtPriority)
return selectImageReader(ctx, artID, funcs...)
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,
func() (io.ReadCloser, error) { rc, _, err := selectImage(); return rc, err })
img, err := s.serveSource(ctx, key, "", dr.cacheTime(), size, square, selectImage)
if err != nil {
if errors.Is(err, context.Canceled) {
return nil, err
@ -386,6 +386,54 @@ func (s *service) parseArtworkID(ctx context.Context, id string) (model.ArtworkI
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. With live
// false the external tier is reported but never called.
func NewTracingResolver(ds model.DataStore, ag *agents.Agents, ffm ffmpeg.FFmpeg, t *ChainTrace, live bool) *TracingResolver {
gate := offlineGate(t)
if live {
// A diagnostic must show the provider's real answer, and one item is at most one call
// per agent, so --live deliberately bypasses the rate limiter and circuit breaker.
gate = tracingGate(t, passthroughGate)
}
return &TracingResolver{inner: newResolver(ds, ag, ffm, gate), 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{}

View File

@ -113,27 +113,71 @@ func newDiscArtworkReader(ctx context.Context, ds model.DataStore, artID model.A
}, nil
}
func (d *discArtworkReader) fromDiscArtPriority(ctx context.Context, ffmpeg ffmpeg.FFmpeg, priority string) []sourceFunc {
var ff []sourceFunc
// discCandidate is one DiscArtPriority entry. skip is set when the entry maps to no source at
// all, so a chain walk can say why instead of leaving a configured entry unaccounted for.
type discCandidate struct {
pattern string
resolve func() (resolution, bool)
skip string
}
func (d *discArtworkReader) discCandidates(ctx context.Context, ffmpeg ffmpeg.FFmpeg, priority string) []discCandidate {
folder := func(sf sourceFunc) func() (resolution, bool) {
return func() (resolution, bool) { return resolveFolderSource(d.lib, sf) }
}
var cc []discCandidate
for pattern := range strings.SplitSeq(strings.ToLower(priority), ",") {
pattern = strings.TrimSpace(pattern)
if pattern == "" {
continue
}
c := discCandidate{pattern: pattern}
switch {
case pattern == "embedded":
ff = append(ff,
fromTag(ctx, d.lib.FS, d.firstTrackRel),
fromFFmpegTag(ctx, ffmpeg, d.lib.Abs(d.firstTrackRel)),
)
case pattern == "external":
// Not supported for disc art, silently ignore
case pattern == "discsubtitle":
if subtitle := strings.TrimSpace(d.album.Discs[d.discNumber]); subtitle != "" {
ff = append(ff, d.fromDiscSubtitle(ctx, subtitle))
c.resolve = func() (resolution, bool) {
return resolveEmbedded(ctx, d.lib, ffmpeg, d.firstTrackRel)
}
case len(d.imgFiles) > 0:
ff = append(ff, d.fromExternalFile(ctx, pattern))
case pattern == externalCandidate:
c.skip = "external sources are not supported for disc artwork"
case pattern == "discsubtitle":
subtitle := strings.TrimSpace(d.album.Discs[d.discNumber])
if subtitle == "" {
c.skip = "disc has no subtitle"
} else {
c.resolve = folder(d.fromDiscSubtitle(ctx, subtitle))
}
case len(d.imgFiles) == 0:
c.skip = "no images in album folder"
default:
c.resolve = folder(d.fromExternalFile(ctx, pattern))
}
cc = append(cc, c)
}
return cc
}
// selectImage walks the DiscArtPriority entries and returns the first that yields an image.
// chain records the walk; the serving path passes an untraced one and pays nothing for it.
func (d *discArtworkReader) selectImage(ctx context.Context, ffmpeg ffmpeg.FFmpeg, priority string,
chain *chainState) (resolution, error) {
for _, c := range d.discCandidates(ctx, ffmpeg, priority) {
if err := ctx.Err(); err != nil {
return resolution{}, err
}
if c.skip != "" {
chain.record(c.pattern, OutcomeSkipped, c.skip)
continue
}
start := time.Now()
res, ok := c.resolve()
log.Trace(ctx, "Artwork: Tried a disc artwork candidate", "albumID", d.album.ID,
"disc", d.discNumber, "pattern", c.pattern, "hit", ok, "path", res.sourcePath,
"elapsed", time.Since(start))
if res, ok = chain.try(c.pattern, res, ok); ok {
return res, nil
}
}
return ff
return chain.exhausted(), nil
}
// fromDiscSubtitle returns a sourceFunc that matches image files whose stem

View File

@ -6,6 +6,8 @@ import (
"path/filepath"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/tests"
"github.com/navidrome/navidrome/utils/slice"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
@ -179,19 +181,19 @@ var _ = Describe("Disc Artwork Reader", func() {
lib: libraryView{FS: osDirFS{os.DirFS(tmpDir)}, absRoot: tmpDir},
}
ff := reader.fromDiscArtPriority(ctx, nil, "disc*.*, cover.*")
Expect(ff).To(HaveLen(2))
r, path, err := ff[0]()
Expect(err).ToNot(HaveOccurred())
Expect(path).To(Equal(f2))
r.Close()
cc := reader.discCandidates(ctx, nil, "disc*.*, cover.*")
Expect(cc).To(HaveLen(2))
res, ok := cc[0].resolve()
Expect(ok).To(BeTrue())
Expect(res.sourcePath).To(Equal(reader.lib.Abs(f2)))
res.reader.Close()
ff = reader.fromDiscArtPriority(ctx, nil, "cover.*, disc*.*")
Expect(ff).To(HaveLen(2))
r, path, err = ff[0]()
Expect(err).ToNot(HaveOccurred())
Expect(path).To(Equal(f1))
r.Close()
cc = reader.discCandidates(ctx, nil, "cover.*, disc*.*")
Expect(cc).To(HaveLen(2))
res, ok = cc[0].resolve()
Expect(ok).To(BeTrue())
Expect(res.sourcePath).To(Equal(reader.lib.Abs(f1)))
res.reader.Close()
})
DescribeTable("numbered match wins over shared fallback within a pattern",
@ -428,64 +430,109 @@ var _ = Describe("Disc Artwork Reader", func() {
})
Describe("discArtworkReader", func() {
Describe("fromDiscArtPriority", func() {
var (
reader *discArtworkReader
tmpDir string
var (
reader *discArtworkReader
tmpDir string
)
BeforeEach(func() {
tmpDir = GinkgoT().TempDir()
reader = &discArtworkReader{
discNumber: 2,
isMultiFolder: true,
discFoldersRel: map[string]bool{"music/album/cd2": true},
imgFiles: []string{
"music/album/cd1/disc.jpg",
"music/album/cd2/disc.jpg",
"music/album/cd2/disc2.jpg",
},
firstTrackRel: "music/album/cd2/track1.flac",
lib: libraryView{FS: osDirFS{os.DirFS(tmpDir)}, absRoot: tmpDir},
}
})
Describe("selectImage", func() {
It("abandons the walk when the context is cancelled", func() {
ctx, cancel := context.WithCancel(context.Background())
cancel()
res, err := reader.selectImage(ctx, nil, "disc*.*, cover.*", &chainState{})
Expect(err).To(MatchError(context.Canceled))
Expect(res.reader).To(BeNil())
})
// "the track has no embedded art" and "the track is there but unreadable" are the two
// answers a wrong-artwork report needs told apart; only the second is worth retrying.
It("reports a track it cannot parse as unreadable, not as a miss", func() {
trace := &ChainTrace{}
track := filepath.Join(tmpDir, filepath.FromSlash(reader.firstTrackRel))
Expect(os.MkdirAll(filepath.Dir(track), 0755)).To(Succeed())
Expect(os.WriteFile(track, []byte("not audio"), 0600)).To(Succeed())
res, err := reader.selectImage(context.Background(), tests.NewMockFFmpeg(""), "embedded",
&chainState{trace: trace})
Expect(err).ToNot(HaveOccurred())
Expect(res.localError).To(BeTrue())
Expect(trace.Steps()).To(Equal([]TraceStep{{Candidate: "embedded", Outcome: OutcomeUnreadable}}))
})
It("reports a disc with no tracks to read as a miss", func() {
trace := &ChainTrace{}
reader.firstTrackRel = ""
res, err := reader.selectImage(context.Background(), tests.NewMockFFmpeg(""), "embedded",
&chainState{trace: trace})
Expect(err).ToNot(HaveOccurred())
Expect(res.localError).To(BeFalse(), "there was nothing to read, so nothing failed to read")
Expect(trace.Steps()).To(Equal([]TraceStep{{Candidate: "embedded", Outcome: OutcomeMiss}}))
})
})
Describe("discCandidates", func() {
It("returns a resolvable candidate for glob patterns", func() {
cc := reader.discCandidates(context.Background(), nil, "disc*.*")
Expect(cc).To(HaveLen(1))
Expect(cc[0].resolve).ToNot(BeNil())
})
It("returns one candidate per entry, in order", func() {
cc := reader.discCandidates(context.Background(), nil, "disc*.*, cd*.*, embedded")
Expect(slice.Map(cc, func(c discCandidate) string { return c.pattern })).
To(Equal([]string{"disc*.*", "cd*.*", "embedded"}))
})
It("skips an empty entry rather than building a glob that matches nothing", func() {
cc := reader.discCandidates(context.Background(), nil, "disc*.*,")
Expect(cc).To(HaveLen(1))
})
// The skip reasons below are what `artwork explain` prints, so an entry that maps to no
// source must say why instead of vanishing from the walk.
DescribeTable("keeps an entry that maps to no source, with its reason",
func(setup func(), priority, reason string) {
setup()
cc := reader.discCandidates(context.Background(), nil, priority)
Expect(cc).To(HaveLen(1))
Expect(cc[0].resolve).To(BeNil())
Expect(cc[0].skip).To(Equal(reason))
},
Entry("external is unsupported", func() {}, "external",
"external sources are not supported for disc artwork"),
Entry("no images in the album folder", func() { reader.imgFiles = nil }, "disc*.*",
"no images in album folder"),
Entry("the disc has no subtitle",
func() { reader.album = model.Album{Discs: model.Discs{2: ""}} }, "discsubtitle",
"disc has no subtitle"),
)
BeforeEach(func() {
tmpDir = GinkgoT().TempDir()
reader = &discArtworkReader{
discNumber: 2,
isMultiFolder: true,
discFoldersRel: map[string]bool{"music/album/cd2": true},
imgFiles: []string{
"music/album/cd1/disc.jpg",
"music/album/cd2/disc.jpg",
"music/album/cd2/disc2.jpg",
},
firstTrackRel: "music/album/cd2/track1.flac",
lib: libraryView{FS: osDirFS{os.DirFS(tmpDir)}, absRoot: tmpDir},
}
})
It("returns source funcs for glob patterns", func() {
ff := reader.fromDiscArtPriority(context.Background(), nil, "disc*.*")
Expect(ff).To(HaveLen(1))
})
It("returns source funcs for embedded pattern", func() {
ff := reader.fromDiscArtPriority(context.Background(), nil, "embedded")
Expect(ff).To(HaveLen(2)) // fromTag + fromFFmpegTag
})
It("handles multiple comma-separated patterns", func() {
ff := reader.fromDiscArtPriority(context.Background(), nil, "disc*.*, cd*.*, embedded")
Expect(ff).To(HaveLen(4)) // disc*.* + cd*.* + fromTag + fromFFmpegTag
})
It("ignores 'external' pattern silently", func() {
ff := reader.fromDiscArtPriority(context.Background(), nil, "external")
Expect(ff).To(HaveLen(0))
})
It("returns no source funcs when imgFiles is empty and pattern is not embedded", func() {
reader.imgFiles = nil
ff := reader.fromDiscArtPriority(context.Background(), nil, "disc*.*")
Expect(ff).To(HaveLen(0))
})
It("returns source func for discsubtitle pattern", func() {
reader.album = model.Album{Discs: model.Discs{2: "Bonus Tracks"}}
ff := reader.fromDiscArtPriority(context.Background(), nil, "discsubtitle")
Expect(ff).To(HaveLen(1))
})
It("returns no source func for discsubtitle when disc has no subtitle", func() {
reader.album = model.Album{Discs: model.Discs{2: ""}}
ff := reader.fromDiscArtPriority(context.Background(), nil, "discsubtitle")
Expect(ff).To(HaveLen(0))
cc := reader.discCandidates(context.Background(), nil, "discsubtitle")
Expect(cc).To(HaveLen(1))
Expect(cc[0].resolve).ToNot(BeNil())
})
})
})

View File

@ -6,6 +6,7 @@ import (
"errors"
"io"
"os"
"path/filepath"
"time"
"github.com/navidrome/navidrome/conf"
@ -116,6 +117,25 @@ var _ = Describe("Acquisition → serve loop", func() {
albumRepo.SetData(model.Albums{{ID: albumID, Name: "Album", FolderIDs: []string{"f1"}, LibraryID: 0}})
}
It("acquires and serves a cover whose format has no registered decoder (#5950)", func() {
libDir := GinkgoT().TempDir()
Expect(os.MkdirAll(filepath.Join(libDir, "an-album"), 0755)).To(Succeed())
Expect(os.WriteFile(filepath.Join(libDir, "an-album", "cover.jxl"), jxlFixture, 0600)).To(Succeed())
conf.Server.CoverArtPriority = "cover.*"
libRepo.SetData(model.Libraries{{ID: 0, Path: libDir}})
folderRepo.result = []model.Folder{{Path: "an-album", ImageFiles: []string{"cover.jxl"}}}
albumRepo.SetData(model.Albums{{ID: "al1", Name: "Album", FolderIDs: []string{"f1"}, LibraryID: 0}})
bump("al", "al1")
runWorkerUntil(ctx, worker, itemFound(model.KindAlbumArtwork, "al1"))
img, err := svc.Get(ctx, model.MustParseArtworkID("al-al1"), 0, false)
Expect(err).ToNot(HaveOccurred())
Expect(img.Placeholder).To(BeFalse())
Expect(readAll(img)).To(Equal(jxlFixture))
})
It("acquires album folder art and serves the exact bytes under its hash", func() {
seedFolderAlbum("al1")
bump("al", "al1")
@ -317,6 +337,9 @@ func mustGet(img *artwork.Image, err error) *artwork.Image {
}
// Raw bytes on purpose: encoding a GIF here would register image/gif in the test binary, masking
// jxlFixture is a JPEG XL bare codestream header: a real image format, with no stdlib decoder.
var jxlFixture = []byte{0xff, 0x0a, 0x00, 0x10, 0x00}
// the production import the spec above guards.
var gifFixture = []byte{
0x47, 0x49, 0x46, 0x38, 0x39, 0x61, 0x04, 0x00, 0x04, 0x00, 0x80, 0x00,

View File

@ -17,6 +17,11 @@ import (
const (
breakerThreshold = 5
breakerProbeAfter = time.Minute
// breakerRecoveries is how many consecutive answers an open breaker needs before it trusts the
// provider again. One is not enough: a provider that is rate-limiting or blocking us still
// answers the occasional request, and closing on the first of those puts the agent straight
// back to full rate, which is what earns the next block.
breakerRecoveries = 3
)
var errBreakerOpen = errors.New("artwork: external circuit breaker open")
@ -44,7 +49,8 @@ type extGate struct {
// gate runs a named external step through that agent's rate limiter and circuit breaker.
func (w *Worker) gate(name string, f func() (io.ReadCloser, string, error)) (io.ReadCloser, string, error) {
g := w.gateFor(name)
if !g.breaker.allow() {
allowed, gen := g.breaker.allow()
if !allowed {
log.Debug(w.runCtx, "Artwork: Skipping agent, circuit breaker open", "agent", name)
return nil, "", errBreakerOpen
}
@ -55,7 +61,7 @@ func (w *Worker) gate(name string, f func() (io.ReadCloser, string, error)) (io.
}
callStart := time.Now()
r, path, err := f()
g.breaker.record(name, err)
g.breaker.record(name, gen, err)
log.Trace(w.runCtx, "Artwork: External agent call", "agent", name, "hit", r != nil,
"limiterWait", callStart.Sub(waitStart), "elapsed", time.Since(callStart), err)
return r, path, err
@ -79,46 +85,70 @@ func (w *Worker) gateFor(name string) *extGate {
}
// breaker opens after breakerThreshold consecutive errors and admits a single probe once
// breakerProbeAfter has elapsed; a success re-closes it.
// breakerProbeAfter has elapsed; it closes after breakerRecoveries consecutive answers.
type breaker struct {
mu sync.Mutex
failures int
openedAt time.Time
// recoveries counts consecutive good answers while open; a single failure discards them.
recoveries int
// generation identifies the current open episode, so an answer from a call admitted before
// the breaker opened cannot be mistaken for evidence that it has recovered.
generation int
}
func newBreaker() *breaker { return &breaker{} }
func (b *breaker) allow() bool {
// allow reports whether a call may proceed, and the open episode it was admitted under: zero
// when the breaker was closed, the current generation when admitted as a half-open probe.
func (b *breaker) allow() (bool, int) {
b.mu.Lock()
defer b.mu.Unlock()
if b.failures < breakerThreshold {
return true
return true, 0
}
if time.Since(b.openedAt) >= breakerProbeAfter {
b.openedAt = time.Now() // start a fresh probe window so only one caller passes
return true
return true, b.generation
}
return false
return false, 0
}
func (b *breaker) record(name string, err error) {
func (b *breaker) record(name string, gen int, err error) {
// A cancelled run says nothing about the provider, so it neither counts nor clears.
if errors.Is(err, context.Canceled) {
return
}
b.mu.Lock()
defer b.mu.Unlock()
if !isTransientExternal(err) {
if b.failures >= breakerThreshold {
log.Info("Artwork: Circuit breaker closed for agent", "agent", name)
if isTransientExternal(err) {
b.recoveries = 0
b.failures++
if b.failures == breakerThreshold {
b.openedAt = time.Now()
b.generation++
log.Warn("Artwork: Circuit breaker opened for agent", "agent", name,
"consecutiveFailures", b.failures, "probeAfter", breakerProbeAfter, err)
}
return
}
if b.failures < breakerThreshold {
b.failures = 0
return
}
b.failures++
if b.failures == breakerThreshold {
b.openedAt = time.Now()
log.Warn("Artwork: Circuit breaker opened for agent", "agent", name,
"consecutiveFailures", b.failures, "probeAfter", breakerProbeAfter, err)
// Only a probe from this open episode is evidence of recovery. The worker drains concurrently,
// so answers keep arriving from calls admitted before the breaker opened; counting those would
// close it with no probe interval elapsed, which is the burst this exists to prevent.
if gen == 0 || gen != b.generation {
return
}
// A not-found counts because the provider did answer, but on its own it is thin evidence that
// a provider which just blocked us is well.
b.recoveries++
if b.recoveries < breakerRecoveries {
return
}
log.Info("Artwork: Circuit breaker closed for agent", "agent", name,
"consecutiveAnswers", b.recoveries)
b.failures, b.recoveries = 0, 0
}

43
core/artwork/gate_test.go Normal file
View File

@ -0,0 +1,43 @@
package artwork
import (
"errors"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
// allowed drops the generation token when a caller only cares about admission.
func allowed(b *breaker) bool { ok, _ := b.allow(); return ok }
var _ = Describe("breaker", func() {
// The worker drains concurrently, so when the breaker opens there are already calls past
// allow(), queued in the rate limiter or waiting on a response. Their answers arrive
// afterwards. Counting those as recovery closes the breaker with no probe interval elapsed,
// which is the burst the ramp exists to prevent. No clock is involved: the race is an
// ordering, so it is reproduced by making the calls in the order concurrency produces.
It("ignores answers from calls admitted before it opened", func() {
b := newBreaker()
// A batch clears allow() while the breaker is still closed.
for range breakerThreshold + breakerRecoveries {
ok, gen := b.allow()
Expect(ok).To(BeTrue())
Expect(gen).To(BeZero(), "admitted with the breaker closed, so not a probe")
}
// The fast failures in that batch open it.
for range breakerThreshold {
b.record("agentA", 0, errors.New("blocked"))
}
Expect(allowed(b)).To(BeFalse(), "breaker is open")
// The slower answers from the same batch land now.
for range breakerRecoveries {
b.record("agentA", 0, nil)
}
Expect(allowed(b)).To(BeFalse(),
"answers from calls admitted before the breaker opened must not close it")
})
})

View File

@ -6,6 +6,8 @@ import (
"encoding/hex"
"fmt"
"slices"
"strconv"
"strings"
"time"
"github.com/navidrome/navidrome/conf"
@ -16,27 +18,54 @@ 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{
// 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)
return ok && slices.Contains(RecheckKinds, kind)
}
// 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 {
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)
// 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)
sum := md5.Sum([]byte(raw)) //nolint:gosec // fingerprint, not security-sensitive
return hex.EncodeToString(sum[:])
}
@ -45,7 +74,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,9 +124,9 @@ 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 {
for _, kind := range RecheckKinds {
if _, err := queue.EnqueueStaleAbsent(kind, cutoff); err != nil {
return err
}
@ -108,7 +137,7 @@ func enqueueStaleAbsentAll(ctx context.Context, ds model.DataStore) error {
// 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 {
for _, kind := range RecheckKinds {
if _, err := queue.EnqueueAllMissing(kind, model.ArtworkPriorityRecheck); err != nil {
return err
}

View File

@ -52,6 +52,19 @@ func (o *orderTrackingQueueRepo) Enqueue(items ...model.ArtworkQueueItem) error
return o.MockArtworkQueueRepo.Enqueue(items...)
}
var _ = Describe("RefreshableKinds", func() {
// The two are meant to describe the same fact. Nothing but this test stops them from drifting,
// and a drift would have `artwork explain` report state for a kind that keeps none.
It("holds exactly the kinds that keep state", func() {
for _, k := range []model.Kind{
model.KindArtistArtwork, model.KindAlbumArtwork, model.KindPlaylistArtwork,
model.KindRadioArtwork, model.KindMediaFileArtwork, model.KindDiscArtwork,
} {
Expect(slices.Contains(RefreshableKinds, k)).To(Equal(KeepsState(k)), k.String())
}
})
})
var _ = Describe("Housekeeping", func() {
var (
ctx context.Context
@ -93,32 +106,54 @@ 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))
})
// Pinned: a changed formula re-resolves every library on upgrade, flooding external providers.
It("hashes a given config to a stable value", func() {
conf.Server.CoverArtPriority = "cover.*, embedded"
conf.Server.ArtistArtPriority = "artist.*, external"
conf.Server.ArtistImageFolder = ""
conf.Server.Agents = "lastfm,spotify"
conf.Server.EnableExternalServices = true
conf.Server.EnableM3UExternalAlbumArt = false
Expect(ConfigFingerprint()).To(Equal("7e537a22febc07d3d5ca40546e88da54"))
})
It("reports the config inputs it hashes, so a change can be traced to a setting", func() {
conf.Server.Agents = "lastfm,spotify"
conf.Server.CoverArtPriority = "cover.*, embedded"
Expect(FingerprintInputs()).To(ContainElements(
FingerprintInput{Name: "Agents", Value: "lastfm,spotify"},
FingerprintInput{Name: "CoverArtPriority", Value: "cover.*, embedded"},
))
})
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 +161,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 +185,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

@ -55,6 +55,10 @@ func (r *resizedItem) Reader(ctx context.Context) (io.ReadCloser, error) {
if err != nil {
return nil, err
}
// An open() that reports "no image" as a nil reader would otherwise panic on the Close below.
if orig == nil {
return nil, ErrUnavailable
}
defer orig.Close()
data, err := readCapped(orig)
if err != nil {

View File

@ -0,0 +1,41 @@
package artwork
import (
"context"
"errors"
"io"
"strings"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
var _ = Describe("resizedItem", func() {
Describe("Reader", func() {
newItem := func(open func() (io.ReadCloser, error)) *resizedItem {
return &resizedItem{hash: "abc123", size: 300, open: open}
}
It("reports a nil reader as unavailable instead of panicking on it", func() {
// Every caller is expected to report "no image" as an error, but a nil reader reaches
// the deferred Close as a nil interface, which takes the whole request down.
_, err := newItem(func() (io.ReadCloser, error) { return nil, nil }).Reader(context.Background())
Expect(err).To(MatchError(ErrUnavailable))
})
It("propagates the open error", func() {
boom := errors.New("boom")
_, err := newItem(func() (io.ReadCloser, error) { return nil, boom }).Reader(context.Background())
Expect(err).To(MatchError(boom))
})
It("serves the original bytes when they cannot be resized", func() {
rc, err := newItem(func() (io.ReadCloser, error) {
return io.NopCloser(strings.NewReader("not an image")), nil
}).Reader(context.Background())
Expect(err).ToNot(HaveOccurred())
defer rc.Close()
Expect(io.ReadAll(rc)).To(Equal([]byte("not an image")))
})
})
})

View File

@ -123,12 +123,20 @@ func (p *processor) acquire(ctx context.Context, item model.ArtworkQueueItem) (o
art, err := repo.GetImage(hash)
switch {
case err == nil:
case err == nil && art.Width > 0:
log.Debug(ctx, "Artwork: Reusing a known image, skipping decode", "kind", item.ItemKind,
"id", item.ItemID, "hash", hash)
case errors.Is(err, model.ErrNotFound):
// A row with no dimensions was stored when no decoder matched; retry in case one exists now.
case err == nil, errors.Is(err, model.ErrNotFound):
decodeStart := time.Now()
art, err = decodeArtwork(ctx, hash, data)
// Extension-matched local bytes we cannot decode are most likely a codec we lack; an
// external body carries no such guarantee, and empty bytes are no image at all.
if errors.Is(err, image.ErrFormat) && len(data) > 0 && isLocalSource(res.source) {
log.Debug(ctx, "Artwork: No decoder for this image format, storing it without placeholders",
"kind", item.ItemKind, "id", item.ItemID, "source", res.source, "bytes", len(data))
art, err = undecodedArtwork(hash), nil
}
if err != nil {
log.Warn(ctx, "Artwork: Failed to decode resolved image", "kind", item.ItemKind, "id", item.ItemID, err)
return outcomeFailed, nil
@ -234,6 +242,12 @@ func decodeCapped(data []byte) (image.Image, string, error) {
return img, format, nil
}
// undecodedArtwork is the row for bytes no decoder matched: servable, but with no dimensions
// and none of the placeholders a decode would have produced.
func undecodedArtwork(hash string) *model.Artwork {
return &model.Artwork{Hash: hash, Mime: mimeForFormat("")}
}
// decodeArtwork builds a new Artwork row from raw bytes: dimensions, mime and the two
// placeholder hashes, both encoded from one shared downscaled thumbnail.
func decodeArtwork(ctx context.Context, hash string, data []byte) (*model.Artwork, error) {
@ -288,6 +302,11 @@ func isFileBacked(source string) bool {
return source == "folder" || source == "upload"
}
// isLocalSource reports whether the bytes came off disk rather than off the network.
func isLocalSource(source string) bool {
return isFileBacked(source) || source == "embedded"
}
// placeBytes reports the item's backing-file provenance and writes the bytes into the store
// for the sources that have none.
func placeBytes(store *ImageStore, art *model.Artwork, res resolution, data []byte) (sourcePath string, refMtime int64, err error) {

View File

@ -1,6 +1,7 @@
package artwork
import (
"bytes"
"context"
"encoding/binary"
"errors"
@ -24,6 +25,9 @@ import (
. "github.com/onsi/gomega"
)
// jxlCodestream is a JPEG XL bare codestream header: a real image format, with no stdlib decoder.
var jxlCodestream = []byte{0xff, 0x0a, 0x00, 0x10, 0x00}
// DecodeConfig reads only the header, so the pixel data can be omitted entirely.
func pngHeaderWithDims(w, h uint32) []byte {
ihdr := make([]byte, 13)
@ -259,6 +263,59 @@ var _ = Describe("processor.acquire", func() {
Expect(ia.Source).To(Equal("folder"))
})
It("undecodable local file: acquires it anyway, with no placeholder metadata", func() {
libRoot := GinkgoT().TempDir()
Expect(os.MkdirAll(filepath.Join(libRoot, "album"), 0755)).To(Succeed())
Expect(os.WriteFile(filepath.Join(libRoot, "album", "cover.jpg"), jxlCodestream, 0600)).To(Succeed())
libRepo.SetData(model.Libraries{{ID: 0, Path: testFileLibPath(libRoot)}})
ds.MockedAlbum.(*tests.MockAlbumRepo).SetData(model.Albums{{ID: "alU", Name: "Album", FolderIDs: []string{"f1"}}})
folderRepo.result = []model.Folder{{Path: "album", ImageFiles: []string{"cover.jpg"}}}
out, _ := proc.acquire(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "alU"})
Expect(out).To(Equal(outcomeFound))
ia, err := artRepo.GetItemArtwork(model.KindAlbumArtwork, "alU", model.ImageTypePrimary)
Expect(err).ToNot(HaveOccurred())
Expect(ia.Source).To(Equal("folder"))
art, err := artRepo.GetImage(ia.Hash)
Expect(err).ToNot(HaveOccurred())
Expect(art.Width).To(BeZero())
Expect(art.BlurHash).To(BeEmpty())
})
It("empty local file: fails without writing state", func() {
libRoot := GinkgoT().TempDir()
Expect(os.MkdirAll(filepath.Join(libRoot, "album"), 0755)).To(Succeed())
Expect(os.WriteFile(filepath.Join(libRoot, "album", "cover.jpg"), nil, 0600)).To(Succeed())
libRepo.SetData(model.Libraries{{ID: 0, Path: testFileLibPath(libRoot)}})
ds.MockedAlbum.(*tests.MockAlbumRepo).SetData(model.Albums{{ID: "alE", Name: "Album", FolderIDs: []string{"f1"}}})
folderRepo.result = []model.Folder{{Path: "album", ImageFiles: []string{"cover.jpg"}}}
out, _ := proc.acquire(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "alE"})
Expect(out).To(Equal(outcomeFailed))
_, err := artRepo.GetItemArtwork(model.KindAlbumArtwork, "alE", model.ImageTypePrimary)
Expect(err).To(MatchError(model.ErrNotFound))
})
// An agent answering 200 with a non-image body must keep retrying, not pin garbage as a cover.
It("undecodable external body: fails without writing state", func() {
conf.Server.CoverArtPriority = "external"
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
_, _ = w.Write([]byte("<html>rate limited</html>"))
}))
DeferCleanup(srv.Close)
ds.MockedAlbum.(*tests.MockAlbumRepo).SetData(model.Albums{{ID: "alX", Name: "Album"}})
imageAgents(&fakeImageAgent{name: "deezerFake", imgs: []agents.ExternalImage{{URL: srv.URL, Size: 500}}})
out, _ := proc.acquire(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "alX"})
Expect(out).To(Equal(outcomeFailed))
_, err := artRepo.GetItemArtwork(model.KindAlbumArtwork, "alX", model.ImageTypePrimary)
Expect(err).To(MatchError(model.ErrNotFound))
})
It("found-external: persists source as external:<agentName> and stores the fetched bytes", func() {
conf.Server.CoverArtPriority = "external"
imgBytes, err := os.ReadFile(filepath.Join(repoRoot, "tests/fixtures/artist/an-album/cover.jpg"))
@ -374,7 +431,8 @@ var _ = Describe("processor.acquire", func() {
conf.Server.DataFolder = conf.NewDir(tmpDir)
Expect(os.MkdirAll(filepath.Join(tmpDir, "artwork", "radio"), 0755)).To(Succeed())
imgPath := filepath.Join(tmpDir, "artwork", "radio", "ra1_test.jpg")
Expect(os.WriteFile(imgPath, []byte("not actually an image"), 0600)).To(Succeed())
// Truncated PNG: a known format, so this is a real decode failure, not a missing decoder.
Expect(os.WriteFile(imgPath, pngHeaderWithDims(100, 100), 0600)).To(Succeed())
radioRepo := tests.CreateMockedRadioRepo()
radioRepo.Data = map[string]*model.Radio{"ra1": {ID: "ra1", Name: "Radio", UploadedImage: "ra1_test.jpg"}}
@ -428,6 +486,54 @@ var _ = Describe("processor.acquire", func() {
Expect(err.Error()).To(ContainSubstring("dimensions"))
})
It("unknown format: reports ErrFormat so the caller can decide", func() {
_, err := decodeArtwork(ctx, "jxl", jxlCodestream)
Expect(err).To(MatchError(image.ErrFormat))
})
It("undecodedArtwork: carries the hash and mime, and nothing a decode would add", func() {
art := undecodedArtwork("jxl")
Expect(art.Hash).To(Equal("jxl"))
Expect(art.Mime).To(Equal("application/octet-stream"))
Expect(art.Width).To(BeZero())
Expect(art.Height).To(BeZero())
Expect(art.BlurHash).To(BeEmpty())
Expect(art.ThumbHash).To(BeEmpty())
Expect(art.DominantColor).To(BeEmpty())
})
It("corrupt image of a known format: still fails", func() {
data := pngHeaderWithDims(100, 100) // header declares a decodable size, body is missing
_, err := decodeArtwork(ctx, "truncated", data)
Expect(err).To(HaveOccurred())
Expect(err.Error()).To(ContainSubstring("decode image"))
})
// Without this a metadata-less row would be reused forever, so a decoder added later
// could never upgrade it.
It("metadata-less row: re-decodes on reuse instead of skipping", func() {
libRoot := GinkgoT().TempDir()
imgBytes, err := os.ReadFile(filepath.Join(repoRoot, "tests/fixtures/artist/an-album/cover.jpg"))
Expect(err).ToNot(HaveOccurred())
Expect(os.MkdirAll(filepath.Join(libRoot, "album"), 0755)).To(Succeed())
Expect(os.WriteFile(filepath.Join(libRoot, "album", "cover.jpg"), imgBytes, 0600)).To(Succeed())
libRepo.SetData(model.Libraries{{ID: 0, Path: testFileLibPath(libRoot)}})
ds.MockedAlbum.(*tests.MockAlbumRepo).SetData(model.Albums{{ID: "alM", Name: "Album", FolderIDs: []string{"f1"}}})
folderRepo.result = []model.Folder{{Path: "album", ImageFiles: []string{"cover.jpg"}}}
hash, err := hashImage(bytes.NewReader(imgBytes))
Expect(err).ToNot(HaveOccurred())
Expect(artRepo.PutImage(&model.Artwork{Hash: hash, Mime: "application/octet-stream"})).To(Succeed())
out, _ := proc.acquire(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "alM"})
Expect(out).To(Equal(outcomeFound))
upgraded, err := artRepo.GetImage(hash)
Expect(err).ToNot(HaveOccurred())
Expect(upgraded.Width).To(BeNumerically(">", 0))
Expect(upgraded.BlurHash).ToNot(BeEmpty())
})
It("store write failure: fails without writing state", func() {
ds.MockedAlbum.(*tests.MockAlbumRepo).SetData(model.Albums{
{ID: "al7", Name: "Album", EmbedArtPath: "tests/fixtures/artist/an-album/test.mp3", FolderIDs: []string{"f1"}},

View File

@ -9,13 +9,14 @@ import (
"io/fs"
"net/url"
"os"
"path"
"strings"
"github.com/Masterminds/squirrel"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/core/agents"
"github.com/navidrome/navidrome/core/ffmpeg"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/persistence"
)
// resolution is one attempted acquisition outcome for an entity.
@ -34,18 +35,31 @@ type resolution struct {
// chainState carries what a priority walk has seen so far. A hit takes extErr with it so a
// transient external failure still retries; localErr is dropped, as the scanner re-lists changes.
type chainState struct{ extErr, localErr bool }
type chainState struct {
extErr, localErr bool
trace *ChainTrace // nil unless the CLI asked for a trace
}
// try stamps the accumulated external failure onto a hit, and records the miss otherwise.
func (c *chainState) try(res resolution, ok bool) (resolution, bool) {
func (c *chainState) try(candidate string, res resolution, ok bool) (resolution, bool) {
if ok {
res.extError = c.extErr
c.record(candidate, OutcomeHit, res.sourcePath)
return res, true
}
c.localErr = c.localErr || res.localError
if res.localError {
c.record(candidate, OutcomeUnreadable, "")
} else {
c.record(candidate, OutcomeMiss, "")
}
return resolution{}, false
}
func (c *chainState) record(candidate string, out Outcome, detail string) {
c.trace.add(TraceStep{Candidate: candidate, Outcome: out, Detail: detail})
}
// exhausted is the outcome when no source in the chain yielded an image.
func (c *chainState) exhausted() resolution {
return resolution{extError: c.extErr, localError: c.localErr}
@ -95,8 +109,69 @@ func (r *resolver) resolve(ctx context.Context, item model.ArtworkQueueItem) (re
}
}
// fetchExternalAlbum and fetchExternalArtist are the only places resolution touches the network,
// so a local-only resolver is stopped here rather than at each point in the chain walk.
// Explainable reports whether TracingResolver can walk this kind's sources and report which one
// won; playlists and radios resolve from a fixed internal order, with nothing configured to explain.
func Explainable(kind model.Kind) bool {
switch kind {
case model.KindArtistArtwork, model.KindAlbumArtwork, model.KindDiscArtwork, model.KindMediaFileArtwork:
return true
}
return false
}
// MayFetchExternal reports whether resolving this kind can issue an external request under the
// current config. Playlists inherit the album chain: the generated grid resolves album art.
func MayFetchExternal(kind model.Kind) bool {
switch kind {
case model.KindArtistArtwork:
return chainFetchesExternal(conf.Server.ArtistArtPriority)
case model.KindAlbumArtwork:
return chainFetchesExternal(conf.Server.CoverArtPriority)
case model.KindPlaylistArtwork:
return conf.Server.EnableM3UExternalAlbumArt || chainFetchesExternal(conf.Server.CoverArtPriority)
default:
return false
}
}
// ImageAgentCount is how many enabled agents provide artist and album images.
type ImageAgentCount struct{ Artist, Album int }
// ExternalLookupsPerItem reports what resolving one item of this kind can cost: every image agent is
// tried, and a zero count still bills one, so agents the caller cannot see never read as free.
func ExternalLookupsPerItem(kind model.Kind, agents ImageAgentCount) int64 {
if !MayFetchExternal(kind) {
return 0
}
switch kind {
case model.KindArtistArtwork:
return int64(max(agents.Artist, 1))
case model.KindAlbumArtwork:
return int64(max(agents.Album, 1))
case model.KindPlaylistArtwork:
var n int64
if conf.Server.EnableM3UExternalAlbumArt {
n++
}
if chainFetchesExternal(conf.Server.CoverArtPriority) {
n += PlaylistGridSamples * int64(max(agents.Album, 1))
}
return n
}
return 0
}
func chainFetchesExternal(priority string) bool {
for pattern := range strings.SplitSeq(strings.ToLower(priority), ",") {
if strings.TrimSpace(pattern) == externalCandidate {
return true
}
}
return false
}
// Album and artist fetches stop here when the resolver is local-only, rather than at each point in
// the chain walk; resolvePlaylist gates the third network path, the m3u image URL, itself.
func (r *resolver) fetchExternalAlbum(ctx context.Context, al model.Album) (io.ReadCloser, string, bool) {
if r.ext == nil {
return nil, "", false
@ -126,24 +201,31 @@ func (r *resolver) resolveAlbum(ctx context.Context, albumID string) (resolution
return resolution{}, err
}
var chain chainState
chain := chainState{trace: traceFrom(ctx)}
for pattern := range strings.SplitSeq(strings.ToLower(conf.Server.CoverArtPriority), ",") {
pattern = strings.TrimSpace(pattern)
if pattern == "" {
continue
}
switch {
case pattern == "embedded":
if res, ok := chain.try(resolveEmbedded(ctx, lib, r.ffmpeg, al.EmbedArtPath)); ok {
res, ok := resolveEmbedded(ctx, lib, r.ffmpeg, al.EmbedArtPath)
if res, ok = chain.try(pattern, res, ok); ok {
return res, nil
}
case pattern == "external":
case pattern == externalCandidate:
if rd, name, isErr := r.fetchExternalAlbum(ctx, *al); rd != nil {
return resolution{reader: rd, source: "external:" + name}, nil
return resolution{reader: rd, source: ExternalPrefix + name}, nil
} else if isErr {
chain.extErr = true
}
case len(imgFiles) > 0:
if res, ok := chain.try(resolveFolderFile(ctx, lib, imgFiles, pattern)); ok {
res, ok := resolveFolderFile(ctx, lib, imgFiles, pattern)
if res, ok = chain.try(pattern, res, ok); ok {
return res, nil
}
default:
chain.record(pattern, OutcomeSkipped, "no images in album folder")
}
}
return chain.exhausted(), nil
@ -155,9 +237,10 @@ func (r *resolver) resolveArtist(ctx context.Context, artistID string) (resoluti
if err != nil {
return resolution{}, err
}
upload, ok := resolveLocalFile(ar.UploadedImagePath(), "upload")
if ok {
return upload, nil
chain := chainState{trace: traceFrom(ctx)}
upload, uploadOK := resolveLocalFile(ar.UploadedImagePath(), "upload")
if res, ok := chain.try("upload", upload, uploadOK); ok {
return res, nil
}
if upload.localError {
// The upload outranks every other source; falling through would persist a lower-priority
@ -166,12 +249,7 @@ func (r *resolver) resolveArtist(ctx context.Context, artistID string) (resoluti
}
// Only consider albums where the artist is the sole album artist.
als, err := r.ds.Album(ctx).GetAll(model.QueryOptions{
Filters: squirrel.And{
squirrel.Eq{"album_artist_id": artistID},
squirrel.Eq{"json_array_length(participants, '$.albumartist')": 1},
},
})
als, err := r.ds.Album(ctx).GetAll(model.QueryOptions{Filters: persistence.SoleAlbumArtistFilter(artistID)})
if err != nil {
return resolution{}, err
}
@ -191,32 +269,43 @@ func (r *resolver) resolveArtist(ctx context.Context, artistID string) (resoluti
}
}
var chain chainState
for pattern := range strings.SplitSeq(strings.ToLower(conf.Server.ArtistArtPriority), ",") {
pattern = strings.TrimSpace(pattern)
if pattern == "" {
continue
}
switch {
case pattern == "external":
case pattern == externalCandidate:
if rd, name, isErr := r.fetchExternalArtist(ctx, *ar); rd != nil {
return resolution{reader: rd, source: "external:" + name}, nil
return resolution{reader: rd, source: ExternalPrefix + name}, nil
} else if isErr {
chain.extErr = true
}
case pattern == "image-folder":
if res, ok := chain.try(resolveArtistImageFolder(ar)); ok {
res, ok := resolveArtistImageFolder(ar)
if res, ok = chain.try(pattern, res, ok); ok {
return res, nil
}
case strings.HasPrefix(pattern, "album/"):
if lib.FS == nil {
chain.record(pattern, OutcomeSkipped, "artist has no albums")
continue
}
if res, ok := chain.try(resolveFolderFile(ctx, lib, imgFiles, strings.TrimPrefix(pattern, "album/"))); ok {
res, ok := resolveFolderFile(ctx, lib, imgFiles, strings.TrimPrefix(pattern, "album/"))
if res, ok = chain.try(pattern, res, ok); ok {
return res, nil
}
default:
if lib.FS == nil || artistFolder == "" {
if lib.FS == nil {
chain.record(pattern, OutcomeSkipped, "artist has no albums")
continue
}
if res, ok := chain.try(resolveArtistFolderPattern(ctx, lib, artistFolder, pattern)); ok {
if artistFolder == "" {
chain.record(pattern, OutcomeSkipped, "no artist folder")
continue
}
res, ok := resolveArtistFolderPattern(ctx, lib, artistFolder, pattern)
if res, ok = chain.try(pattern, res, ok); ok {
return res, nil
}
}
@ -224,6 +313,9 @@ func (r *resolver) resolveArtist(ctx context.Context, artistID string) (resoluti
return chain.exhausted(), nil
}
// PlaylistGridSamples is how many albums resolvePlaylist samples to build the generated grid.
const PlaylistGridSamples = 4
// resolvePlaylist tries the uploaded image, the sidecar and ExternalImageURL, then a generated grid.
func (r *resolver) resolvePlaylist(ctx context.Context, playlistID string) (resolution, error) {
pl, err := r.ds.Playlist(ctx).Get(playlistID)
@ -269,7 +361,8 @@ func (r *resolver) resolvePlaylist(ctx context.Context, playlistID string) (reso
}
}
albumIDs, err := r.ds.Playlist(ctx).Tracks(pl.ID, false).GetAlbumIDs(model.QueryOptions{Max: 4, Sort: "random()"})
albumIDs, err := r.ds.Playlist(ctx).Tracks(pl.ID, false).
GetAlbumIDs(model.QueryOptions{Max: PlaylistGridSamples, Sort: "random()"})
if err != nil {
return resolution{}, err
}
@ -295,7 +388,7 @@ func (r *resolver) resolvePlaylist(ctx context.Context, playlistID string) (reso
if decErr == nil {
tiles = append(tiles, tile)
}
if len(tiles) == 4 {
if len(tiles) == PlaylistGridSamples {
break
}
}
@ -337,15 +430,35 @@ func (r *resolver) resolveMediaFile(ctx context.Context, id string) (resolution,
if err != nil {
return resolution{}, err
}
if !conf.Server.EnableMediaFileCoverArt || !mf.HasCoverArt {
chain := chainState{trace: traceFrom(ctx)}
switch {
case !conf.Server.EnableMediaFileCoverArt:
chain.record("embedded", OutcomeSkipped, "EnableMediaFileCoverArt is off")
return resolution{}, nil
case !mf.HasCoverArt:
chain.record("embedded", OutcomeMiss, "the track has no embedded cover art")
return resolution{}, nil
}
lib, err := loadLibraryView(ctx, r.ds, mf.LibraryID)
if err != nil {
return resolution{}, err
}
res, _ := resolveEmbedded(ctx, lib, r.ffmpeg, mf.Path)
return res, nil
res, ok := resolveEmbedded(ctx, lib, r.ffmpeg, mf.Path)
if res, ok = chain.try("embedded", res, ok); ok {
return res, nil
}
return chain.exhausted(), nil
}
// resolveDisc walks conf.Server.DiscArtPriority. Disc artwork keeps no state row and is never
// queued: the serving path reads it through on every request, so this only ever explains.
func (r *resolver) resolveDisc(ctx context.Context, id string) (resolution, error) {
dr, err := newDiscArtworkReader(ctx, r.ds, model.ArtworkID{Kind: model.KindDiscArtwork, ID: id})
if err != nil {
return resolution{}, err
}
chain := chainState{trace: traceFrom(ctx)}
return dr.selectImage(ctx, r.ffmpeg, conf.Server.DiscArtPriority, &chain)
}
// resolveExternalStep runs a single external sourceFunc through the named gate. extErr excludes
@ -353,7 +466,7 @@ func (r *resolver) resolveMediaFile(ctx context.Context, id string) (resolution,
func resolveExternalStep(gate gateFunc, name string, sf sourceFunc) (res resolution, ok bool, extErr bool) {
r, path, err := gate(name, sf)
if r != nil {
return resolution{reader: r, source: "external", sourcePath: path}, true, false
return resolution{reader: r, source: externalCandidate, sourcePath: path}, true, false
}
return resolution{}, false, err != nil && !errors.Is(err, model.ErrNotFound)
}
@ -394,14 +507,36 @@ func resolveEmbedded(ctx context.Context, lib libraryView, ffm ffmpeg.FFmpeg, em
return resolution{localError: unreadable}, false
}
func resolveFolderFile(ctx context.Context, lib libraryView, imgFiles []string, pattern string) (resolution, bool) {
r, path, err := fromExternalFile(ctx, lib.FS, imgFiles, pattern)()
// resolveFolderSource turns a source that yields a library-relative image path into a folder
// resolution, keeping an existing-but-unopenable file distinct from an absent one.
func resolveFolderSource(lib libraryView, sf sourceFunc) (resolution, bool) {
r, path, err := sf()
if r == nil {
return resolution{localError: errors.Is(err, errSourceUnreadable)}, false
}
return resolution{reader: r, source: "folder", sourcePath: lib.Abs(path), refMtime: mtimeViaFS(lib.FS, path)}, true
}
func resolveFolderFile(ctx context.Context, lib libraryView, imgFiles []string, pattern string) (resolution, bool) {
return resolveFolderSource(lib, fromExternalFile(ctx, lib.FS, imgFiles, pattern))
}
// IsArtistImageFile reports whether a file name matches any file-glob token of ArtistArtPriority.
// Basename-only on purpose: the chain climbs parent folders, so a token's prefix is not fixed.
func IsArtistImageFile(name string) bool {
name = strings.ToLower(name)
for pattern := range strings.SplitSeq(strings.ToLower(conf.Server.ArtistArtPriority), ",") {
pattern = strings.TrimSpace(pattern)
if pattern == "" || pattern == externalCandidate || pattern == "image-folder" {
continue
}
if ok, _ := path.Match(path.Base(pattern), name); ok {
return true
}
}
return false
}
func resolveArtistImageFolder(ar *model.Artist) (resolution, bool) {
folder := conf.Server.ArtistImageFolder
if folder == "" {

View File

@ -21,6 +21,31 @@ import (
. "github.com/onsi/gomega"
)
var _ = Describe("IsArtistImageFile", func() {
BeforeEach(func() {
DeferCleanup(configtest.SetupConfig())
})
It("matches bare and album/-prefixed glob tokens, case-insensitively", func() {
conf.Server.ArtistArtPriority = "artist.*, album/artistfolder.*, external"
Expect(IsArtistImageFile("Artist.jpg")).To(BeTrue())
Expect(IsArtistImageFile("artistfolder.png")).To(BeTrue())
Expect(IsArtistImageFile("cover.jpg")).To(BeFalse())
})
It("matches a directory-bearing glob by its basename", func() {
conf.Server.ArtistArtPriority = "images/artist.*, external"
Expect(IsArtistImageFile("artist.jpg")).To(BeTrue())
Expect(IsArtistImageFile("cover.jpg")).To(BeFalse())
})
It("does not treat non-file tokens as globs", func() {
conf.Server.ArtistArtPriority = "image-folder, external"
Expect(IsArtistImageFile("image-folder")).To(BeFalse())
Expect(IsArtistImageFile("external")).To(BeFalse())
})
})
var _ = Describe("resolveItem", func() {
var (
ctx context.Context
@ -394,6 +419,28 @@ var _ = Describe("resolveItem", func() {
Entry("4 albums -> full grid", []string{"t1", "t2", "t3", "t4"}, tileSize-1),
)
// The grid samples album art through the full album chain, so a playlist reaches the
// network even with the m3u fetch off.
It("calls the album image agents for its grid tiles when m3u art is disabled", func() {
conf.Server.EnableM3UExternalAlbumArt = false
conf.Server.CoverArtPriority = "external"
folderRepo.result = nil
plRepo := tests.CreateMockPlaylistRepo()
plRepo.SetData(model.Playlists{{ID: "plgrid", Name: "Playlist"}})
plRepo.TracksRepo = &tests.MockPlaylistTrackRepo{AlbumIDs: []string{"t1", "t2"}}
ds.MockedPlaylist = plRepo
imageAgents(&fakeImageAgent{name: "failAgent", err: errors.New("boom")})
var gatedNames []string
gate := func(name string, f func() (io.ReadCloser, string, error)) (io.ReadCloser, string, error) {
gatedNames = append(gatedNames, name)
return f()
}
_, err := newResolver(ds, ag, ffm, gate).resolve(ctx, model.ArtworkQueueItem{ItemKind: "pl", ItemID: "plgrid"})
Expect(err).ToNot(HaveOccurred())
Expect(gatedNames).To(Equal([]string{"failAgent", "failAgent"}), "one lookup per sampled album")
})
It("resolves the uploaded image before the generated grid", func() {
tmpDir := GinkgoT().TempDir()
conf.Server.DataFolder = conf.NewDir(tmpDir)
@ -630,3 +677,110 @@ var _ = Describe("decodeTile", func() {
Expect(err).To(HaveOccurred())
})
})
var _ = Describe("Explainable", func() {
It("is true for the kinds the resolver walks", func() {
Expect(Explainable(model.KindArtistArtwork)).To(BeTrue())
Expect(Explainable(model.KindAlbumArtwork)).To(BeTrue())
Expect(Explainable(model.KindDiscArtwork)).To(BeTrue())
Expect(Explainable(model.KindMediaFileArtwork)).To(BeTrue())
})
It("is false for the kinds resolved from a fixed internal order", func() {
Expect(Explainable(model.KindPlaylistArtwork)).To(BeFalse())
Expect(Explainable(model.KindRadioArtwork)).To(BeFalse())
})
})
var _ = Describe("MayFetchExternal", func() {
BeforeEach(func() {
DeferCleanup(configtest.SetupConfig())
conf.Server.CoverArtPriority = "cover.*, embedded"
conf.Server.ArtistArtPriority = "artist.*"
conf.Server.EnableM3UExternalAlbumArt = false
})
It("is true for the kinds whose chain includes the external candidate", func() {
conf.Server.CoverArtPriority = "cover.*, external"
conf.Server.ArtistArtPriority = "artist.*, external"
Expect(MayFetchExternal(model.KindAlbumArtwork)).To(BeTrue())
Expect(MayFetchExternal(model.KindArtistArtwork)).To(BeTrue())
})
It("is false for a chain with no external candidate", func() {
Expect(MayFetchExternal(model.KindAlbumArtwork)).To(BeFalse())
Expect(MayFetchExternal(model.KindArtistArtwork)).To(BeFalse())
})
It("is true for playlists when the m3u image fetch is enabled", func() {
conf.Server.EnableM3UExternalAlbumArt = true
Expect(MayFetchExternal(model.KindPlaylistArtwork)).To(BeTrue())
})
It("is true for playlists whose grid tiles resolve through an external album chain", func() {
conf.Server.CoverArtPriority = "cover.*, external"
Expect(MayFetchExternal(model.KindPlaylistArtwork)).To(BeTrue())
})
It("is false for playlists with both paths off", func() {
Expect(MayFetchExternal(model.KindPlaylistArtwork)).To(BeFalse())
})
It("is false for the kinds that only read local files", func() {
conf.Server.CoverArtPriority = "external"
conf.Server.ArtistArtPriority = "external"
conf.Server.EnableM3UExternalAlbumArt = true
Expect(MayFetchExternal(model.KindRadioArtwork)).To(BeFalse())
Expect(MayFetchExternal(model.KindMediaFileArtwork)).To(BeFalse())
})
})
var _ = Describe("ExternalLookupsPerItem", func() {
count := ImageAgentCount{Artist: 3, Album: 2}
BeforeEach(func() {
DeferCleanup(configtest.SetupConfig())
conf.Server.CoverArtPriority = "cover.*, external"
conf.Server.ArtistArtPriority = "artist.*, external"
conf.Server.EnableM3UExternalAlbumArt = false
})
It("bills one call per agent, since the walk only stops early on a hit", func() {
Expect(ExternalLookupsPerItem(model.KindArtistArtwork, count)).To(Equal(int64(3)))
Expect(ExternalLookupsPerItem(model.KindAlbumArtwork, count)).To(Equal(int64(2)))
})
It("bills a playlist for every album its grid samples", func() {
Expect(ExternalLookupsPerItem(model.KindPlaylistArtwork, count)).
To(Equal(int64(PlaylistGridSamples) * 2))
})
It("adds the m3u image fetch on top of the grid", func() {
conf.Server.EnableM3UExternalAlbumArt = true
Expect(ExternalLookupsPerItem(model.KindPlaylistArtwork, count)).
To(Equal(int64(PlaylistGridSamples)*2 + 1))
})
It("bills only the m3u fetch when the album chain stays local", func() {
conf.Server.CoverArtPriority = "cover.*"
conf.Server.EnableM3UExternalAlbumArt = true
Expect(ExternalLookupsPerItem(model.KindPlaylistArtwork, count)).To(Equal(int64(1)))
})
It("still bills a call when no agent is visible, which plugins never are offline", func() {
none := ImageAgentCount{}
Expect(ExternalLookupsPerItem(model.KindArtistArtwork, none)).To(Equal(int64(1)))
Expect(ExternalLookupsPerItem(model.KindAlbumArtwork, none)).To(Equal(int64(1)))
Expect(ExternalLookupsPerItem(model.KindPlaylistArtwork, none)).
To(Equal(int64(PlaylistGridSamples)))
})
It("is zero whenever the kind reaches no agent at all", func() {
conf.Server.CoverArtPriority = "cover.*"
conf.Server.ArtistArtPriority = "artist.*"
Expect(ExternalLookupsPerItem(model.KindArtistArtwork, count)).To(BeZero())
Expect(ExternalLookupsPerItem(model.KindAlbumArtwork, count)).To(BeZero())
Expect(ExternalLookupsPerItem(model.KindPlaylistArtwork, count)).To(BeZero())
Expect(ExternalLookupsPerItem(model.KindRadioArtwork, count)).To(BeZero())
})
})

View File

@ -27,23 +27,6 @@ import (
// to open it is not evidence the entity has no artwork, so callers must not settle on absent.
var errSourceUnreadable = errors.New("artwork source unreadable")
func selectImageReader(ctx context.Context, artID model.ArtworkID, extractFuncs ...sourceFunc) (io.ReadCloser, string, error) {
for _, f := range extractFuncs {
if ctx.Err() != nil {
return nil, "", ctx.Err()
}
start := time.Now()
r, path, err := f()
if r != nil {
msg := fmt.Sprintf("Artwork: Found %s artwork", artID.Kind)
log.Debug(ctx, msg, "artID", artID, "path", path, "source", f, "elapsed", time.Since(start))
return r, path, nil
}
log.Trace(ctx, "Artwork: Failed trying to extract artwork", "artID", artID, "source", f, "elapsed", time.Since(start), err)
}
return nil, "", fmt.Errorf("could not get `%s` cover art for %s: %w", artID.Kind, artID, ErrUnavailable)
}
type sourceFunc func() (r io.ReadCloser, path string, err error)
func (f sourceFunc) String() string {

99
core/artwork/trace.go Normal file
View File

@ -0,0 +1,99 @@
package artwork
import (
"context"
"errors"
"io"
"slices"
"sync"
)
// Outcome is what the priority chain observed for one candidate; the CLI renders and branches on these.
type Outcome string
const (
OutcomeHit Outcome = "hit"
OutcomeMiss Outcome = "miss"
OutcomeUnreadable Outcome = "unreadable"
OutcomeSkipped Outcome = "skipped"
OutcomeWouldTry Outcome = "would-try"
OutcomeError Outcome = "error"
)
const (
// externalCandidate labels the external tier itself, for the cases that never reach an agent.
externalCandidate = "external"
// ExternalPrefix qualifies a candidate or a stored source with the agent that produced it.
ExternalPrefix = externalCandidate + ":"
)
// TraceStep is one candidate the priority chain considered.
type TraceStep struct {
Candidate string
Outcome Outcome
Detail string
}
// ChainTrace collects the walk of a single resolution. The artwork worker never attaches
// one; only the CLI does, so resolution stays allocation-free in the hot path.
type ChainTrace struct {
mu sync.Mutex
steps []TraceStep
}
func (t *ChainTrace) add(step TraceStep) {
if t == nil {
return
}
t.mu.Lock()
defer t.mu.Unlock()
t.steps = append(t.steps, step)
}
func (t *ChainTrace) Steps() []TraceStep {
if t == nil {
return nil
}
t.mu.Lock()
defer t.mu.Unlock()
return slices.Clone(t.steps)
}
type traceCtxKey struct{}
func withTrace(ctx context.Context, t *ChainTrace) context.Context {
return context.WithValue(ctx, traceCtxKey{}, t)
}
func traceFrom(ctx context.Context) *ChainTrace {
t, _ := ctx.Value(traceCtxKey{}).(*ChainTrace)
return t
}
var errOfflineSkipped = errors.New("artwork: external lookup skipped (offline)")
// tracingGate records each external agent's outcome without changing what the gate returns.
func tracingGate(t *ChainTrace, inner gateFunc) gateFunc {
return func(name string, f func() (io.ReadCloser, string, error)) (io.ReadCloser, string, error) {
r, path, err := inner(name, f)
candidate := ExternalPrefix + name
switch {
case r != nil:
t.add(TraceStep{Candidate: candidate, Outcome: OutcomeHit, Detail: path})
case isTransientExternal(err):
t.add(TraceStep{Candidate: candidate, Outcome: OutcomeError, Detail: err.Error()})
default:
t.add(TraceStep{Candidate: candidate, Outcome: OutcomeMiss})
}
return r, path, err
}
}
// offlineGate reports which agents would be asked without asking them, so a diagnostic
// command cannot add load to a provider that is already rate-limiting us.
func offlineGate(t *ChainTrace) gateFunc {
return func(name string, _ func() (io.ReadCloser, string, error)) (io.ReadCloser, string, error) {
t.add(TraceStep{Candidate: ExternalPrefix + name, Outcome: OutcomeWouldTry})
return nil, "", errOfflineSkipped
}
}

628
core/artwork/trace_test.go Normal file
View File

@ -0,0 +1,628 @@
package artwork
import (
"context"
"errors"
"io"
"os"
"path/filepath"
"runtime"
"strings"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/conf/configtest"
"github.com/navidrome/navidrome/consts"
"github.com/navidrome/navidrome/core/agents"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/tests"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
var _ = Describe("trace vocabulary", func() {
// The CLI renders these verbatim and branches on them; a value change is a change to
// what `artwork explain` tells an operator, so it must be made deliberately.
It("pins the wire values the CLI reads", func() {
Expect([]Outcome{
OutcomeHit, OutcomeMiss, OutcomeUnreadable, OutcomeSkipped, OutcomeWouldTry, OutcomeError,
}).To(Equal([]Outcome{"hit", "miss", "unreadable", "skipped", "would-try", "error"}))
Expect(externalCandidate).To(Equal("external"))
Expect(ExternalPrefix).To(Equal("external:"))
})
})
var _ = Describe("chainTrace", func() {
It("returns nil when no trace is attached", func() {
Expect(traceFrom(context.Background())).To(BeNil())
})
It("collects steps in order", func() {
t := &ChainTrace{}
ctx := withTrace(context.Background(), t)
traceFrom(ctx).add(TraceStep{Candidate: "cover.*", Outcome: OutcomeMiss})
traceFrom(ctx).add(TraceStep{Candidate: "embedded", Outcome: OutcomeHit, Detail: "/music/a.flac"})
Expect(t.Steps()).To(Equal([]TraceStep{
{Candidate: "cover.*", Outcome: OutcomeMiss},
{Candidate: "embedded", Outcome: OutcomeHit, Detail: "/music/a.flac"},
}))
s := t.Steps()
s[0].Candidate = "mutated"
Expect(t.Steps()[0].Candidate).To(Equal("cover.*"))
})
It("does not panic when the trace is nil", func() {
var t *ChainTrace
Expect(func() { t.add(TraceStep{Candidate: "cover.*", Outcome: OutcomeMiss}) }).ToNot(Panic())
Expect(t.Steps()).To(BeEmpty(), "a nil trace collects nothing, so reading it must be as safe as writing it")
})
It("is safe to use concurrently", func() {
t := &ChainTrace{}
done := make(chan struct{})
for range 10 {
go func() {
defer GinkgoRecover()
t.add(TraceStep{Candidate: "x", Outcome: OutcomeMiss})
done <- struct{}{}
}()
}
for range 10 {
<-done
}
Expect(t.Steps()).To(HaveLen(10))
})
})
var _ = Describe("chainState tracing", func() {
It("records a miss when the candidate was absent", func() {
t := &ChainTrace{}
c := chainState{trace: t}
_, ok := c.try("cover.*", resolution{}, false)
Expect(ok).To(BeFalse())
Expect(t.Steps()).To(Equal([]TraceStep{{Candidate: "cover.*", Outcome: OutcomeMiss}}))
})
It("records unreadable when the candidate existed but could not be read", func() {
t := &ChainTrace{}
c := chainState{trace: t}
_, ok := c.try("cover.*", resolution{localError: true}, false)
Expect(ok).To(BeFalse())
Expect(t.Steps()).To(HaveLen(1))
Expect(t.Steps()[0].Outcome).To(Equal(OutcomeUnreadable),
"a candidate that existed and failed to decode must be distinguishable from one that was absent")
})
It("records a hit with the backing path", func() {
t := &ChainTrace{}
c := chainState{trace: t}
res, ok := c.try("embedded", resolution{reader: nil, source: "embedded", sourcePath: "/music/a.flac"}, true)
Expect(ok).To(BeTrue())
Expect(res.source).To(Equal("embedded"))
Expect(t.Steps()).To(Equal([]TraceStep{
{Candidate: "embedded", Outcome: OutcomeHit, Detail: "/music/a.flac"},
}))
})
})
var _ = Describe("external gate tracing", func() {
hit := func() (io.ReadCloser, string, error) {
return io.NopCloser(strings.NewReader("x")), "http://img", nil
}
miss := func() (io.ReadCloser, string, error) { return nil, "", agents.ErrNotFound }
boom := func() (io.ReadCloser, string, error) { return nil, "", errors.New("returned status 429") }
It("records a hit with the image path", func() {
t := &ChainTrace{}
g := tracingGate(t, passthroughGate)
r, _, err := g("deezer", hit)
Expect(err).ToNot(HaveOccurred())
Expect(r).ToNot(BeNil())
Expect(t.Steps()).To(Equal([]TraceStep{
{Candidate: "external:deezer", Outcome: OutcomeHit, Detail: "http://img"},
}))
})
It("records a miss for a not-found", func() {
t := &ChainTrace{}
_, _, _ = tracingGate(t, passthroughGate)("deezer", miss)
Expect(t.Steps()[0].Outcome).To(Equal(OutcomeMiss))
})
It("records a miss for a model not-found", func() {
t := &ChainTrace{}
notFound := func() (io.ReadCloser, string, error) { return nil, "", model.ErrNotFound }
_, _, _ = tracingGate(t, passthroughGate)("deezer", notFound)
Expect(t.Steps()[0].Outcome).To(Equal(OutcomeMiss),
"both not-found flavours are definitive answers, not faults")
})
It("records an error with its reason", func() {
t := &ChainTrace{}
_, _, _ = tracingGate(t, passthroughGate)("apple-music", boom)
Expect(t.Steps()[0].Outcome).To(Equal(OutcomeError))
Expect(t.Steps()[0].Detail).To(ContainSubstring("429"))
})
It("never calls the agent in offline mode", func() {
t := &ChainTrace{}
called := false
counting := func() (io.ReadCloser, string, error) {
called = true
return hit()
}
_, _, err := offlineGate(t)("deezer", counting)
Expect(called).To(BeFalse(), "offline mode must not perform external requests")
Expect(err).To(MatchError(errOfflineSkipped))
Expect(t.Steps()).To(Equal([]TraceStep{
{Candidate: "external:deezer", Outcome: OutcomeWouldTry},
}))
})
})
var _ = Describe("resolveAlbum tracing", func() {
var (
ctx context.Context
ds *tests.MockDataStore
albumRepo *tests.MockAlbumRepo
folderRepo *fakeFolderRepo
ffm *tests.MockFFmpeg
ag *agents.Agents
t *ChainTrace
)
BeforeEach(func() {
DeferCleanup(configtest.SetupConfig())
conf.Server.CoverArtPriority = "cover.jpg, embedded"
repoRoot, err := os.Getwd()
Expect(err).ToNot(HaveOccurred())
libRepo := &tests.MockLibraryRepo{}
libRepo.SetData(model.Libraries{{ID: 0, Path: testFileLibPath(repoRoot)}})
albumRepo = tests.CreateMockAlbumRepo()
folderRepo = &fakeFolderRepo{}
ds = &tests.MockDataStore{
MockedAlbum: albumRepo,
MockedFolder: folderRepo,
MockedLibrary: libRepo,
}
ffm = tests.NewMockFFmpeg("")
ag = agents.GetAgents(&tests.MockDataStore{}, nil)
t = &ChainTrace{}
ctx = withTrace(context.Background(), t)
})
It("records a pattern skipped because the album folder holds no images", func() {
albumRepo.SetData(model.Albums{
{ID: "al1", Name: "Album", EmbedArtPath: "tests/fixtures/artist/an-album/test.mp3", FolderIDs: []string{"f1"}},
})
res, err := newResolver(ds, ag, ffm, nil).resolve(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "al1"})
Expect(err).ToNot(HaveOccurred())
Expect(res.reader).ToNot(BeNil())
defer res.reader.Close()
Expect(t.Steps()).To(HaveLen(2), "a configured pattern must appear even when the chain never evaluated it")
Expect(t.Steps()[0]).To(Equal(TraceStep{
Candidate: "cover.jpg", Outcome: OutcomeSkipped, Detail: "no images in album folder",
}))
Expect(t.Steps()[1].Candidate).To(Equal("embedded"))
Expect(t.Steps()[1].Outcome).To(Equal(OutcomeHit))
})
It("records an evaluated pattern that matched nothing as a miss, not a skip", func() {
folderRepo.result = []model.Folder{{
Path: "tests/fixtures/artist/an-album",
ImageFiles: []string{"artist.png"},
}}
albumRepo.SetData(model.Albums{
{ID: "al3", Name: "Album", EmbedArtPath: "tests/fixtures/artist/an-album/test.mp3", FolderIDs: []string{"f1"}},
})
res, err := newResolver(ds, ag, ffm, nil).resolve(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "al3"})
Expect(err).ToNot(HaveOccurred())
Expect(res.reader).ToNot(BeNil())
defer res.reader.Close()
Expect(t.Steps()[0]).To(Equal(TraceStep{Candidate: "cover.jpg", Outcome: OutcomeMiss}),
"the folder was searched and held no cover.jpg, which is not the same as never looking")
})
It("ignores an empty priority token", func() {
conf.Server.CoverArtPriority = "cover.jpg,"
albumRepo.SetData(model.Albums{{ID: "al2", Name: "Album", FolderIDs: []string{"f1"}}})
_, err := newResolver(ds, ag, ffm, nil).resolve(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "al2"})
Expect(err).ToNot(HaveOccurred())
Expect(t.Steps()).To(Equal([]TraceStep{
{Candidate: "cover.jpg", Outcome: OutcomeSkipped, Detail: "no images in album folder"},
}))
})
})
var _ = Describe("resolveArtist tracing", func() {
var (
ctx context.Context
ds *tests.MockDataStore
artistRepo *tests.MockArtistRepo
albumRepo *tests.MockAlbumRepo
folderRepo *fakeFolderRepo
ffm *tests.MockFFmpeg
ag *agents.Agents
t *ChainTrace
repoRoot string
)
uploadPath := func(file string) string {
path := model.UploadedImagePath(consts.EntityArtist, file)
Expect(os.MkdirAll(filepath.Dir(path), 0o755)).To(Succeed())
Expect(os.WriteFile(path, []byte("uploaded artist image"), 0o600)).To(Succeed())
return path
}
BeforeEach(func() {
DeferCleanup(configtest.SetupConfig())
conf.Server.DataFolder = conf.NewDir(GinkgoT().TempDir())
conf.Server.ArtistArtPriority = "album/artist.*"
var err error
repoRoot, err = os.Getwd()
Expect(err).ToNot(HaveOccurred())
libRepo := &tests.MockLibraryRepo{}
libRepo.SetData(model.Libraries{{ID: 0, Path: testFileLibPath(repoRoot)}})
artistRepo = tests.CreateMockArtistRepo()
albumRepo = tests.CreateMockAlbumRepo()
folderRepo = &fakeFolderRepo{}
ds = &tests.MockDataStore{
MockedArtist: artistRepo,
MockedAlbum: albumRepo,
MockedFolder: folderRepo,
MockedLibrary: libRepo,
}
ffm = tests.NewMockFFmpeg("")
ag = agents.GetAgents(&tests.MockDataStore{}, nil)
t = &ChainTrace{}
ctx = withTrace(context.Background(), t)
})
It("records the upload short-circuit as a hit", func() {
path := uploadPath("ar1_test.jpg")
artistRepo.SetData(model.Artists{{ID: "ar1", Name: "Artist", UploadedImage: "ar1_test.jpg"}})
res, err := newResolver(ds, ag, ffm, nil).resolve(ctx, model.ArtworkQueueItem{ItemKind: "ar", ItemID: "ar1"})
Expect(err).ToNot(HaveOccurred())
Expect(res.reader).ToNot(BeNil())
defer res.reader.Close()
Expect(t.Steps()).To(Equal([]TraceStep{{Candidate: "upload", Outcome: OutcomeHit, Detail: path}}))
})
It("records an upload miss before walking the chain", func() {
artistRepo.SetData(model.Artists{{ID: "ar2", Name: "Artist"}})
_, err := newResolver(ds, ag, ffm, nil).resolve(ctx, model.ArtworkQueueItem{ItemKind: "ar", ItemID: "ar2"})
Expect(err).ToNot(HaveOccurred())
Expect(t.Steps()[0]).To(Equal(TraceStep{Candidate: "upload", Outcome: OutcomeMiss}))
})
It("labels each step with the configured priority token", func() {
folderRepo.result = []model.Folder{{
LibraryPath: testFileLibPath(repoRoot),
Path: "tests/fixtures/artist/an-album",
ImageFiles: []string{"artist.png"},
}}
artistRepo.SetData(model.Artists{{ID: "ar4", Name: "Artist"}})
albumRepo.All = model.Albums{{ID: "al9", Name: "Album", LibraryID: 0, FolderIDs: []string{"f1"}}}
res, err := newResolver(ds, ag, ffm, nil).resolve(ctx, model.ArtworkQueueItem{ItemKind: "ar", ItemID: "ar4"})
Expect(err).ToNot(HaveOccurred())
Expect(res.reader).ToNot(BeNil())
defer res.reader.Close()
Expect(t.Steps()).To(HaveLen(2))
Expect(t.Steps()[1].Candidate).To(Equal("album/artist.*"),
"the step must be labelled with the priority token, not the pattern it was rewritten into")
Expect(t.Steps()[1].Outcome).To(Equal(OutcomeHit))
Expect(filepath.ToSlash(t.Steps()[1].Detail)).To(HaveSuffix("tests/fixtures/artist/an-album/artist.png"))
})
It("records a configured pattern that could not be evaluated", func() {
artistRepo.SetData(model.Artists{{ID: "ar5", Name: "Artist"}})
_, err := newResolver(ds, ag, ffm, nil).resolve(ctx, model.ArtworkQueueItem{ItemKind: "ar", ItemID: "ar5"})
Expect(err).ToNot(HaveOccurred())
Expect(t.Steps()).To(Equal([]TraceStep{
{Candidate: "upload", Outcome: OutcomeMiss},
{Candidate: "album/artist.*", Outcome: OutcomeSkipped, Detail: "artist has no albums"},
}), "a configured pattern that was never evaluated must still appear, and say why")
})
It("records why an artist folder pattern was skipped", func() {
conf.Server.ArtistArtPriority = "artist.*"
artistRepo.SetData(model.Artists{{ID: "ar6", Name: "Artist"}})
albumRepo.All = model.Albums{{ID: "al10", Name: "Album", LibraryID: 0, FolderIDs: []string{"f1"}}}
_, err := newResolver(ds, ag, ffm, nil).resolve(ctx, model.ArtworkQueueItem{ItemKind: "ar", ItemID: "ar6"})
Expect(err).ToNot(HaveOccurred())
Expect(t.Steps()).To(Equal([]TraceStep{
{Candidate: "upload", Outcome: OutcomeMiss},
{Candidate: "artist.*", Outcome: OutcomeSkipped, Detail: "no artist folder"},
}))
})
It("records an upload that exists but cannot be read as unreadable", func() {
if runtime.GOOS == "windows" {
Skip("chmod does not restrict read access on Windows")
}
path := uploadPath("ar3_test.jpg")
Expect(os.Chmod(path, 0o000)).To(Succeed())
DeferCleanup(func() { _ = os.Chmod(path, 0o600) })
artistRepo.SetData(model.Artists{{ID: "ar3", Name: "Artist", UploadedImage: "ar3_test.jpg"}})
_, err := newResolver(ds, ag, ffm, nil).resolve(ctx, model.ArtworkQueueItem{ItemKind: "ar", ItemID: "ar3"})
Expect(err).ToNot(HaveOccurred())
Expect(t.Steps()).To(HaveLen(1))
Expect(t.Steps()[0].Outcome).To(Equal(OutcomeUnreadable),
"an upload that exists and will not open must not look like an absent upload")
})
})
var _ = Describe("NewTracingResolver", func() {
var (
ds *tests.MockDataStore
albumRepo *tests.MockAlbumRepo
artistRepo *tests.MockArtistRepo
artworkRepo *tests.MockArtworkRepo
queueRepo *tests.MockArtworkQueueRepo
ffm *tests.MockFFmpeg
t *ChainTrace
)
BeforeEach(func() {
DeferCleanup(configtest.SetupConfig())
conf.Server.DataFolder = conf.NewDir(GinkgoT().TempDir())
conf.Server.CoverArtPriority = "external, embedded"
conf.Server.ArtistArtPriority = "external"
repoRoot, err := os.Getwd()
Expect(err).ToNot(HaveOccurred())
libRepo := &tests.MockLibraryRepo{}
libRepo.SetData(model.Libraries{{ID: 0, Path: testFileLibPath(repoRoot)}})
albumRepo = tests.CreateMockAlbumRepo()
artistRepo = tests.CreateMockArtistRepo()
artworkRepo = tests.CreateMockArtworkRepo()
queueRepo = tests.CreateMockArtworkQueueRepo()
ds = &tests.MockDataStore{
MockedAlbum: albumRepo,
MockedArtist: artistRepo,
MockedFolder: &fakeFolderRepo{},
MockedLibrary: libRepo,
MockedArtwork: artworkRepo,
MockedArtworkQueue: queueRepo,
}
ffm = tests.NewMockFFmpeg("")
t = &ChainTrace{}
})
Context("offline", func() {
var fake *fakeImageAgent
BeforeEach(func() {
fake = &fakeImageAgent{name: "offline-probe"}
albumRepo.SetData(model.Albums{{
ID: "al1", Name: "Album", EmbedArtPath: "tests/fixtures/artist/an-album/test.mp3", FolderIDs: []string{"f1"},
}})
artistRepo.SetData(model.Artists{{ID: "ar1", Name: "Artist"}})
})
It("reports the external tier without asking any agent", func() {
source, err := NewTracingResolver(ds, imageAgents(fake), ffm, t, false).Resolve(context.Background(), model.KindAlbumArtwork, "al1")
Expect(err).ToNot(HaveOccurred())
Expect(source).To(Equal("embedded"))
Expect(fake.albumCalls).To(BeZero(), "offline mode must not add load to an external provider")
Expect(t.Steps()).To(ContainElement(TraceStep{Candidate: "external:offline-probe", Outcome: OutcomeWouldTry}))
})
It("records the local chain steps too", func() {
_, err := NewTracingResolver(ds, imageAgents(fake), ffm, t, false).Resolve(context.Background(), model.KindAlbumArtwork, "al1")
Expect(err).ToNot(HaveOccurred())
last := t.Steps()[len(t.Steps())-1]
Expect(last.Candidate).To(Equal("embedded"), "the local chain must be traced, not just the external gate")
Expect(last.Outcome).To(Equal(OutcomeHit))
})
It("never persists artwork state", func() {
_, err := NewTracingResolver(ds, imageAgents(fake), ffm, t, false).Resolve(context.Background(), model.KindAlbumArtwork, "al1")
Expect(err).ToNot(HaveOccurred())
Expect(artworkRepo.ItemData).To(BeEmpty(),
"an offline resolution carries extError, which must never be recorded as a real provider failure")
Expect(queueRepo.Data).To(BeEmpty())
})
It("resolves an artist without persisting anything", func() {
source, err := NewTracingResolver(ds, imageAgents(fake), ffm, t, false).Resolve(context.Background(), model.KindArtistArtwork, "ar1")
Expect(err).ToNot(HaveOccurred())
Expect(source).To(BeEmpty())
Expect(fake.artistCalls).To(BeZero())
Expect(t.Steps()).To(ContainElement(TraceStep{Candidate: "external:offline-probe", Outcome: OutcomeWouldTry}))
Expect(artworkRepo.ItemData).To(BeEmpty())
Expect(queueRepo.Data).To(BeEmpty())
})
It("closes the reader it does not hand back", func() {
conf.Server.CoverArtPriority = "embedded"
ffm = tests.NewMockFFmpeg("fake image bytes")
albumRepo.SetData(model.Albums{{
ID: "al2", Name: "Album", EmbedArtPath: "tests/fixtures/artist/an-album/no-such-file.mp3", FolderIDs: []string{"f1"},
}})
source, err := NewTracingResolver(ds, imageAgents(fake), ffm, t, false).Resolve(context.Background(), model.KindAlbumArtwork, "al2")
Expect(err).ToNot(HaveOccurred())
Expect(source).To(Equal("embedded"))
Expect(ffm.IsClosed()).To(BeTrue(), "nothing downstream closes it, so a leak is one file handle per invocation")
})
It("propagates a lookup error", func() {
_, err := NewTracingResolver(ds, imageAgents(fake), ffm, t, false).Resolve(context.Background(), model.KindAlbumArtwork, "nope")
Expect(err).To(MatchError(model.ErrNotFound))
})
})
It("asks the agents when live is true", func() {
fake := &fakeImageAgent{name: "live-probe", err: agents.ErrNotFound}
albumRepo.SetData(model.Albums{{
ID: "al1", Name: "Album", EmbedArtPath: "tests/fixtures/artist/an-album/test.mp3", FolderIDs: []string{"f1"},
}})
_, err := NewTracingResolver(ds, imageAgents(fake), ffm, t, true).Resolve(context.Background(), model.KindAlbumArtwork, "al1")
Expect(err).ToNot(HaveOccurred())
Expect(fake.albumCalls).To(Equal(1))
Expect(t.Steps()).To(ContainElement(TraceStep{Candidate: "external:live-probe", Outcome: OutcomeMiss}))
})
})
var _ = Describe("resolveDisc tracing", func() {
var (
ctx context.Context
ds *tests.MockDataStore
albumRepo *tests.MockAlbumRepo
folderRepo *fakeFolderRepo
ffm *tests.MockFFmpeg
t *ChainTrace
)
BeforeEach(func() {
DeferCleanup(configtest.SetupConfig())
repoRoot, err := os.Getwd()
Expect(err).ToNot(HaveOccurred())
libRepo := &tests.MockLibraryRepo{}
libRepo.SetData(model.Libraries{{ID: 0, Path: testFileLibPath(repoRoot)}})
albumRepo = tests.CreateMockAlbumRepo()
albumRepo.SetData(model.Albums{{ID: "al1", Name: "Album", FolderIDs: []string{"f1"}}})
folderRepo = &fakeFolderRepo{}
mfRepo := tests.CreateMockMediaFileRepo()
mfRepo.SetData(model.MediaFiles{{ID: "mf1", AlbumID: "al1", DiscNumber: 2, Path: "tests/fixtures/artist/an-album/test.mp3"}})
ds = &tests.MockDataStore{
MockedAlbum: albumRepo,
MockedMediaFile: mfRepo,
MockedFolder: folderRepo,
MockedLibrary: libRepo,
}
ffm = tests.NewMockFFmpeg("")
t = &ChainTrace{}
ctx = withTrace(context.Background(), t)
})
It("accounts for every configured entry, including the ones that map to no source", func() {
conf.Server.DiscArtPriority = "external, discsubtitle, cover.jpg"
res, err := newResolver(ds, nil, ffm, nil).resolveDisc(ctx, model.DiscArtworkID("al1", 2))
Expect(err).ToNot(HaveOccurred())
Expect(res.reader).To(BeNil())
Expect(t.Steps()).To(Equal([]TraceStep{
{Candidate: "external", Outcome: OutcomeSkipped, Detail: "external sources are not supported for disc artwork"},
{Candidate: "discsubtitle", Outcome: OutcomeSkipped, Detail: "disc has no subtitle"},
{Candidate: "cover.jpg", Outcome: OutcomeSkipped, Detail: "no images in album folder"},
}))
})
It("records the entry that won and stops there", func() {
conf.Server.DiscArtPriority = "disc*.*, cover.jpg, embedded"
folderRepo.result = []model.Folder{{
Path: "tests/fixtures/artist/an-album",
ImageFiles: []string{"cover.jpg"},
}}
res, err := newResolver(ds, nil, ffm, nil).resolveDisc(ctx, model.DiscArtworkID("al1", 2))
Expect(err).ToNot(HaveOccurred())
Expect(res.reader).ToNot(BeNil())
defer res.reader.Close()
Expect(res.source).To(Equal("folder"))
Expect(t.Steps()).To(HaveLen(2), "the walk must stop at the winner, and record nothing below it")
Expect(t.Steps()[0]).To(Equal(TraceStep{Candidate: "disc*.*", Outcome: OutcomeMiss}))
Expect(t.Steps()[1].Candidate).To(Equal("cover.jpg"))
Expect(t.Steps()[1].Outcome).To(Equal(OutcomeHit))
Expect(t.Steps()[1].Detail).To(HaveSuffix(filepath.FromSlash("tests/fixtures/artist/an-album/cover.jpg")))
Expect(t.Steps()[1].Detail).ToNot(Equal("tests/fixtures/artist/an-album/cover.jpg"),
"a library-relative path sends the operator looking in the wrong place")
})
It("reports an unparseable disc id rather than explaining another disc", func() {
conf.Server.DiscArtPriority = "cover.jpg"
_, err := newResolver(ds, nil, ffm, nil).resolveDisc(ctx, "al1")
Expect(err).To(HaveOccurred())
})
})
var _ = Describe("resolveMediaFile tracing", func() {
var (
ctx context.Context
ds *tests.MockDataStore
mfRepo *tests.MockMediaFileRepo
ffm *tests.MockFFmpeg
t *ChainTrace
)
BeforeEach(func() {
DeferCleanup(configtest.SetupConfig())
conf.Server.EnableMediaFileCoverArt = true
repoRoot, err := os.Getwd()
Expect(err).ToNot(HaveOccurred())
libRepo := &tests.MockLibraryRepo{}
libRepo.SetData(model.Libraries{{ID: 0, Path: testFileLibPath(repoRoot)}})
mfRepo = tests.CreateMockMediaFileRepo()
mfRepo.SetData(model.MediaFiles{{
ID: "mf1", Title: "Song", HasCoverArt: true, Path: "tests/fixtures/artist/an-album/test.mp3",
}})
ds = &tests.MockDataStore{MockedMediaFile: mfRepo, MockedLibrary: libRepo}
ffm = tests.NewMockFFmpeg("")
t = &ChainTrace{}
ctx = withTrace(context.Background(), t)
})
It("separates a disabled setting from a track with nothing embedded", func() {
conf.Server.EnableMediaFileCoverArt = false
_, err := newResolver(ds, nil, ffm, nil).resolveMediaFile(ctx, "mf1")
Expect(err).ToNot(HaveOccurred())
Expect(t.Steps()).To(Equal([]TraceStep{
{Candidate: "embedded", Outcome: OutcomeSkipped, Detail: "EnableMediaFileCoverArt is off"},
}))
})
It("records a track with no embedded art as a miss", func() {
mfRepo.SetData(model.MediaFiles{{ID: "mf2", Title: "Song", HasCoverArt: false}})
_, err := newResolver(ds, nil, ffm, nil).resolveMediaFile(ctx, "mf2")
Expect(err).ToNot(HaveOccurred())
Expect(t.Steps()).To(Equal([]TraceStep{
{Candidate: "embedded", Outcome: OutcomeMiss, Detail: "the track has no embedded cover art"},
}))
})
It("records the embedded hit", func() {
res, err := newResolver(ds, nil, ffm, nil).resolveMediaFile(ctx, "mf1")
Expect(err).ToNot(HaveOccurred())
Expect(res.reader).ToNot(BeNil())
defer res.reader.Close()
Expect(res.source).To(Equal("embedded"))
Expect(t.Steps()).To(HaveLen(1))
Expect(t.Steps()[0].Outcome).To(Equal(OutcomeHit))
})
})

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)
}

View File

@ -362,7 +362,7 @@ var _ = Describe("Worker", func() {
Expect(ia.Hash).To(Equal("cafebabe"), "a persistent outage must not discard served art")
})
// Media files are excluded from recheckKinds, so an absent row here would never be
// Media files are excluded from RecheckKinds, so an absent row here would never be
// revisited: a transient read error would look permanent.
It("does not settle absent on exhaustion for a kind with no recheck path", func() {
conf.Server.EnableMediaFileCoverArt = true

View File

@ -20,24 +20,63 @@ func TestArtworkBreakerHalfOpen(t *testing.T) {
b := newBreaker()
for range breakerThreshold {
b.record("agentA", errors.New("boom"))
b.record("agentA", 0, errors.New("boom"))
}
g.Expect(b.allow()).To(BeFalse(), "breaker opens after consecutive errors")
g.Expect(allowed(b)).To(BeFalse(), "breaker opens after consecutive errors")
time.Sleep(breakerProbeAfter - time.Nanosecond)
g.Expect(b.allow()).To(BeFalse(), "still open before the probe interval")
g.Expect(allowed(b)).To(BeFalse(), "still open before the probe interval")
time.Sleep(time.Nanosecond)
g.Expect(b.allow()).To(BeTrue(), "half-open: one probe is granted")
g.Expect(b.allow()).To(BeFalse(), "only a single probe per interval")
ok, gen := b.allow()
g.Expect(ok).To(BeTrue(), "half-open: one probe is granted")
g.Expect(gen).ToNot(BeZero(), "a probe carries the open episode it belongs to")
g.Expect(allowed(b)).To(BeFalse(), "only a single probe per interval")
b.record("agentA", errors.New("boom")) // probe fails -> stay open
b.record("agentA", gen, errors.New("boom")) // probe fails -> stay open
time.Sleep(breakerProbeAfter)
g.Expect(b.allow()).To(BeTrue(), "another probe after the next interval")
ok, gen = b.allow()
g.Expect(ok).To(BeTrue(), "another probe after the next interval")
b.record("agentA", nil) // probe succeeds -> close
g.Expect(b.allow()).To(BeTrue(), "closed breaker admits freely")
g.Expect(b.allow()).To(BeTrue())
// One good answer must not reopen the floodgates: closing here is what let a burst out at
// full rate and got the provider to escalate from throttling to blocking.
b.record("agentA", gen, nil)
g.Expect(allowed(b)).To(BeFalse(), "a single good answer does not close the breaker")
for range breakerRecoveries - 1 {
time.Sleep(breakerProbeAfter)
ok, gen = b.allow()
g.Expect(ok).To(BeTrue())
b.record("agentA", gen, nil)
}
g.Expect(allowed(b)).To(BeTrue(), "closed breaker admits freely")
g.Expect(allowed(b)).To(BeTrue())
})
}
// The failure seen in production: while an agent was blocked, the occasional answer it did serve
// reset the breaker, releasing a burst that immediately re-tripped it. Open and closed pairs were
// seconds apart, over and over.
func TestArtworkBreakerDoesNotCloseOnAnIsolatedAnswer(t *testing.T) {
synctest.Test(t, func(t *testing.T) {
g := NewWithT(t)
b := newBreaker()
open := func() {
for range breakerThreshold {
b.record("agentA", 0, errors.New("blocked"))
}
}
open()
// A not-found is an answer, so it counts toward recovery, but never on its own.
for range breakerRecoveries * 2 {
time.Sleep(breakerProbeAfter)
ok, gen := b.allow()
g.Expect(ok).To(BeTrue(), "one probe per interval")
b.record("agentA", gen, agents.ErrNotFound)
b.record("agentA", 0, errors.New("blocked")) // the very next call is refused again
g.Expect(allowed(b)).To(BeFalse(), "an answer between failures must not close the breaker")
}
})
}

View File

@ -10,6 +10,7 @@ import (
"path/filepath"
"runtime"
"runtime/debug"
"strings"
"sync"
"sync/atomic"
"time"
@ -153,6 +154,17 @@ func getFSInfo(path string) *insights.FSInfo {
return &info
}
// installedPackage returns the official installer format used, as written by our own packagers.
func installedPackage() string {
data, _ := os.ReadFile(filepath.Join(conf.Server.DataFolder.String(), ".package"))
return strings.TrimSpace(string(data))
}
// hostingPlatform is env-based, not a file, as app stores can only inject env vars into our image.
func hostingPlatform() string {
return strings.TrimSpace(os.Getenv("ND_PLATFORM"))
}
var staticData = sync.OnceValue(func() insights.Data {
// Basic info
data := insights.Data{
@ -165,11 +177,8 @@ var staticData = sync.OnceValue(func() insights.Data {
data.OS.Containerized = consts.InContainer
// Install info
packageFilename := filepath.Join(conf.Server.DataFolder.String(), ".package")
packageFileData, err := os.ReadFile(packageFilename)
if err == nil {
data.OS.Package = string(packageFileData)
}
data.OS.Package = installedPackage()
data.Platform = hostingPlatform()
// OS info
data.OS.Type = runtime.GOOS

View File

@ -4,7 +4,9 @@ type Data struct {
InsightsID string `json:"id"`
Version string `json:"version"`
Uptime int64 `json:"uptime"`
Build struct {
// Platform is the app store or hosting provider this instance runs on, self-declared via ND_PLATFORM
Platform string `json:"platform,omitempty"`
Build struct {
// build settings used by the Go compiler
Settings map[string]string `json:"settings"`
GoVersion string `json:"goVersion"`

View File

@ -0,0 +1,70 @@
package metrics
import (
"os"
"path/filepath"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/conf/configtest"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
var _ = Describe("installedPackage", func() {
BeforeEach(func() {
DeferCleanup(configtest.SetupConfig())
conf.Server.DataFolder = conf.NewDir(GinkgoT().TempDir())
})
It("returns empty when there's no .package file", func() {
Expect(installedPackage()).To(BeEmpty())
})
It("reads the .package file from the data folder", func() {
writePackageFile("deb")
Expect(installedPackage()).To(Equal("deb"))
})
It("trims surrounding whitespace, as the msi packager writes a trailing newline", func() {
writePackageFile("msi\n")
Expect(installedPackage()).To(Equal("msi"))
})
It("ignores ND_PLATFORM", func() {
GinkgoT().Setenv("ND_PLATFORM", "zimaos")
Expect(installedPackage()).To(BeEmpty())
})
})
var _ = Describe("hostingPlatform", func() {
BeforeEach(func() {
// Setenv registers the restore, then unset so an inherited value can't leak in
GinkgoT().Setenv("ND_PLATFORM", "")
Expect(os.Unsetenv("ND_PLATFORM")).To(Succeed())
})
It("returns empty when ND_PLATFORM is not set", func() {
Expect(hostingPlatform()).To(BeEmpty())
})
It("reads ND_PLATFORM", func() {
GinkgoT().Setenv("ND_PLATFORM", "zimaos")
Expect(hostingPlatform()).To(Equal("zimaos"))
})
It("trims surrounding whitespace", func() {
GinkgoT().Setenv("ND_PLATFORM", " pikapods\n")
Expect(hostingPlatform()).To(Equal("pikapods"))
})
})
func writePackageFile(content string) {
GinkgoHelper()
path := filepath.Join(conf.Server.DataFolder.String(), ".package")
Expect(os.WriteFile(path, []byte(content), 0600)).To(Succeed())
}

View File

@ -0,0 +1,17 @@
package metrics
import (
"testing"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/tests"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
func TestMetrics(t *testing.T) {
tests.Init(t, false)
log.SetLevel(log.LevelFatal)
RegisterFailHandler(Fail)
RunSpecs(t, "Metrics Suite")
}

View File

@ -2,6 +2,7 @@ package scrobbler
import (
"context"
"encoding/json"
"maps"
"slices"
"sync"
@ -11,6 +12,7 @@ import (
"github.com/navidrome/navidrome/consts"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/model/criteria"
"github.com/navidrome/navidrome/model/request"
"github.com/navidrome/navidrome/server/events"
"github.com/navidrome/navidrome/utils/cache"
@ -43,6 +45,10 @@ type PlaybackSession struct {
PositionMs int64
PlaybackRate float64
LastReport time.Time
// Verdict from the last report, for the expiry callback: its context carries only
// a stub user, so it cannot evaluate the filter itself.
filtered bool
}
type Submission struct {
@ -68,8 +74,9 @@ type nowPlayingEntry struct {
}
type playbackReportEntry struct {
ctx context.Context
info PlaybackSession
ctx context.Context
info PlaybackSession
filtered bool
}
type PlayTracker interface {
@ -145,7 +152,7 @@ func newPlayTracker(ds model.DataStore, broker events.Broker, pluginManager Plug
log.Trace("Enqueueing PlaybackReport for expired session", "session", info)
info.State = StateExpired
info.LastReport = time.Now()
p.enqueuePlaybackReport(ctx, info)
p.enqueuePlaybackReport(ctx, info, info.filtered)
}
})
@ -273,6 +280,10 @@ func (p *playTracker) ReportPlayback(ctx context.Context, params ReportPlaybackP
now := time.Now()
// One verdict per report, reused by every dispatch below, so a filter reading
// annotations cannot decide differently on either side of incPlay.
var filtered bool
switch params.State {
case StateStarting:
// Clients may send starting/playing unordered; a late "starting" must not downgrade
@ -285,8 +296,10 @@ func (p *playTracker) ReportPlayback(ctx context.Context, params ReportPlaybackP
if err != nil {
return err
}
filtered = p.isFilteredOut(ctx, mf)
info := PlaybackSession{
MediaFile: *mf,
filtered: filtered,
Start: now,
UserId: user.ID,
Username: user.UserName,
@ -309,7 +322,7 @@ func (p *playTracker) ReportPlayback(ctx context.Context, params ReportPlaybackP
if err != nil {
log.Warn(ctx, "Error adding PlaybackSession to cache", "clientId", clientId, "mediaId", params.MediaId, "state", params.State, err)
}
p.enqueuePlaybackReport(ctx, info)
p.enqueuePlaybackReport(ctx, info, filtered)
case StatePlaying, StatePaused:
info, getErr := p.playMap.Get(clientId)
@ -331,6 +344,8 @@ func (p *playTracker) ReportPlayback(ctx context.Context, params ReportPlaybackP
info.PositionMs = params.PositionMs
info.PlaybackRate = params.PlaybackRate
info.LastReport = now
filtered = p.isFilteredOut(ctx, &info.MediaFile)
info.filtered = filtered
ttl := 30 * time.Minute
if params.State == StatePlaying {
ttl = remainingTTL(info.MediaFile.Duration, params.PositionMs, params.PlaybackRate)
@ -342,16 +357,19 @@ func (p *playTracker) ReportPlayback(ctx context.Context, params ReportPlaybackP
if err != nil {
log.Warn(ctx, "Error updating PlaybackSession in cache", "clientId", clientId, "mediaId", params.MediaId, "state", params.State, err)
}
p.enqueuePlaybackReport(ctx, info)
p.enqueuePlaybackReport(ctx, info, filtered)
case StateStopped:
var loadedMF *model.MediaFile
haveVerdict := false
if !params.IgnoreScrobble && player.ScrobbleEnabled {
mf, err := p.ds.MediaFile(ctx).GetWithParticipants(params.MediaId)
if err != nil {
return err
}
loadedMF = mf
filtered = p.isFilteredOut(ctx, mf)
haveVerdict = true
trackDurationMs := int64(mf.Duration * 1000)
threshold := min(trackDurationMs*50/100, 240_000)
if params.PositionMs >= threshold {
@ -359,7 +377,7 @@ func (p *playTracker) ReportPlayback(ctx context.Context, params ReportPlaybackP
if err != nil {
log.Warn(ctx, "Error updating play counts", "id", mf.ID, "track", mf.Title, "user", user.UserName, err)
}
p.dispatchScrobble(ctx, mf, now)
p.dispatchScrobble(ctx, mf, now, filtered)
}
}
p.sessionsMu.Lock()
@ -397,7 +415,10 @@ func (p *playTracker) ReportPlayback(ctx context.Context, params ReportPlaybackP
}
stoppedInfo.MediaFile = *mf
}
p.enqueuePlaybackReport(ctx, stoppedInfo)
if !haveVerdict {
filtered = p.isFilteredOut(ctx, &stoppedInfo.MediaFile)
}
p.enqueuePlaybackReport(ctx, stoppedInfo, filtered)
}
if conf.Server.EnableNowPlaying {
@ -413,7 +434,9 @@ func (p *playTracker) ReportPlayback(ctx context.Context, params ReportPlaybackP
// scrobbler plugins) returned by getActiveScrobblers; see dispatchNowPlaying.
if player.ScrobbleEnabled &&
(params.State == StateStarting || params.State == StatePlaying) {
if info, err := p.playMap.Get(clientId); err == nil {
if filtered {
log.Debug(ctx, "Ignoring external NowPlaying update for filtered track", "mediaId", params.MediaId)
} else if info, err := p.playMap.Get(clientId); err == nil {
p.enqueueNowPlaying(ctx, clientId, user.ID, &info.MediaFile, int(params.PositionMs/1000))
}
}
@ -452,6 +475,7 @@ func (p *playTracker) Submit(ctx context.Context, submissions []Submission) erro
log.Error(ctx, "Cannot find track for scrobbling", "id", s.TrackID, "user", username, err)
continue
}
filtered := p.isFilteredOut(ctx, mf)
err = p.incPlay(ctx, mf, s.Timestamp)
if err != nil {
log.Error(ctx, "Error updating play counts", "id", mf.ID, "track", mf.Title, "user", username, err)
@ -460,7 +484,7 @@ func (p *playTracker) Submit(ctx context.Context, submissions []Submission) erro
event.With("song", mf.ID).With("album", mf.AlbumID).With("artist", mf.AlbumArtistID)
log.Info(ctx, "Scrobbled", "title", mf.Title, "artist", mf.Artist, "user", username, "timestamp", s.Timestamp)
if player.ScrobbleEnabled {
p.dispatchScrobble(ctx, mf, s.Timestamp)
p.dispatchScrobble(ctx, mf, s.Timestamp, filtered)
}
}
}
@ -494,11 +518,36 @@ func (p *playTracker) incPlay(ctx context.Context, track *model.MediaFile, times
})
}
func (p *playTracker) dispatchScrobble(ctx context.Context, t *model.MediaFile, playTime time.Time) {
// Take this verdict before incPlay mutates what a filter reads, and independently of
// which scrobblers are active: it can be stored on a session and dispatched much later.
// Any parse or query failure fails open, because filtering must not break scrobbling.
func (p *playTracker) isFilteredOut(ctx context.Context, t *model.MediaFile) bool {
u, _ := request.UserFrom(ctx)
if u.ScrobbleFilter == "" {
return false
}
var c criteria.Criteria
if err := json.Unmarshal([]byte(u.ScrobbleFilter), &c); err != nil {
log.Warn(ctx, "Invalid scrobble filter, ignoring", "user", u.UserName, err)
return false
}
match, err := p.ds.MediaFile(ctx).MatchesCriteria(t.ID, c)
if err != nil {
log.Warn(ctx, "Error evaluating scrobble filter, ignoring", "user", u.UserName, "track", t.Title, err)
return false
}
return match
}
func (p *playTracker) dispatchScrobble(ctx context.Context, t *model.MediaFile, playTime time.Time, filtered bool) {
if t.Artist == consts.UnknownArtist {
log.Debug(ctx, "Ignoring external Scrobble for track with unknown artist", "track", t.Title, "artist", t.Artist)
return
}
if filtered {
log.Debug(ctx, "Ignoring external Scrobble for filtered track", "track", t.Title, "artist", t.Artist)
return
}
allScrobblers := p.getActiveScrobblers()
u, _ := request.UserFrom(ctx)

View File

@ -14,6 +14,7 @@ import (
"github.com/navidrome/navidrome/consts"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/model/criteria"
"github.com/navidrome/navidrome/model/request"
"github.com/navidrome/navidrome/server/events"
"github.com/navidrome/navidrome/tests"
@ -46,6 +47,26 @@ func (m *mockPluginLoader) LoadScrobbler(name string) (Scrobbler, bool) {
return s, ok
}
// flipOnPlayRepo reports one filter verdict before the play is counted and another
// after, reproducing a filter that reads annotations incPlay mutates.
type flipOnPlayRepo struct {
model.MediaFileRepository
before, after bool
played atomic.Bool
}
func (r *flipOnPlayRepo) IncPlayCount(id string, ts time.Time) error {
r.played.Store(true)
return r.MediaFileRepository.IncPlayCount(id, ts)
}
func (r *flipOnPlayRepo) MatchesCriteria(string, criteria.Criteria) (bool, error) {
if r.played.Load() {
return r.after, nil
}
return r.before, nil
}
// slowMediaFileRepo widens the window between a report's session check and its
// write, making check-then-write races reproducible.
type slowMediaFileRepo struct {
@ -320,6 +341,185 @@ var _ = Describe("PlayTracker", func() {
})
})
Describe("Scrobble filter", func() {
var repo *tests.MockMediaFileRepo
BeforeEach(func() {
ctx = request.WithUser(ctx, model.User{ID: "u-1", UserName: "user-1",
ScrobbleFilter: `{"all":[{"contains":{"title":"Track"}}]}`})
repo = ds.MediaFile(ctx).(*tests.MockMediaFileRepo)
})
It("does not send a matching track to the agent", func() {
repo.MatchesCriteriaValue = true
err := tracker.Submit(ctx, []Submission{{TrackID: "123", Timestamp: time.Now()}})
Expect(err).ToNot(HaveOccurred())
Expect(fake.ScrobbleCalled.Load()).To(BeFalse())
})
It("still increments play counts for a filtered track", func() {
repo.MatchesCriteriaValue = true
err := tracker.Submit(ctx, []Submission{{TrackID: "123", Timestamp: time.Now()}})
Expect(err).ToNot(HaveOccurred())
Expect(track.PlayCount).To(Equal(int64(1)))
Expect(album.PlayCount).To(Equal(int64(1)))
})
It("sends a non-matching track to the agent", func() {
repo.MatchesCriteriaValue = false
err := tracker.Submit(ctx, []Submission{{TrackID: "123", Timestamp: time.Now()}})
Expect(err).ToNot(HaveOccurred())
Expect(fake.ScrobbleCalled.Load()).To(BeTrue())
})
It("fails open when evaluation errors", func() {
repo.MatchesCriteriaErr = errors.New("boom")
err := tracker.Submit(ctx, []Submission{{TrackID: "123", Timestamp: time.Now()}})
Expect(err).ToNot(HaveOccurred())
Expect(fake.ScrobbleCalled.Load()).To(BeTrue())
})
It("fails open when the stored filter is not valid JSON", func() {
ctx = request.WithUser(ctx, model.User{ID: "u-1", UserName: "user-1", ScrobbleFilter: `{broken`})
repo.MatchesCriteriaValue = true
err := tracker.Submit(ctx, []Submission{{TrackID: "123", Timestamp: time.Now()}})
Expect(err).ToNot(HaveOccurred())
Expect(fake.ScrobbleCalled.Load()).To(BeTrue())
})
It("does not send now-playing for a filtered track", func() {
repo.MatchesCriteriaValue = true
err := tracker.ReportPlayback(ctx, ReportPlaybackParams{
MediaId: "123", State: StateStarting, ClientId: "player-1", ClientName: "player"})
Expect(err).ToNot(HaveOccurred())
Consistently(func() bool { return fake.GetNowPlayingCalled() }).Should(BeFalse())
})
It("does not send playback reports for a filtered track", func() {
repo.MatchesCriteriaValue = true
err := tracker.ReportPlayback(ctx, ReportPlaybackParams{
MediaId: "123", State: StateStarting, ClientId: "player-1", ClientName: "player"})
Expect(err).ToNot(HaveOccurred())
Consistently(func() bool { return fake.PlaybackReportCalled.Load() }).Should(BeFalse())
})
It("sends playback reports for a non-matching track", func() {
repo.MatchesCriteriaValue = false
err := tracker.ReportPlayback(ctx, ReportPlaybackParams{
MediaId: "123", State: StateStarting, ClientId: "player-1", ClientName: "player"})
Expect(err).ToNot(HaveOccurred())
Eventually(func() bool { return fake.PlaybackReportCalled.Load() }).Should(BeTrue())
})
It("evaluates the filter even when no scrobbler is active yet", func() {
// The verdict is stored on the session and dispatched at expiry, by which
// time a plugin scrobbler may have been enabled.
tracker.builtinScrobblers = map[string]Scrobbler{}
repo.MatchesCriteriaValue = true
err := tracker.ReportPlayback(ctx, ReportPlaybackParams{
MediaId: "123", State: StatePlaying, ClientId: "player-12", ClientName: "player"})
Expect(err).ToNot(HaveOccurred())
stored, getErr := tracker.playMap.Get("player-12")
Expect(getErr).ToNot(HaveOccurred())
Expect(stored.filtered).To(BeTrue())
})
Context("when incPlay itself flips the filter", func() {
// A filter on playCount or lastPlayed changes verdict the moment incPlay
// commits, so the verdict has to be taken before it, not at dispatch time.
var flip *flipOnPlayRepo
install := func(before, after bool) {
flip = &flipOnPlayRepo{MediaFileRepository: ds.MediaFile(ctx), before: before, after: after}
ds.(*tests.MockDataStore).MockedMediaFile = flip
}
It("does not scrobble a track the filter matched before the play was counted", func() {
install(true, false)
err := tracker.ReportPlayback(ctx, ReportPlaybackParams{
MediaId: "123", State: StateStopped, PositionMs: 120_000, ClientId: "player-1", ClientName: "player"})
Expect(err).ToNot(HaveOccurred())
Expect(flip.played.Load()).To(BeTrue(), "incPlay must still have run")
Expect(fake.ScrobbleCalled.Load()).To(BeFalse())
})
It("still sends the stopped report when the filter only starts matching after the play", func() {
install(false, true)
err := tracker.ReportPlayback(ctx, ReportPlaybackParams{
MediaId: "123", State: StateStopped, PositionMs: 120_000, ClientId: "player-1", ClientName: "player"})
Expect(err).ToNot(HaveOccurred())
Expect(flip.played.Load()).To(BeTrue(), "incPlay must still have run")
Eventually(func() bool { return fake.PlaybackReportCalled.Load() }).Should(BeTrue())
})
It("does not report an expired session for a filtered track", func() {
// The expiry callback runs with a stub user, so it cannot evaluate the
// filter itself and must reuse the verdict stored on the session.
info := PlaybackSession{
MediaFile: track, Start: time.Now(), UserId: "u-1", Username: "user-1",
PlayerId: "player-9", PlayerName: "test-player", State: StatePlaying, filtered: true,
}
_ = tracker.playMap.AddWithTTL("player-9", info, 10*time.Millisecond)
Consistently(func() bool { return fake.PlaybackReportCalled.Load() }).Should(BeFalse())
})
It("still reports an expired session for a track that is not filtered", func() {
info := PlaybackSession{
MediaFile: track, Start: time.Now(), UserId: "u-1", Username: "user-1",
PlayerId: "player-10", PlayerName: "test-player", State: StatePlaying, filtered: false,
}
_ = tracker.playMap.AddWithTTL("player-10", info, 10*time.Millisecond)
Eventually(func() bool { return fake.PlaybackReportCalled.Load() }).Should(BeTrue())
})
It("stores the verdict on the session so expiry can reuse it", func() {
repo.MatchesCriteriaValue = true
err := tracker.ReportPlayback(ctx, ReportPlaybackParams{
MediaId: "123", State: StatePlaying, ClientId: "player-11", ClientName: "player"})
Expect(err).ToNot(HaveOccurred())
stored, getErr := tracker.playMap.Get("player-11")
Expect(getErr).ToNot(HaveOccurred())
Expect(stored.filtered).To(BeTrue())
})
It("does not scrobble a Submit whose filter matched before the play was counted", func() {
install(true, false)
err := tracker.Submit(ctx, []Submission{{TrackID: "123", Timestamp: time.Now()}})
Expect(err).ToNot(HaveOccurred())
Expect(flip.played.Load()).To(BeTrue(), "incPlay must still have run")
Expect(fake.ScrobbleCalled.Load()).To(BeFalse())
})
})
})
Describe("ReportPlayback", func() {
const defaultClientId = "client-1"

View File

@ -6,13 +6,14 @@ import (
"github.com/navidrome/navidrome/log"
)
func (p *playTracker) enqueuePlaybackReport(ctx context.Context, info PlaybackSession) {
func (p *playTracker) enqueuePlaybackReport(ctx context.Context, info PlaybackSession, filtered bool) {
p.prMu.Lock()
defer p.prMu.Unlock()
ctx = context.WithoutCancel(ctx)
p.prQueue = append(p.prQueue, playbackReportEntry{
ctx: ctx,
info: info,
ctx: ctx,
info: info,
filtered: filtered,
})
p.sendPlaybackReportSignal()
}
@ -44,12 +45,16 @@ func (p *playTracker) playbackReportWorker() {
allScrobblers := p.getActiveScrobblers()
for _, entry := range entries {
p.dispatchPlaybackReport(entry.ctx, entry.info, allScrobblers)
p.dispatchPlaybackReport(entry.ctx, entry.info, allScrobblers, entry.filtered)
}
}
}
func (p *playTracker) dispatchPlaybackReport(ctx context.Context, info PlaybackSession, allScrobblers map[string]Scrobbler) {
func (p *playTracker) dispatchPlaybackReport(ctx context.Context, info PlaybackSession, allScrobblers map[string]Scrobbler, filtered bool) {
if filtered {
log.Debug(ctx, "Ignoring external PlaybackReport for filtered track", "track", info.MediaFile.Title, "state", info.State)
return
}
for name, s := range allScrobblers {
if !s.IsAuthorized(ctx, info.UserId) {
continue

View File

@ -0,0 +1,5 @@
-- +goose Up
alter table user add column scrobble_filter varchar default '' not null;
-- +goose Down
alter table user drop column scrobble_filter;

View File

@ -0,0 +1,11 @@
-- +goose Up
-- Repairs album.created_at values stored in RFC3339 T-format by CopyAttributes.
-- These values sort incorrectly in "Recently Added", which compares timestamps as raw strings.
UPDATE album SET created_at = replace(replace(created_at, 'T', ' '), 'Z', '+00:00')
WHERE created_at LIKE '%T%';
-- +goose Down
SELECT 1;

58
go.mod
View File

@ -11,7 +11,7 @@ require (
github.com/bmatcuk/doublestar/v4 v4.10.0
github.com/deluan/rest v0.0.0-20211102003136-6260bc399cbf
github.com/deluan/sanitize v0.0.0-20241120162836-fdfd8fdfaa55
github.com/dexterlb/mpvipc v0.0.0-20241005113212-7cdefca0e933
github.com/dexterlb/mpvipc v0.0.0-20260722094525-0cf47d745b36
github.com/djherbis/atime v1.1.0
github.com/djherbis/fscache v0.10.2-0.20231127215153-442a07e326c4
github.com/djherbis/stream v1.4.0
@ -26,7 +26,7 @@ require (
github.com/go-chi/jwtauth/v5 v5.4.0
github.com/go-viper/encoding/ini v0.1.1
github.com/go-viper/mapstructure/v2 v2.5.0
github.com/gohugoio/hashstructure v0.6.0
github.com/gohugoio/hashstructure v1.0.0
github.com/google/go-pipeline v0.0.0-20230411140531-6cbedfc1d3fc
github.com/google/uuid v1.6.0
github.com/google/wire v0.7.0
@ -35,37 +35,37 @@ require (
github.com/jellydator/ttlcache/v3 v3.4.1
github.com/kardianos/service v1.3.0
github.com/kr/pretty v0.3.1
github.com/lestrrat-go/jwx/v3 v3.1.1
github.com/mattn/go-sqlite3 v1.14.48
github.com/lestrrat-go/jwx/v3 v3.2.0
github.com/mattn/go-sqlite3 v1.14.50
github.com/microcosm-cc/bluemonday v1.0.27
github.com/mileusna/useragent v1.3.5
github.com/onsi/ginkgo/v2 v2.32.0
github.com/onsi/ginkgo/v2 v2.32.1
github.com/onsi/gomega v1.42.1
github.com/pelletier/go-toml/v2 v2.4.3
github.com/pmezard/go-difflib v1.0.0
github.com/pocketbase/dbx v1.12.0
github.com/pressly/goose/v3 v3.27.2
github.com/prometheus/client_golang v1.23.2
github.com/pressly/goose/v3 v3.27.3
github.com/prometheus/client_golang v1.24.1
github.com/rjeczalik/notify v0.9.3
github.com/robfig/cron/v3 v3.0.1
github.com/sabhiram/go-gitignore v0.0.0-20210923224102-525f6e181f06
github.com/santhosh-tekuri/jsonschema/v6 v6.0.2
github.com/sirupsen/logrus v1.9.4
github.com/santhosh-tekuri/jsonschema/v6 v6.0.3
github.com/sirupsen/logrus v1.10.0
github.com/spf13/cobra v1.10.2
github.com/spf13/viper v1.21.0
github.com/stretchr/testify v1.11.1
github.com/stretchr/testify v1.12.0
github.com/tetratelabs/wazero v1.12.0
github.com/unrolled/secure v1.17.0
github.com/xrash/smetrics v0.0.0-20250705151800-55b8f293f342
github.com/zeebo/xxh3 v1.1.0
go.senan.xyz/taglib v0.11.1
go.uber.org/goleak v1.3.0
golang.org/x/image v0.44.0
golang.org/x/net v0.57.0
golang.org/x/image v0.45.0
golang.org/x/net v0.58.0
golang.org/x/sync v0.22.0
golang.org/x/sys v0.47.0
golang.org/x/term v0.45.0
golang.org/x/text v0.40.0
golang.org/x/text v0.41.0
golang.org/x/time v0.15.0
gopkg.in/yaml.v3 v3.0.1
)
@ -79,22 +79,21 @@ require (
github.com/cespare/reflex v0.3.2 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/creack/pty v1.1.24 // indirect
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.1 // indirect
github.com/dylibso/observe-sdk/go v0.0.0-20240828172851-9145d8ad07e1 // indirect
github.com/ebitengine/purego v0.10.1 // indirect
github.com/ebitengine/purego v0.10.2 // indirect
github.com/fsnotify/fsnotify v1.10.1 // indirect
github.com/go-logr/logr v1.4.3 // indirect
github.com/go-logr/logr v1.4.4 // indirect
github.com/go-task/slim-sprig/v3 v3.0.0 // indirect
github.com/gobwas/glob v0.2.3 // indirect
github.com/goccy/go-json v0.10.6 // indirect
github.com/goccy/go-yaml v1.19.2 // indirect
github.com/google/go-cmp v0.7.0 // indirect
github.com/google/pprof v0.0.0-20260709232956-b9395ee17fa0 // indirect
github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3 // indirect
github.com/google/subcommands v1.2.0 // indirect
github.com/gorilla/css v1.0.1 // indirect
github.com/hashicorp/errwrap v1.1.0 // indirect
github.com/ianlancetaylor/demangle v0.0.0-20260505044615-1ff4bf46051f // indirect
github.com/ianlancetaylor/demangle v0.0.0-20260724033716-83e58baca724 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 // indirect
github.com/klauspost/cpuid/v2 v2.4.0 // indirect
@ -114,13 +113,13 @@ require (
github.com/ogier/pflag v0.0.1 // indirect
github.com/pkg/errors v0.9.1 // indirect
github.com/prometheus/client_model v0.6.2 // indirect
github.com/prometheus/common v0.67.5 // indirect
github.com/prometheus/procfs v0.20.1 // indirect
github.com/rogpeppe/go-internal v1.15.0 // indirect
github.com/prometheus/common v0.70.1 // indirect
github.com/prometheus/procfs v0.21.1 // indirect
github.com/rogpeppe/go-internal v1.16.0 // indirect
github.com/sagikazarmark/locafero v0.12.0 // indirect
github.com/sanity-io/litter v1.5.8 // indirect
github.com/segmentio/asm v1.2.1 // indirect
github.com/sethvargo/go-retry v0.3.0 // indirect
github.com/sethvargo/go-retry v0.4.0 // indirect
github.com/sosodev/duration v1.3.1 // indirect
github.com/spf13/afero v1.15.0 // indirect
github.com/spf13/cast v1.10.0 // indirect
@ -129,15 +128,14 @@ require (
github.com/subosito/gotenv v1.6.0 // indirect
github.com/tetratelabs/wabin v0.0.0-20230304001439-f6f874872834 // indirect
github.com/valyala/fastjson v1.6.10 // indirect
go.opentelemetry.io/proto/otlp v1.10.0 // indirect
go.opentelemetry.io/proto/otlp v1.11.0 // indirect
go.uber.org/multierr v1.11.0 // indirect
go.yaml.in/yaml/v2 v2.4.3 // indirect
go.yaml.in/yaml/v3 v3.0.4 // indirect
golang.org/x/crypto v0.54.0 // indirect
golang.org/x/mod v0.38.0 // indirect
golang.org/x/telemetry v0.0.0-20260708182218-49f421fb7959 // indirect
golang.org/x/tools v0.48.0 // indirect
google.golang.org/protobuf v1.36.11 // indirect
go.yaml.in/yaml/v3 v3.0.5 // indirect
golang.org/x/crypto v0.55.0 // indirect
golang.org/x/mod v0.40.0 // indirect
golang.org/x/telemetry v0.0.0-20260811182544-a038080d80e5 // indirect
golang.org/x/tools v0.49.0 // indirect
google.golang.org/protobuf v1.36.12 // indirect
gopkg.in/ini.v1 v1.67.3 // indirect
gopkg.in/natefinch/npipe.v2 v2.0.0-20160621034901-c1b8fa8bdcce // indirect
)

136
go.sum
View File

@ -27,8 +27,6 @@ github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfv
github.com/davecgh/go-spew v0.0.0-20161028175848-04cdfd42973b/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.1 h1:5RVFMOWjMyRy8cARdy79nAmgYw3hK/4HUq48LQ6Wwqo=
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.1/go.mod h1:ZXNYxsqcloTdSy/rNShjYzMhyjf0LaoftYK0p+A3h40=
github.com/deluan/go-taglib v0.0.0-20260720134629-a133b9719ea3 h1:j7eSXqgtjhlNfwnMEzRdXnJGZTEw4I7J9TeQAll83bU=
@ -37,8 +35,8 @@ github.com/deluan/rest v0.0.0-20211102003136-6260bc399cbf h1:tb246l2Zmpt/GpF9EcH
github.com/deluan/rest v0.0.0-20211102003136-6260bc399cbf/go.mod h1:tSgDythFsl0QgS/PFWfIZqcJKnkADWneY80jaVRlqK8=
github.com/deluan/sanitize v0.0.0-20241120162836-fdfd8fdfaa55 h1:wSCnggTs2f2ji6nFwQmfwgINcmSMj0xF0oHnoyRSPe4=
github.com/deluan/sanitize v0.0.0-20241120162836-fdfd8fdfaa55/go.mod h1:ZNCLJfehvEf34B7BbLKjgpsL9lyW7q938w/GY1XgV4E=
github.com/dexterlb/mpvipc v0.0.0-20241005113212-7cdefca0e933 h1:r4hxcT6GBIA/j8Ox4OXI5MNgMKfR+9plcAWYi1OnmOg=
github.com/dexterlb/mpvipc v0.0.0-20241005113212-7cdefca0e933/go.mod h1:RkQWLNITKkXHLP7LXxZSgEq+uFWU25M5qW7qfEhL9Wc=
github.com/dexterlb/mpvipc v0.0.0-20260722094525-0cf47d745b36 h1:KtPfdSST6e0vJbMzMmVqPa5mO1u8vMBlybRCW2ieXpA=
github.com/dexterlb/mpvipc v0.0.0-20260722094525-0cf47d745b36/go.mod h1:RkQWLNITKkXHLP7LXxZSgEq+uFWU25M5qW7qfEhL9Wc=
github.com/djherbis/atime v1.1.0 h1:rgwVbP/5by8BvvjBNrbh64Qz33idKT3pSnMSJsxhi0g=
github.com/djherbis/atime v1.1.0/go.mod h1:28OF6Y8s3NQWwacXc5eZTsEsiMzp7LF8MbXE+XJPdBE=
github.com/djherbis/fscache v0.10.2-0.20231127215153-442a07e326c4 h1:wdZllsLrDJtYfHiAKogB4PNHSDeO+v+5S3eqSWHGDlc=
@ -53,8 +51,8 @@ github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkp
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/dylibso/observe-sdk/go v0.0.0-20240828172851-9145d8ad07e1 h1:idfl8M8rPW93NehFw5H1qqH8yG158t5POr+LX9avbJY=
github.com/dylibso/observe-sdk/go v0.0.0-20240828172851-9145d8ad07e1/go.mod h1:C8DzXehI4zAbrdlbtOByKX6pfivJTBiV9Jjqv56Yd9Q=
github.com/ebitengine/purego v0.10.1 h1:dewVBCBT2GaMu1SrNTYxQhgQBethzfhiwvZiLGP/qyY=
github.com/ebitengine/purego v0.10.1/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ=
github.com/ebitengine/purego v0.10.2 h1:W809HbnvzAxgdm+aOvlSekrM16wGCdT/e76+9tS7gzE=
github.com/ebitengine/purego v0.10.2/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ=
github.com/extism/go-sdk v1.7.1 h1:lWJos6uY+tRFdlIHR+SJjwFDApY7OypS/2nMhiVQ9Sw=
github.com/extism/go-sdk v1.7.1/go.mod h1:IT+Xdg5AZM9hVtpFUA+uZCJMge/hbvshl8bwzLtFyKA=
github.com/fatih/structs v1.1.0 h1:Q7juDM0QtcnhCpeyLGQKyg4TOIghuNXrkL32pHAUMxo=
@ -79,8 +77,8 @@ github.com/go-chi/httprate v0.16.0 h1:8V5DH9j6pSK6UQoBsTpvMyFxycqaKEIToyPKzHJjUa
github.com/go-chi/httprate v0.16.0/go.mod h1:A8lo+qRhk+s9LiuP5saS7XCGDXRXMcrueq0NfIuCa/I=
github.com/go-chi/jwtauth/v5 v5.4.0 h1:Ieh0xMJsFvqylqJ02/mQHKzbbKO9DYNBh4DPKCwTwYI=
github.com/go-chi/jwtauth/v5 v5.4.0/go.mod h1:w6yjqUUXz1b8+oiJel64Sz1KJwduQM6qUA5QNzO5+bQ=
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
github.com/go-logr/logr v1.4.4 h1:tG4xh9yMsRCAiodLVTxyrkzSZ9+o0L1Kg/+cPVcbP/8=
github.com/go-logr/logr v1.4.4/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
github.com/go-sql-driver/mysql v1.4.1/go.mod h1:zAC/RDZ24gD3HViQzih4MyKcchzm+sOG5ZlKdlhCg5w=
github.com/go-sql-driver/mysql v1.10.0 h1:Q+1LV8DkHJvSYAdR83XzuhDaTykuDx0l6fkXxoWCWfw=
github.com/go-sql-driver/mysql v1.10.0/go.mod h1:M+cqaI7+xxXGG9swrdeUIoPG3Y3KCkF0pZej+SK+nWk=
@ -96,15 +94,15 @@ github.com/goccy/go-json v0.10.6 h1:p8HrPJzOakx/mn/bQtjgNjdTcN+/S6FcG2CTtQOrHVU=
github.com/goccy/go-json v0.10.6/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM=
github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
github.com/gohugoio/hashstructure v0.6.0 h1:7wMB/2CfXoThFYhdWRGv3u3rUM761Cq29CxUW+NltUg=
github.com/gohugoio/hashstructure v0.6.0/go.mod h1:lapVLk9XidheHG1IQ4ZSbyYrXcaILU1ZEP/+vno5rBQ=
github.com/gohugoio/hashstructure v1.0.0 h1:vWYuyzs1n0LdI0F54TJQeYAiB44fHX7H9hCp9X6gHKg=
github.com/gohugoio/hashstructure v1.0.0/go.mod h1:FSbTK4QwxucJ2bC4Lvrs9a6x0DbQDXNoyBO+h4nlCgE=
github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/go-pipeline v0.0.0-20230411140531-6cbedfc1d3fc h1:hd+uUVsB1vdxohPneMrhGH2YfQuH5hRIK9u4/XCeUtw=
github.com/google/go-pipeline v0.0.0-20230411140531-6cbedfc1d3fc/go.mod h1:SL66SJVysrh7YbDCP9tH30b8a9o/N2HeiQNUm85EKhc=
github.com/google/pprof v0.0.0-20260709232956-b9395ee17fa0 h1:du0WGc8xSKq/++e0cglxhS/mXVqsR7+c7jLEi5Vqduw=
github.com/google/pprof v0.0.0-20260709232956-b9395ee17fa0/go.mod h1:MxpfABSjhmINe3F1It9d+8exIHFvUqtLIRCdOGNXqiI=
github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3 h1:LMLX+LgTNWpfvCBdFebv6EsYotImrt/Ppc5cXIriCSo=
github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3/go.mod h1:jl5iWTm0/hd5PjEYEOuwAJ57L/CibdZfrqZ5XA5GrCk=
github.com/google/subcommands v1.2.0 h1:vWQspBTo2nEqTUFita5/KeEWlUL8kQObDFbub/EN9oE=
github.com/google/subcommands v1.2.0/go.mod h1:ZjhPrFU+Olkh9WazFPsl27BQ4UPiG37m3yTrtFlrHVk=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
@ -122,8 +120,8 @@ github.com/hashicorp/errwrap v1.1.0 h1:OxrOeh75EUXMY8TBjag2fzXGZ40LB6IKw45YeGUDY
github.com/hashicorp/errwrap v1.1.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4=
github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+lD48awMYo=
github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM=
github.com/ianlancetaylor/demangle v0.0.0-20260505044615-1ff4bf46051f h1:NW3E2QSchEk63/fjeEvWOa2cE02FSv9ox//VE/N4c8g=
github.com/ianlancetaylor/demangle v0.0.0-20260505044615-1ff4bf46051f/go.mod h1:gx7rwoVhcfuVKG5uya9Hs3Sxj7EIvldVofAWIUtGouw=
github.com/ianlancetaylor/demangle v0.0.0-20260724033716-83e58baca724 h1:QixF8Mcbe87ET7pK/fPbBJ9GXFddmEY8yYMepzMzo30=
github.com/ianlancetaylor/demangle v0.0.0-20260724033716-83e58baca724/go.mod h1:gx7rwoVhcfuVKG5uya9Hs3Sxj7EIvldVofAWIUtGouw=
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
github.com/jellydator/ttlcache/v3 v3.4.1 h1:bOdXmXiycyK6E6Qjyuj5vl+/vU3SCOoDs8a86NbHjAQ=
@ -136,8 +134,8 @@ github.com/kardianos/service v1.3.0 h1:/LGy+xPP2TM+GLTiCZ2di7cy0Jd/qrawlTUfqKYFd
github.com/kardianos/service v1.3.0/go.mod h1:E4V9ufUuY82F7Ztlu1eN9VXWIQxg8NoLQlmFe0MtrXc=
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 h1:Z9n2FFNUXsshfwJMBgNA0RU6/i7WVaAegv3PtuIHPMs=
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51/go.mod h1:CzGEWj7cYgsdH8dAjBGEr58BoE7ScuLd+fwFZ44+/x8=
github.com/klauspost/compress v1.18.5 h1:/h1gH5Ce+VWNLSWqPzOVn6XBO+vJbCNGvjoaGBFW2IE=
github.com/klauspost/compress v1.18.5/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ=
github.com/klauspost/compress v1.19.1 h1:VsB4HPswih7mmZ8WleSFQ75c/Ui1M4trX5oAsJnhSlk=
github.com/klauspost/compress v1.19.1/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ=
github.com/klauspost/cpuid/v2 v2.4.0 h1:S6Hrbc7+ywsr0r+RLapfGBHfyefhCTwEh3A0tV913Dw=
github.com/klauspost/cpuid/v2 v2.4.0/go.mod h1:19jmZ9mjzoF//ddRSUsv0zfBTJWh3QJh9FNxZTMrGxU=
github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ=
@ -161,16 +159,16 @@ github.com/lestrrat-go/httpcc v1.0.1 h1:ydWCStUeJLkpYyjLDHihupbn2tYmZ7m22BGkcvZZ
github.com/lestrrat-go/httpcc v1.0.1/go.mod h1:qiltp3Mt56+55GPVCbTdM9MlqhvzyuL6W/NMDA8vA5E=
github.com/lestrrat-go/httprc/v3 v3.0.6 h1:4FpLQ18KK/ypPbVU3NLWJNRvH3kcYiqKqWfKGqNWxxI=
github.com/lestrrat-go/httprc/v3 v3.0.6/go.mod h1:mSMtkZW92Z98M5YoNNztbRGxbXHql7tSitCvaxvo9l0=
github.com/lestrrat-go/jwx/v3 v3.1.1 h1:yd9AdPmZ4INnQ7k42IrzXYpnEG803+SrQ6hdMvzHJzw=
github.com/lestrrat-go/jwx/v3 v3.1.1/go.mod h1:uw/MN2M/Xiu4FhwcIwH11Zsh9JWx9SWzgALl7/uIEkU=
github.com/lestrrat-go/jwx/v3 v3.2.0 h1:Jb3zBASTSZXz7gzzSAfYqxXF8KejvKC4xWoePLQqXCA=
github.com/lestrrat-go/jwx/v3 v3.2.0/go.mod h1:38vQ8iWKq3qRSbilbzvzdQPuywhowwuR03lhkYskyrw=
github.com/lestrrat-go/option/v2 v2.0.0 h1:XxrcaJESE1fokHy3FpaQ/cXW8ZsIdWcdFzzLOcID3Ss=
github.com/lestrrat-go/option/v2 v2.0.0/go.mod h1:oSySsmzMoR0iRzCDCaUfsCzxQHUEuhOViQObyy7S6Vg=
github.com/maruel/natural v1.3.0 h1:VsmCsBmEyrR46RomtgHs5hbKADGRVtliHTyCOLFBpsg=
github.com/maruel/natural v1.3.0/go.mod h1:v+Rfd79xlw1AgVBjbO0BEQmptqb5HvL/k9GRHB7ZKEg=
github.com/mattn/go-isatty v0.0.21 h1:xYae+lCNBP7QuW4PUnNG61ffM4hVIfm+zUzDuSzYLGs=
github.com/mattn/go-isatty v0.0.21/go.mod h1:ZXfXG4SQHsB/w3ZeOYbR0PrPwLy+n6xiMrJlRFqopa4=
github.com/mattn/go-sqlite3 v1.14.48 h1:7XHIgl0a8HwOaiK4E47ozLkST78rR9+OtNGx27D/TFs=
github.com/mattn/go-sqlite3 v1.14.48/go.mod h1:6JTjA44L93a0QCyJef5YvlPoKXntQPjzWv5gtm9sB6w=
github.com/mattn/go-isatty v0.0.23 h1:cYwCQTQf3HB6xUC+BtyCLZNr7IzbOmoZbmssVNzSyiQ=
github.com/mattn/go-isatty v0.0.23/go.mod h1:nMCL3Zebbrt45jsMDgnfIwz6ydEQApk5oEI3HqDio6A=
github.com/mattn/go-sqlite3 v1.14.50 h1:dmdFvo1XG4MPzA4IkAmE9upVz/Nj31uRoM5+jC8hYbY=
github.com/mattn/go-sqlite3 v1.14.50/go.mod h1:6JTjA44L93a0QCyJef5YvlPoKXntQPjzWv5gtm9sB6w=
github.com/mfridman/interpolate v0.0.2 h1:pnuTK7MQIxxFz1Gr+rjSIx9u7qVjf5VOoM/u6BbAxPY=
github.com/mfridman/interpolate v0.0.2/go.mod h1:p+7uk6oE07mpE/Ik1b8EckO0O4ZXiGAfshKBWLUM9Xg=
github.com/mfridman/tparse v0.18.0 h1:wh6dzOKaIwkUGyKgOntDW4liXSo37qg5AXbIhkMV3vE=
@ -187,8 +185,8 @@ github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOF
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/ogier/pflag v0.0.1 h1:RW6JSWSu/RkSatfcLtogGfFgpim5p7ARQ10ECk5O750=
github.com/ogier/pflag v0.0.1/go.mod h1:zkFki7tvTa0tafRvTBIZTvzYyAu6kQhPZFnshFFPE+g=
github.com/onsi/ginkgo/v2 v2.32.0 h1:Hw7s2pVrQo/8Yz5N77qdnpHaoc+c6cC9WIV1Jce+J6E=
github.com/onsi/ginkgo/v2 v2.32.0/go.mod h1:+aXOY+vzZ5mu2iI2HpTZUPmM//oQfsNFX6gU9kNcA44=
github.com/onsi/ginkgo/v2 v2.32.1 h1:6tlvcDm/3sE8lGJbZ4+d4mO3RLy24/tQWOFzVSQNIfw=
github.com/onsi/ginkgo/v2 v2.32.1/go.mod h1:+aXOY+vzZ5mu2iI2HpTZUPmM//oQfsNFX6gU9kNcA44=
github.com/onsi/gomega v1.42.1 h1:iN1rCUX+44NZ1Dc97MPoeFYbFR0vh8zxoxMFwKdyZ6I=
github.com/onsi/gomega v1.42.1/go.mod h1:REff/hsDsodHoKlWsP2mAPhu1+5/6hVYNf9rIEBpeSg=
github.com/pelletier/go-toml/v2 v2.4.3 h1:GTRvJQutkOSftxIFD5xw9aepkYNuPWmVJpffdDPYVpY=
@ -201,16 +199,16 @@ github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZb
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/pocketbase/dbx v1.12.0 h1:/oLErM+A0b4xI0PWTGPqSDVjzix48PqI/bng2l0PzoA=
github.com/pocketbase/dbx v1.12.0/go.mod h1:xXRCIAKTHMgUCyCKZm55pUOdvFziJjQfXaWKhu2vhMs=
github.com/pressly/goose/v3 v3.27.2 h1:FjKNzcmMdGrQlSIu5alMSmakQtJFBgtw+A0bb1p/LC8=
github.com/pressly/goose/v3 v3.27.2/go.mod h1:qWW+/8dkVtJYjJrbIpwD5xxnEJTUKvxkQ9JKQp9LaIM=
github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o=
github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg=
github.com/pressly/goose/v3 v3.27.3 h1:pIglVHjw99r4e/hDHHwbl9vfOsDMqUokfkXo6+n/RxA=
github.com/pressly/goose/v3 v3.27.3/go.mod h1:Dag+xpV6o20HR2LFY1j0q6MDwc3f7vPUFDA77R+0yGY=
github.com/prometheus/client_golang v1.24.1 h1:JnJkREXzWxUdCuPFpIWZiPispT9xVV59uiuyR2bPlnU=
github.com/prometheus/client_golang v1.24.1/go.mod h1:F+oSRECHg4sse5ucfYpYDeIv/hu68Zo0uoHKetWnzcE=
github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk=
github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE=
github.com/prometheus/common v0.67.5 h1:pIgK94WWlQt1WLwAC5j2ynLaBRDiinoAb86HZHTUGI4=
github.com/prometheus/common v0.67.5/go.mod h1:SjE/0MzDEEAyrdr5Gqc6G+sXI67maCxzaT3A2+HqjUw=
github.com/prometheus/procfs v0.20.1 h1:XwbrGOIplXW/AU3YhIhLODXMJYyC1isLFfYCsTEycfc=
github.com/prometheus/procfs v0.20.1/go.mod h1:o9EMBZGRyvDrSPH1RqdxhojkuXstoe4UlK79eF5TGGo=
github.com/prometheus/common v0.70.1 h1:1HvjP4D5oL3t8RsPlwxA9onvvStjtIHYE5XuuwOi/PY=
github.com/prometheus/common v0.70.1/go.mod h1:VdFUQDMZK3VLkurFUVhia6uys/0suUp86TJz5qbJRhc=
github.com/prometheus/procfs v0.21.1 h1:GljZCt+zSTS+NZq88cyQ1LjZ+RCHp3uVuabBWA5+OJI=
github.com/prometheus/procfs v0.21.1/go.mod h1:aB55Cww9pdSJVHk0hUf0inxWyyjPogFIjmHKYgMKmtY=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/rjeczalik/notify v0.9.3 h1:6rJAzHTGKXGj76sbRgDiDcYj/HniypXmSJo1SWakZeY=
@ -218,8 +216,8 @@ github.com/rjeczalik/notify v0.9.3/go.mod h1:gF3zSOrafR9DQEWSE8TjfI9NkooDxbyT4Ug
github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs=
github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro=
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
github.com/rogpeppe/go-internal v1.15.0 h1:D0RCU5rMAp+SpgkiNdrjfJ+LX4J1M32V2NeCY7EJ6hc=
github.com/rogpeppe/go-internal v1.15.0/go.mod h1:DrUVZyrJU+txYW5/1kwtXQSMFio52ZOxX7yM1VHvnxs=
github.com/rogpeppe/go-internal v1.16.0 h1:O9DK+vNMDVGLr2BeZqmpLeMjiMNkuXfcqntWbZV6S5g=
github.com/rogpeppe/go-internal v1.16.0/go.mod h1:DrUVZyrJU+txYW5/1kwtXQSMFio52ZOxX7yM1VHvnxs=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/sabhiram/go-gitignore v0.0.0-20210923224102-525f6e181f06 h1:OkMGxebDjyw0ULyrTYWeN0UNCCkmCWfjPnIA2W6oviI=
github.com/sabhiram/go-gitignore v0.0.0-20210923224102-525f6e181f06/go.mod h1:+ePHsJ1keEjQtpvf9HHw0f4ZeJ0TLRsxhunSI2hYJSs=
@ -227,15 +225,15 @@ github.com/sagikazarmark/locafero v0.12.0 h1:/NQhBAkUb4+fH1jivKHWusDYFjMOOKU88ee
github.com/sagikazarmark/locafero v0.12.0/go.mod h1:sZh36u/YSZ918v0Io+U9ogLYQJ9tLLBmM4eneO6WwsI=
github.com/sanity-io/litter v1.5.8 h1:uM/2lKrWdGbRXDrIq08Lh9XtVYoeGtcQxk9rtQ7+rYg=
github.com/sanity-io/litter v1.5.8/go.mod h1:9gzJgR2i4ZpjZHsKvUXIRQVk7P+yM3e+jAF7bU2UI5U=
github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 h1:KRzFb2m7YtdldCEkzs6KqmJw4nqEVZGK7IN2kJkjTuQ=
github.com/santhosh-tekuri/jsonschema/v6 v6.0.2/go.mod h1:JXeL+ps8p7/KNMjDQk3TCwPpBy0wYklyWTfbkIzdIFU=
github.com/santhosh-tekuri/jsonschema/v6 v6.0.3 h1:1EYB5IzjZawrrnELUi78f9fPu57HuXjmddZPjrls/28=
github.com/santhosh-tekuri/jsonschema/v6 v6.0.3/go.mod h1:JXeL+ps8p7/KNMjDQk3TCwPpBy0wYklyWTfbkIzdIFU=
github.com/segmentio/asm v1.2.1 h1:DTNbBqs57ioxAD4PrArqftgypG4/qNpXoJx8TVXxPR0=
github.com/segmentio/asm v1.2.1/go.mod h1:BqMnlJP91P8d+4ibuonYZw9mfnzI9HfxselHZr5aAcs=
github.com/sethvargo/go-retry v0.3.0 h1:EEt31A35QhrcRZtrYFDTBg91cqZVnFL2navjDrah2SE=
github.com/sethvargo/go-retry v0.3.0/go.mod h1:mNX17F0C/HguQMyMyJxcnU471gOZGxCLyYaFyAZraas=
github.com/sethvargo/go-retry v0.4.0 h1:9qy1OoIAxBL+gBYnkTnTnWle5wlfsXQlwRzIbbpdqPw=
github.com/sethvargo/go-retry v0.4.0/go.mod h1:tvsjdKG6xfiCx4LSiUZ06kcv38xvdVQwv8R6/VnnVWg=
github.com/sirupsen/logrus v1.4.2/go.mod h1:tLMulIdttU9McNUspp0xgXVQah82FyeX6MwdIuYE2rE=
github.com/sirupsen/logrus v1.9.4 h1:TsZE7l11zFCLZnZ+teH4Umoq5BhEIfIzfRDZ1Uzql2w=
github.com/sirupsen/logrus v1.9.4/go.mod h1:ftWc9WdOfJ0a92nsE2jF5u5ZwH8Bv2zdeOC42RjbV2g=
github.com/sirupsen/logrus v1.10.0 h1:T8MxJJXVZkfcC5zSRMRAg2F8+lxjmUCGGWPzFxO+Msc=
github.com/sirupsen/logrus v1.10.0/go.mod h1:FXZFonkDAnFozmO+5hGAFvB0Yg9/j2SIhA/QuIkP180=
github.com/smartystreets/assertions v0.0.0-20180927180507-b2de0cb4f26d h1:zE9ykElWQ6/NYmHa3jpm/yHnI4xSofP+UP6SpjHcSeM=
github.com/smartystreets/assertions v0.0.0-20180927180507-b2de0cb4f26d/go.mod h1:OnSkiWE9lh6wB0YB77sQom3nweQdgAjqCqsofrRNTgc=
github.com/smartystreets/goconvey v1.6.4 h1:fv0U8FUIMPNf1L9lnHLvLhgicrIVChEkdzIKYqbNC9s=
@ -267,8 +265,9 @@ github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/stretchr/testify v1.12.0 h1:K6Mr6jO9JICuend/5xzTM03ydSV3vdNRYAdPSukj8uI=
github.com/stretchr/testify v1.12.0/go.mod h1:bOYBZb5qJ00vPzWfIqBUZPaxK8jWiXc6d3ErP4Ca9Gw=
github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8=
github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU=
github.com/tetratelabs/wabin v0.0.0-20230304001439-f6f874872834 h1:ZF+QBjOI+tILZjBaFj3HgFonKXUcwgJ4djLb6i42S3Q=
@ -293,27 +292,28 @@ github.com/zeebo/assert v1.3.0 h1:g7C04CbJuIDKNPFHmsk4hwZDO5O+kntRxzaUoNXj+IQ=
github.com/zeebo/assert v1.3.0/go.mod h1:Pq9JiuJQpG8JLJdtkwrJESF0Foym2/D9XMU5ciN/wJ0=
github.com/zeebo/xxh3 v1.1.0 h1:s7DLGDK45Dyfg7++yxI0khrfwq9661w9EN78eP/UZVs=
github.com/zeebo/xxh3 v1.1.0/go.mod h1:IisAie1LELR4xhVinxWS5+zf1lA4p0MW4T+w+W07F5s=
go.opentelemetry.io/proto/otlp v1.10.0 h1:IQRWgT5srOCYfiWnpqUYz9CVmbO8bFmKcwYxpuCSL2g=
go.opentelemetry.io/proto/otlp v1.10.0/go.mod h1:/CV4QoCR/S9yaPj8utp3lvQPoqMtxXdzn7ozvvozVqk=
go.opentelemetry.io/proto/otlp v1.11.0 h1:5rrYs0Ykyj50sdU/JU0x8etU+LubXWb+gED6TbEdMIk=
go.opentelemetry.io/proto/otlp v1.11.0/go.mod h1:SmVizdCOAm3XBtG1g1NnOdhW6jtddT72hLMhv8VwA8E=
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0=
go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
go.yaml.in/yaml/v2 v2.4.3 h1:6gvOSjQoTB3vt1l+CU+tSyi/HOjfOjRLJ4YwYZGwRO0=
go.yaml.in/yaml/v2 v2.4.3/go.mod h1:zSxWcmIDjOzPXpjlTTbAsKokqkDNAVtZO0WOMiT90s8=
go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
go.yaml.in/yaml/v2 v2.4.4 h1:tuyd0P+2Ont/d6e2rl3be67goVK4R6deVxCUX5vyPaQ=
go.yaml.in/yaml/v2 v2.4.4/go.mod h1:gMZqIpDtDqOfM0uNfy0SkpRhvUryYH0Z6wdMYcacYXQ=
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
go.yaml.in/yaml/v3 v3.0.5 h1:N6y/pJk8buWs9NY5ERU2HSMfm+IuD/OtfdAnq6kESPw=
go.yaml.in/yaml/v3 v3.0.5/go.mod h1:HVTZu1O7/Vkt2N+BFy8Zza+lnLsABggaTM2ZpNIGuKg=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.54.0 h1:YLIA59K4fiNzHzjnZt2tUJQjQtUWfWbeHBqKtk3eScw=
golang.org/x/crypto v0.54.0/go.mod h1:KWL8ny2AZdGR2cWmzeHrp2azQPGogOv+HeQaVEXC2dk=
golang.org/x/image v0.44.0 h1:+tDekMZED9+LrtB3G5xzRggpVh9CARjZqROla3R3R+I=
golang.org/x/image v0.44.0/go.mod h1:V8K3KE9KKKE+pLpQDOeN18w9oacNSvy1tDOirTu4xtY=
golang.org/x/mod v0.38.0 h1:MECBjubtXD7yj4HrhIUcywNaGeNVUdfVnxmPajOk4yk=
golang.org/x/mod v0.38.0/go.mod h1:V6Xz0pq8TQ3dGqVQ1FVHuelZpAL0uNhSkk9ogYP3c40=
golang.org/x/crypto v0.55.0 h1:+KWHjbgOaAQ66dh/YlkZKHlz9ZUlq61AFirAR9ntP8M=
golang.org/x/crypto v0.55.0/go.mod h1:uq0V9dE/fzQuJtbnL+2EhWOE63vo164FY8xqEnV9xis=
golang.org/x/image v0.45.0 h1:FMb1nTbH5H9vF55SriQHgFw5GnNL9Jg6L25BwXKzhB0=
golang.org/x/image v0.45.0/go.mod h1:n62x/7RqlwXDvGsSU4u6IUTUf6KghUZ9Bt7cG/T9Fx4=
golang.org/x/mod v0.40.0 h1:hUv+3cXcdRHz08UmSiOob7sadHig73uo5bkXxQ/tvUs=
golang.org/x/mod v0.40.0/go.mod h1:0/weTWkPWGBikyTWAX3dkjVztMmBA5hM0DH6BElSupE=
golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks=
golang.org/x/net v0.57.0 h1:K5+3DljvIuDG9/Jv9rvyMywYNFCQ9RSUY6OOTTkT+tE=
golang.org/x/net v0.57.0/go.mod h1:KpXc8iv+r3XplLAG/f7Jsf9RPszJzdR0f58q9vGOuEU=
golang.org/x/net v0.58.0 h1:ynWG7rqYi4ccpTEuPZ2QGWHktVEM9DMCj9yzDE0Q7To=
golang.org/x/net v0.58.0/go.mod h1:YwCddHnFlT7eLQqVprV19OnhLGtc5xOKgE0RyqgfWAU=
golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek=
golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.0.0-20180926160741-c2ed4eda69e7/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
@ -322,26 +322,26 @@ golang.org/x/sys v0.0.0-20190422165155-953cdadca894/go.mod h1:h1NjWce9XRLGQEsW7w
golang.org/x/sys v0.0.0-20220615213510-4f61da869c0c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/telemetry v0.0.0-20260708182218-49f421fb7959 h1:RJhm5l6Fo4rmEIcndxDllNhhf/fAx8qIm4t6A7vpm2A=
golang.org/x/telemetry v0.0.0-20260708182218-49f421fb7959/go.mod h1:LV7u5Oco+Z/g6XI7PqN+EUUUGGkEcmB1uj2ceI0fOVg=
golang.org/x/telemetry v0.0.0-20260811182544-a038080d80e5 h1:ZUSxONxc981v7AW7QUg+I9WwZzSTTJ019ENBYr5pV/Q=
golang.org/x/telemetry v0.0.0-20260811182544-a038080d80e5/go.mod h1:LVehoXe41cL5SCVQilsV7Gg6BNG+Js6P9PhSbYTIUkQ=
golang.org/x/term v0.45.0 h1:NwWyBmoJCbfTHpxrWoZ9C6/VxOf7ic219I8xZZFdrf0=
golang.org/x/term v0.45.0/go.mod h1:9aqxs0blBcrm/n0L9QW0aRVD+ktan8ssZromtqJC43w=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
golang.org/x/text v0.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs=
golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY=
golang.org/x/text v0.41.0 h1:vz/seA0lnX87Othu2f/0L24RcgrXD9/YFTSuGjj3rH8=
golang.org/x/text v0.41.0/go.mod h1:jvf1O8ajNzZqhSrQBPbutR/EB83Cc0CFrezNQIwbb5M=
golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U=
golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20190328211700-ab21143f2384/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
golang.org/x/tools v0.48.0 h1:3+hClM1aLL5mjMKm5ovokw9epgRXPuu2tILgismM6RE=
golang.org/x/tools v0.48.0/go.mod h1:08xX0orndb/F7jJxGDicx061tyd5pcMto75YMAXr6lk=
golang.org/x/tools v0.49.0 h1:3NI7VXzL9+1WZD52Dx2ttoPwD5DWrFGpl9mFZDlmisI=
golang.org/x/tools v0.49.0/go.mod h1:SJNXV9DBKT0UbdttsQjbfJlAE/q+y36++zo3uL3N0Oo=
google.golang.org/appengine v1.6.5/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc=
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
google.golang.org/protobuf v1.36.12 h1:pJOKDDOyeXErUroCihFAd5LQuwXBSpVnKGrj5o/fwxc=
google.golang.org/protobuf v1.36.12/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127 h1:qIbj1fsPNlZgppZ+VLlY7N33q108Sa+fhmuc+sWQYwY=
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/ini.v1 v1.67.3 h1:iM9Lhz5MRSGhHVGGwCuzG9KO8PoirCXj/m/qTmOJJQw=
gopkg.in/ini.v1 v1.67.3/go.mod h1:x/cyOwCgZqOkJoDIJ3c1KNHMo10+nLGAhh+kn3Zizss=
gopkg.in/natefinch/npipe.v2 v2.0.0-20160621034901-c1b8fa8bdcce h1:+JknDZhAj8YMt7GC73Ei8pv4MzjDUNPHgQWJdtMAaDU=
@ -350,11 +350,11 @@ gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
modernc.org/libc v1.73.4 h1:+ra4Ui8ngyt8HDcO1FTDPWlkAh6yOdaO2yAoh8MddQA=
modernc.org/libc v1.73.4/go.mod h1:DXZ3eO8qMCNn2SnmTNCiC71nJ9Rcq3PsnpU6Vc4rWK8=
modernc.org/libc v1.74.3 h1:a4J+Z8aVaxPyjyxRAdJzw246PqpcFGvVPnfT/AuM5Ws=
modernc.org/libc v1.74.3/go.mod h1:4H7h/MJ8wnjL8RAbp9v3OXgnk22X7MouHIhDbvP3gj4=
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
modernc.org/sqlite v1.53.0 h1:20WG8N9q4ji/dEqGk4uiI0c6OPjSeLTNYGFCc3+7c1M=
modernc.org/sqlite v1.53.0/go.mod h1:xoEpOIpGrgT48H5iiyt/YXPCZPEzlfmfFwtk8Lklw8s=
modernc.org/sqlite v1.54.0 h1:JCxR4qwkJvOaqAoYcgDoO25Nc+ROg6EJ2LfBVzdrgog=
modernc.org/sqlite v1.54.0/go.mod h1:4ntCLuNmnH8+GNqjka1wNg7KJd5/Hi5FYp8K+XQ7GZw=

View File

@ -144,6 +144,9 @@ type AlbumRepository interface {
Get(id string) (*Album, error)
GetAll(...QueryOptions) (Albums, error)
GetAllIDs(...QueryOptions) ([]string, error)
// GetSoleAlbumArtistIDsInSubtrees returns the sole album artists of the albums with folders in
// any of the given library-relative subtrees.
GetSoleAlbumArtistIDsInSubtrees(lib Library, paths ...string) ([]string, error)
GetCursor(...QueryOptions) (AlbumCursor, error)
GetYears(libraryIDs ...int) ([]int, error)

View File

@ -119,6 +119,8 @@ type ArtworkRepository interface {
}
type ArtworkQueueRepository interface {
// Get returns the pending row for an item, or ErrNotFound when it is not queued.
Get(kind Kind, id, imageType string) (*ArtworkQueueItem, error)
// Enqueue upserts; an existing row keeps the higher priority and has its retry_at reset.
Enqueue(items ...ArtworkQueueItem) error
// EnqueuePreservingBackoff upserts like Enqueue but preserves an existing row's retry_at, so a
@ -130,6 +132,14 @@ type ArtworkQueueRepository interface {
EnqueueAllMissing(kind Kind, priority int) (int64, error)
// EnqueueIfMissing inserts only for items with no item_artwork row yet.
EnqueueIfMissing(items ...ArtworkQueueItem) error
// CountBySource reports how many items of a kind currently resolve from the given sources.
// An empty sources slice means every source; "" matches absent state.
CountBySource(kind Kind, sources []string) (int64, error)
// SourcesInUse lists the distinct sources items of a kind currently resolve from, "" included.
SourcesInUse(kind Kind) ([]string, error)
// EnqueueBySource inserts queue rows for items of a kind whose current source matches.
// It does not clear existing artwork state: the current image stays until it is replaced.
EnqueueBySource(kind Kind, sources []string, priority int) (int64, error)
// DequeueBatch returns up to n items with retry_at <= now, priority desc, enqueued_at asc.
// Restricted to the given kinds when any are passed, so one kind cannot block another's drain.
DequeueBatch(n int, kinds ...string) ([]ArtworkQueueItem, error)
@ -139,6 +149,22 @@ type ArtworkQueueRepository interface {
// DeleteIfUnchanged deletes only while retry_at still matches, sparing a concurrent re-enqueue.
DeleteIfUnchanged(kind, id, imageType string, retryAt time.Time) error
Count() (int64, error)
// CountByKindAndPriority reports the pending queue rows grouped by kind and priority.
CountByKindAndPriority() ([]ArtworkQueueStat, error)
// CountAbsent reports the absent states of a kind, and how many of those EnqueueStaleAbsent
// would pick up at the given cutoff.
CountAbsent(kind Kind, attemptedBefore time.Time) (ArtworkAbsentStat, error)
// PurgeDangling removes queue rows whose entity no longer exists.
PurgeDangling() (int64, error)
}
type ArtworkQueueStat struct {
ItemKind string
Priority int
Count int64
}
type ArtworkAbsentStat struct {
Total int64
Stale int64
}

View File

@ -76,8 +76,10 @@ func NewFolder(lib Library, folderPath string) *Folder {
type FolderCursor iter.Seq2[Folder, error]
type FolderUpdateInfo struct {
UpdatedAt time.Time
Hash string
UpdatedAt time.Time
Hash string
ImageFiles []string
ImagesUpdatedAt time.Time
}
type FolderRepository interface {

View File

@ -21,6 +21,12 @@ var (
timeRegex = regexp.MustCompile(timeRegexString)
lrcIdRegex = regexp.MustCompile(`\[(ar|ti|offset|lang):([^]]+)]`)
// Non-standard background-vocal tag: [bg: <mm:ss.mm>word...] attaches to the preceding line
bgTagRegex = regexp.MustCompile(`^\[bg:(.*)]$`)
// Any other [name:value] line is a tag we don't support (e.g. [al:], [by:]) and must be skipped whole
unknownTagRegex = regexp.MustCompile(`^\[[A-Za-z][^]:]*:[^]]*]`)
// Enhanced LRC: inline word-level timing markers like <00:12.34>
enhancedLRCTimeString = `<([0-9]{1,2}:)?([0-9]{1,2}):([0-9]{1,2})(\.[0-9]{1,3})?>`
enhancedLRCRegex = regexp.MustCompile(enhancedLRCTimeString)
@ -40,7 +46,26 @@ func parseLRC(language, text string) (*Lyrics, error) {
priorLine := ""
validLine := false
repeated := false
hasBg := false
var timestamps []int64
var pendingBg []string
flushLine := func() {
value, cues := parseEnhancedLine(priorLine)
var merged bool
value, cues, merged = mergeBgLayers(value, cues, pendingBg)
hasBg = hasBg || merged
for idx := range timestamps {
startCopy := timestamps[idx]
structuredLines = append(structuredLines, Line{
Start: &startCopy,
Value: value,
Cue: shiftELRCCues(cues, timestamps[idx]-timestamps[0]),
})
}
timestamps = nil
pendingBg = nil
}
for _, line := range lines {
line := strings.TrimSpace(line)
@ -77,6 +102,17 @@ func parseLRC(language, text string) (*Lyrics, error) {
continue
}
if bgMatch := bgTagRegex.FindStringSubmatch(line); bgMatch != nil {
if validLine {
pendingBg = append(pendingBg, bgMatch[1])
}
continue
}
if unknownTagRegex.MatchString(line) {
continue
}
times := timeRegex.FindAllStringSubmatchIndex(line, -1)
if len(times) > 1 {
repeated = true
@ -92,16 +128,7 @@ func parseLRC(language, text string) (*Lyrics, error) {
}
if validLine {
value, baseCues := parseEnhancedLine(priorLine)
for idx := range timestamps {
startCopy := timestamps[idx]
structuredLines = append(structuredLines, Line{
Start: &startCopy,
Value: value,
Cue: shiftELRCCues(baseCues, timestamps[idx]-timestamps[0]),
})
}
timestamps = nil
flushLine()
}
end := 0
@ -143,15 +170,11 @@ func parseLRC(language, text string) (*Lyrics, error) {
}
if validLine {
value, baseCues := parseEnhancedLine(priorLine)
for idx := range timestamps {
startCopy := timestamps[idx]
structuredLines = append(structuredLines, Line{
Start: &startCopy,
Value: value,
Cue: shiftELRCCues(baseCues, timestamps[idx]-timestamps[0]),
})
}
flushLine()
}
if hasBg {
fillMainAgentID(structuredLines)
}
// If there are repeated values, there is no guarantee that they are in order
@ -170,9 +193,51 @@ func parseLRC(language, text string) (*Lyrics, error) {
Offset: offset,
Synced: synced,
}
if hasBg {
lyrics.Agents = []Agent{
{ID: "main", Role: "main"},
{ID: backgroundAgentID("main"), Role: "bg"},
}
}
return &lyrics, nil
}
// mergeBgLayers appends parsed [bg:] content to a line's value and cues as a
// background agent layer, mirroring the TTML background-vocal representation.
func mergeBgLayers(value string, cues []Cue, bgLines []string) (string, []Cue, bool) {
merged := false
for _, bg := range bgLines {
bgValue, bgCues := parseEnhancedLine(bg)
if len(bgCues) == 0 {
continue
}
offset := 0
if value != "" {
offset = len(value) + 1
value += " "
}
value += bgValue
for _, c := range bgCues {
c.ByteStart += offset
c.ByteEnd += offset
c.AgentID = backgroundAgentID("main")
cues = append(cues, c)
}
merged = true
}
return value, cues, merged
}
func fillMainAgentID(lines []Line) {
for i := range lines {
for j := range lines[i].Cue {
if lines[i].Cue[j].AgentID == "" {
lines[i].Cue[j].AgentID = "main"
}
}
}
}
// parseEnhancedLine extracts word-level timing cues from Enhanced LRC inline markers
// and computes UTF-8 byte offsets against the final stripped line value.
func parseEnhancedLine(text string) (string, []Cue) {

View File

@ -147,6 +147,49 @@ var _ = Describe("parseLRC", func() {
}))
})
It("should skip unknown tag lines instead of merging them into the prior line", func() {
lyrics, err := parseLRC("xxx", "[00:01.00]<00:01.00>Some <00:01.50>lyrics\n[by: someone]\n[al: An album]\n[00:03.00]Next")
Expect(err).ToNot(HaveOccurred())
Expect(lyrics.Synced).To(BeTrue())
Expect(lyrics.Line).To(HaveLen(2))
t1000, t1500, t3000 := int64(1000), int64(1500), int64(3000)
Expect(lyrics.Line[0].Value).To(Equal("Some lyrics"))
Expect(lyrics.Line[0].Cue).To(Equal([]Cue{
{Start: &t1000, End: &t1500, Value: "Some ", ByteStart: 0, ByteEnd: 4},
{Start: &t1500, End: &t3000, Value: "lyrics", ByteStart: 5, ByteEnd: 10},
}))
Expect(lyrics.Line[1].Value).To(Equal("Next"))
})
It("should attach [bg:] background vocals to the previous line as a bg agent layer", func() {
lyrics, err := parseLRC("xxx", "[00:52.00]<00:52.00>Main <00:52.50>line\n[bg: <00:53.00>Okay<00:53.50>]\n[00:55.00]Next")
Expect(err).ToNot(HaveOccurred())
Expect(lyrics.Agents).To(Equal([]Agent{
{ID: "main", Role: "main"},
{ID: "__nd_bg__|main", Role: "bg"},
}))
Expect(lyrics.Line).To(HaveLen(2))
line := lyrics.Line[0]
Expect(line.Value).To(Equal("Main line Okay"))
Expect(line.Cue).To(Equal([]Cue{
{Start: new(int64(52000)), End: new(int64(52500)), Value: "Main ", ByteStart: 0, ByteEnd: 4, AgentID: "main"},
{Start: new(int64(52500)), End: new(int64(55000)), Value: "line", ByteStart: 5, ByteEnd: 8, AgentID: "main"},
{Start: new(int64(53000)), End: new(int64(53500)), Value: "Okay", ByteStart: 10, ByteEnd: 13, AgentID: "__nd_bg__|main"},
}))
Expect(lyrics.Line[1].Value).To(Equal("Next"))
})
It("should skip a [bg:] line that has no inline timing", func() {
lyrics, err := parseLRC("xxx", "[00:01.00]Hi\n[bg: untimed]\n[00:03.00]Next")
Expect(err).ToNot(HaveOccurred())
Expect(lyrics.Agents).To(BeNil())
Expect(lyrics.Line).To(HaveLen(2))
Expect(lyrics.Line[0].Value).To(Equal("Hi"))
Expect(lyrics.Line[1].Value).To(Equal("Next"))
})
It("should handle mixed Enhanced and plain LRC lines", func() {
lyrics, err := parseLRC("xxx", "[00:01.00]<00:01.00>Some <00:01.50>lyrics\n[00:03.00]Plain line\n[00:05.00]<00:05.00>More <00:05.50>words")
Expect(err).ToNot(HaveOccurred())

View File

@ -15,6 +15,7 @@ import (
"github.com/gohugoio/hashstructure"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/consts"
"github.com/navidrome/navidrome/model/criteria"
"github.com/navidrome/navidrome/utils"
"github.com/navidrome/navidrome/utils/gg"
"github.com/navidrome/navidrome/utils/number"
@ -548,9 +549,15 @@ type MediaFileRepository interface {
// filters as GetAll. Sort/Order are ignored.
GetRandom(options ...QueryOptions) (MediaFiles, error)
GetAllByTags(tag TagName, values []string, options ...QueryOptions) (MediaFiles, error)
// MatchesCriteria reports whether the media file matches the criteria's rule
// expression, using the logged user's annotations. Limit and offset are ignored.
MatchesCriteria(id string, c criteria.Criteria) (bool, error)
GetCursor(options ...QueryOptions) (MediaFileCursor, error)
// GetAllIDs returns just the media_file IDs for the same row set as GetAll.
GetAllIDs(options ...QueryOptions) ([]string, error)
// GetAlbumIDsByFolder returns the distinct IDs of albums with non-missing tracks in the given
// folders or their direct children.
GetAlbumIDsByFolder(lib Library, folderIDs ...string) ([]string, error)
// GetCursorWithArtwork streams like GetCursor, hydrated, so callers that render images don't
// pay the scanner's per-row cost; it uses the same id pre-pass as the other cursors.
GetCursorWithArtwork(options ...QueryOptions) (MediaFileCursor, error)

View File

@ -14,6 +14,8 @@ type User struct {
LastAccessAt *time.Time `structs:"last_access_at" json:"lastAccessAt"`
CreatedAt time.Time `structs:"created_at" json:"createdAt"`
UpdatedAt time.Time `structs:"updated_at" json:"updatedAt"`
// Smart-playlist criteria JSON; matching songs are not sent to external scrobblers
ScrobbleFilter string `structs:"scrobble_filter" json:"scrobbleFilter"`
// Library associations (many-to-many relationship)
Libraries Libraries `structs:"-" json:"libraries,omitempty"`

View File

@ -270,6 +270,39 @@ func (r *albumRepository) GetAllIDs(options ...model.QueryOptions) ([]string, er
return ids, err
}
// soleAlbumArtistFilter matches albums with exactly one album artist. The artist artwork
// resolver and the scanner's image-change enqueue must select the same albums.
var soleAlbumArtistFilter = Eq{"json_array_length(participants, '$.albumartist')": 1}
// SoleAlbumArtistFilter matches the albums where the given artist is the only album artist.
// Matches by album-artist participation, not the deprecated album_artist_id column.
func SoleAlbumArtistFilter(artistID string) Sqlizer {
return And{ParticipantIDFilter("album", artistID, model.RoleAlbumArtist), soleAlbumArtistFilter}
}
// GetSoleAlbumArtistIDsInSubtrees matches albums by their own folder_ids, which is the resolver's
// notion of an album's folders.
func (r *albumRepository) GetSoleAlbumArtistIDsInSubtrees(lib model.Library, paths ...string) ([]string, error) {
if len(paths) == 0 {
return nil, nil
}
ids := []string{}
// Repeated IDs across chunks are fine: the queue upserts by PK.
for chunk := range slices.Chunk(paths, subtreePathChunkSize) {
inSubtree := Exists("json_each(album.folder_ids) je join folder on folder.id = je.value",
folderSubtreeFilter(lib, chunk))
// Sole album artist, so participants[0] is the only one.
sq := Select("distinct json_extract(participants, '$.albumartist[0].id')").From("album").
Where(And{soleAlbumArtistFilter, inSubtree})
var chunkIDs []string
if err := r.queryAllSlice(sq, &chunkIDs); err != nil {
return nil, err
}
ids = append(ids, chunkIDs...)
}
return ids, nil
}
func (r *albumRepository) GetCursor(options ...model.QueryOptions) (model.AlbumCursor, error) {
ids, err := r.GetAllIDs(options...)
if err != nil {
@ -296,8 +329,11 @@ func (r *albumRepository) GetYears(libraryIDs ...int) ([]int, error) {
}
func (r *albumRepository) CopyAttributes(fromID, toID string, columns ...string) error {
// Cast values to text so go-sqlite3 does not decode datetime columns as time.Time
// and reformat them as RFC3339 when written back.
sel := slice.Map(columns, func(c string) string { return fmt.Sprintf("cast(%[1]s as text) as %[1]s", c) })
var from dbx.NullStringMap
err := r.queryOne(Select(columns...).From(r.tableName).Where(Eq{"id": fromID}), &from)
err := r.queryOne(Select(sel...).From(r.tableName).Where(Eq{"id": fromID}), &from)
if err != nil {
return fmt.Errorf("getting album to copy fields from: %w", err)
}

View File

@ -1,6 +1,7 @@
package persistence
import (
"context"
"errors"
"fmt"
"sort"
@ -18,11 +19,22 @@ import (
. "github.com/onsi/gomega"
)
// rawColumn returns a column exactly as stored, bypassing go-sqlite3's decoding of
// `datetime` columns into time.Time.
func rawColumn(r sqlRepository, id, column string) string {
var res struct{ Value string }
sel := squirrel.Select("cast(" + column + " as text) as value").
From(r.tableName).Where(squirrel.Eq{"id": id})
ExpectWithOffset(1, r.queryOne(sel, &res)).To(Succeed())
return res.Value
}
var _ = Describe("AlbumRepository", func() {
var albumRepo *albumRepository
var ctx context.Context
BeforeEach(func() {
ctx := request.WithUser(GinkgoT().Context(), model.User{ID: "userid", UserName: "johndoe"})
ctx = request.WithUser(GinkgoT().Context(), model.User{ID: "userid", UserName: "johndoe"})
albumRepo = NewAlbumRepository(ctx, GetDBXBuilder()).(*albumRepository)
})
@ -67,6 +79,22 @@ var _ = Describe("AlbumRepository", func() {
Expect(err).ToNot(HaveOccurred())
Expect(got.CreatedAt).To(BeTemporally("~", dstTime, time.Second))
})
It("returns not found and leaves destination untouched when source does not exist", func() {
err := albumRepo.CopyAttributes("copy-missing", "copy-dst", "created_at")
Expect(errors.Is(err, model.ErrNotFound)).To(BeTrue())
got, getErr := albumRepo.Get("copy-dst")
Expect(getErr).ToNot(HaveOccurred())
Expect(got.CreatedAt).To(BeTemporally("~", dstTime, time.Second))
})
It("keeps the copied created_at in the driver's space-separated format", func() {
// Copying through a Go string would rewrite it as RFC3339 ("2020-01-02T03:04:05Z"),
// which string-sorts above every space-format timestamp and pins the album to the
// top of "Recently Added".
Expect(albumRepo.CopyAttributes("copy-src", "copy-dst", "created_at")).To(Succeed())
Expect(rawColumn(albumRepo.sqlRepository, "copy-dst", "created_at")).
To(Equal(rawColumn(albumRepo.sqlRepository, "copy-src", "created_at")))
Expect(rawColumn(albumRepo.sqlRepository, "copy-dst", "created_at")).ToNot(ContainSubstring("T"))
})
})
Describe("GetCursor", func() {
@ -96,6 +124,77 @@ var _ = Describe("AlbumRepository", func() {
})
})
Describe("GetSoleAlbumArtistIDsInSubtrees", func() {
It("returns the sole album artists of albums with folders in the subtree", func() {
folderRepo := newFolderRepository(ctx, GetDBXBuilder())
lib, err := NewLibraryRepository(ctx, GetDBXBuilder()).Get(1)
Expect(err).ToNot(HaveOccurred())
inTree := model.NewFolder(*lib, "SubtreeAlbums/Artist")
outTree := model.NewFolder(*lib, "OtherTree/Artist")
Expect(folderRepo.Put(inTree)).To(Succeed())
Expect(folderRepo.Put(outTree)).To(Succeed())
// album_artist_id is deliberately wrong: the artist must come from participants
inAl := model.Album{ID: "subtree-in-al", Name: "In", LibraryID: 1, AlbumArtistID: "999", FolderIDs: []string{inTree.ID},
Participants: model.Participants{model.RoleAlbumArtist: []model.Participant{{Artist: artistKraftwerk}}}}
outAl := model.Album{ID: "subtree-out-al", Name: "Out", LibraryID: 1, AlbumArtistID: "3", FolderIDs: []string{outTree.ID},
Participants: model.Participants{model.RoleAlbumArtist: []model.Participant{{Artist: artistBeatles}}}}
duoAl := model.Album{ID: "subtree-duo-al", Name: "Duo", LibraryID: 1, AlbumArtistID: "5", FolderIDs: []string{inTree.ID},
Participants: model.Participants{model.RoleAlbumArtist: []model.Participant{{Artist: artistPunctuation}, {Artist: artistBeatles}}}}
for _, al := range []model.Album{inAl, outAl, duoAl} {
Expect(albumRepo.Put(&al)).To(Succeed())
}
DeferCleanup(func() {
_, _ = GetDBXBuilder().NewQuery("DELETE FROM album WHERE id LIKE 'subtree-%'").Execute()
_, _ = GetDBXBuilder().NewQuery("DELETE FROM folder WHERE path LIKE 'SubtreeAlbums%' OR path LIKE 'OtherTree%'").Execute()
})
ids, err := albumRepo.GetSoleAlbumArtistIDsInSubtrees(*lib, "SubtreeAlbums")
Expect(err).ToNot(HaveOccurred())
Expect(ids).To(ConsistOf("2")) // sole artist in the subtree; the duo and the outside album are excluded
})
It("stays under SQLite's expression tree depth limit with many paths", func() {
lib, err := NewLibraryRepository(ctx, GetDBXBuilder()).Get(1)
Expect(err).ToNot(HaveOccurred())
paths := make([]string, 200)
for i := range paths {
paths[i] = fmt.Sprintf("DepthProbe/Folder%d", i)
}
_, err = albumRepo.GetSoleAlbumArtistIDsInSubtrees(*lib, paths...)
Expect(err).ToNot(HaveOccurred())
})
It("returns nothing when given no paths", func() {
lib, err := NewLibraryRepository(ctx, GetDBXBuilder()).Get(1)
Expect(err).ToNot(HaveOccurred())
ids, err := albumRepo.GetSoleAlbumArtistIDsInSubtrees(*lib)
Expect(err).ToNot(HaveOccurred())
Expect(ids).To(BeEmpty())
})
})
Describe("SoleAlbumArtistFilter", func() {
It("matches only albums where the artist is the sole album artist", func() {
// album_artist_id is deliberately wrong: matching must come from participation
sole := model.Album{ID: "sole-artist-al", Name: "Sole", LibraryID: 1, AlbumArtistID: "999",
Participants: model.Participants{model.RoleAlbumArtist: []model.Participant{{Artist: artistKraftwerk}}}}
duo := model.Album{ID: "duo-artist-al", Name: "Duo", LibraryID: 1, AlbumArtistID: "999",
Participants: model.Participants{model.RoleAlbumArtist: []model.Participant{{Artist: artistKraftwerk}, {Artist: artistBeatles}}}}
Expect(albumRepo.Put(&sole)).To(Succeed())
Expect(albumRepo.Put(&duo)).To(Succeed())
DeferCleanup(func() {
_, _ = GetDBXBuilder().NewQuery("DELETE FROM album WHERE id IN ('sole-artist-al', 'duo-artist-al')").Execute()
})
als, err := albumRepo.GetAll(model.QueryOptions{Filters: SoleAlbumArtistFilter("2")})
Expect(err).ToNot(HaveOccurred())
Expect(als).To(HaveLen(1))
Expect(als[0].ID).To(Equal(sole.ID))
})
})
Describe("GetAll", func() {
var GetAll = func(opts ...model.QueryOptions) (model.Albums, error) {
albums, err := albumRepo.GetAll(opts...)

View File

@ -10,6 +10,7 @@ import (
. "github.com/Masterminds/squirrel"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/utils/slice"
"github.com/pocketbase/dbx"
)
@ -31,6 +32,16 @@ func NewArtworkQueueRepository(ctx context.Context, db dbx.Builder) model.Artwor
return r
}
func (r *artworkQueueRepository) Get(kind model.Kind, id, imageType string) (*model.ArtworkQueueItem, error) {
var res model.ArtworkQueueItem
err := r.queryOne(Select("*").From(r.tableName).
Where(Eq{"item_kind": kind.Prefix(), "item_id": id, "image_type": imageType}), &res)
if err != nil {
return nil, err
}
return &res, nil
}
// Enqueue also resets enqueued_at, so a fresh request does not inherit an old row's spent retry budget.
func (r *artworkQueueRepository) Enqueue(items ...model.ArtworkQueueItem) error {
return r.enqueue(`ON CONFLICT (item_kind, item_id, image_type) DO UPDATE SET
@ -87,12 +98,49 @@ func (r *artworkQueueRepository) EnqueueIfMissing(items ...model.ArtworkQueueIte
return nil
}
// insertIfNotQueued inserts the rows selected by the given SQL, optionally prefixed by a CTE. DO NOTHING is
// deliberate: a recheck must not bump the priority or retry_at of an already-queued item.
// DO NOTHING is deliberate: a recheck must not bump the priority or retry_at of an already-queued item.
const skipIfQueued = ` ON CONFLICT (item_kind, item_id, image_type) DO NOTHING`
// insertIfNotQueued inserts the rows selected by the given SQL, optionally prefixed by a CTE.
func (r *artworkQueueRepository) insertIfNotQueued(with, sql string, args ...any) (int64, error) {
return r.executeSQL(Expr(with+`INSERT INTO `+r.tableName+
` (`+strings.Join(enqueueColumns, ", ")+`) `+sql+
` ON CONFLICT (item_kind, item_id, image_type) DO NOTHING`, args...))
` (`+strings.Join(enqueueColumns, ", ")+`) `+sql+skipIfQueued, args...))
}
// artworkSourceFilter selects item_artwork rows of a kind; no sources means every source, "" the absent state.
func artworkSourceFilter(kind model.Kind, sources []string) Sqlizer {
f := And{Eq{"item_kind": kind.Prefix()}}
if len(sources) > 0 {
f = append(f, Eq{"source": sources})
}
return f
}
func (r *artworkQueueRepository) CountBySource(kind model.Kind, sources []string) (int64, error) {
var res struct{ Count int64 }
err := r.queryOne(Select("count(*) as count").From(itemArtworkTable).
Where(artworkSourceFilter(kind, sources)), &res)
return res.Count, err
}
func (r *artworkQueueRepository) SourcesInUse(kind model.Kind) ([]string, error) {
var res []struct{ Source string }
err := r.queryAll(Select("distinct source").From(itemArtworkTable).
Where(Eq{"item_kind": kind.Prefix()}), &res)
if err != nil {
return nil, err
}
return slice.Map(res, func(s struct{ Source string }) string { return s.Source }), nil
}
// EnqueueBySource deliberately leaves item_artwork alone: clearing state in bulk would blank the
// library's artwork until every item is resolved again.
func (r *artworkQueueRepository) EnqueueBySource(kind model.Kind, sources []string, priority int) (int64, error) {
now := time.Now()
sel := Select("item_kind", "item_id", "image_type").
Column(Expr("?", priority)).Column("0").Column(Expr("?", now)).Column(Expr("?", now)).
From(itemArtworkTable).Where(artworkSourceFilter(kind, sources))
return r.executeSQL(Insert(r.tableName).Columns(enqueueColumns...).Select(sel).Suffix(skipIfQueued))
}
func (r *artworkQueueRepository) enqueue(conflict string, items []model.ArtworkQueueItem) error {
@ -146,4 +194,20 @@ func (r *artworkQueueRepository) Count() (int64, error) {
return res.Count, err
}
func (r *artworkQueueRepository) CountByKindAndPriority() ([]model.ArtworkQueueStat, error) {
var res []model.ArtworkQueueStat
err := r.queryAll(Select("item_kind", "priority", "count(*) as count").From(r.tableName).
GroupBy("item_kind", "priority").OrderBy("item_kind", "priority desc"), &res)
return res, err
}
// CountAbsent matches EnqueueStaleAbsent on hash, so the stale count is what a recheck would queue.
func (r *artworkQueueRepository) CountAbsent(kind model.Kind, attemptedBefore time.Time) (model.ArtworkAbsentStat, error) {
var res model.ArtworkAbsentStat
err := r.queryOne(Select("count(*) as total").
Column(Expr("coalesce(sum(attempted_at < ?), 0) as stale", attemptedBefore)).
From(itemArtworkTable).Where(Eq{"item_kind": kind.Prefix(), "hash": ""}), &res)
return res, err
}
var _ model.ArtworkQueueRepository = (*artworkQueueRepository)(nil)

View File

@ -54,6 +54,22 @@ var _ = Describe("ArtworkQueueRepository", func() {
Expect(got[0].ItemID).To(Equal("high"))
})
It("Get returns a queued row, including one still backing off", func() {
Expect(repo.Enqueue(item("ar", "g1", model.ArtworkPriorityScan))).To(Succeed())
backOff("ar", "g1", time.Now().Add(time.Hour))
got, err := repo.Get(model.KindArtistArtwork, "g1", model.ImageTypePrimary)
Expect(err).ToNot(HaveOccurred())
Expect(got.Priority).To(Equal(model.ArtworkPriorityScan))
Expect(got.Attempts).To(Equal(1))
Expect(got.RetryAt).To(BeTemporally(">", time.Now()))
})
It("Get reports ErrNotFound when the item is not queued", func() {
_, err := repo.Get(model.KindArtistArtwork, "nope", model.ImageTypePrimary)
Expect(err).To(MatchError(model.ErrNotFound))
})
It("keeps the higher priority on duplicate enqueue", func() {
Expect(repo.Enqueue(item("al", "a1", model.ArtworkPriorityBump))).To(Succeed())
Expect(repo.Enqueue(item("al", "a1", model.ArtworkPriorityBackfill))).To(Succeed())
@ -265,6 +281,89 @@ var _ = Describe("ArtworkQueueRepository", func() {
Expect(got[0].Priority).To(Equal(model.ArtworkPriorityBump), "the existing priority must survive")
})
Describe("EnqueueBySource", func() {
BeforeEach(func() {
artRepo := NewArtworkRepository(context.Background(), GetDBXBuilder())
for _, ia := range []model.ItemArtwork{
{ItemKind: "ar", ItemID: "ar1", ImageType: model.ImageTypePrimary, Hash: "h1", Source: "external:deezer"},
{ItemKind: "ar", ItemID: "ar2", ImageType: model.ImageTypePrimary, Hash: "h2", Source: "external:lastfm"},
{ItemKind: "ar", ItemID: "ar3", ImageType: model.ImageTypePrimary, Hash: "", Source: ""},
{ItemKind: "al", ItemID: "al1", ImageType: model.ImageTypePrimary, Hash: "h4", Source: "external:deezer"},
} {
Expect(artRepo.PutItemArtwork(&ia)).To(Succeed())
}
})
It("enqueues only the matching source within the kind", func() {
n, err := repo.EnqueueBySource(model.KindArtistArtwork, []string{"external:deezer"}, model.ArtworkPriorityRecheck)
Expect(err).ToNot(HaveOccurred())
Expect(n).To(Equal(int64(1)), "al1 is a different kind and must not be touched")
got, err := repo.DequeueBatch(10)
Expect(err).ToNot(HaveOccurred())
Expect(slice.Map(got, func(it model.ArtworkQueueItem) string { return it.ItemID })).To(ConsistOf("ar1"))
})
It("treats the empty source as absent", func() {
n, err := repo.EnqueueBySource(model.KindArtistArtwork, []string{""}, model.ArtworkPriorityRecheck)
Expect(err).ToNot(HaveOccurred())
Expect(n).To(Equal(int64(1)))
got, _ := repo.DequeueBatch(10)
Expect(slice.Map(got, func(it model.ArtworkQueueItem) string { return it.ItemID })).To(ConsistOf("ar3"))
})
It("enqueues every source when none is given", func() {
n, err := repo.EnqueueBySource(model.KindArtistArtwork, nil, model.ArtworkPriorityRecheck)
Expect(err).ToNot(HaveOccurred())
Expect(n).To(Equal(int64(3)))
})
It("leaves the current artwork state in place", func() {
_, err := repo.EnqueueBySource(model.KindArtistArtwork, []string{"external:deezer"}, model.ArtworkPriorityRecheck)
Expect(err).ToNot(HaveOccurred())
artRepo := NewArtworkRepository(context.Background(), GetDBXBuilder())
ia, err := artRepo.GetItemArtwork(model.KindArtistArtwork, "ar1", model.ImageTypePrimary)
Expect(err).ToNot(HaveOccurred())
Expect(ia.Hash).To(Equal("h1"), "the current image must survive until it is replaced")
Expect(ia.Source).To(Equal("external:deezer"))
})
It("does not disturb an already-queued row", func() {
Expect(repo.Enqueue(item("ar", "ar1", model.ArtworkPriorityBump))).To(Succeed())
n, err := repo.EnqueueBySource(model.KindArtistArtwork, []string{"external:deezer"}, model.ArtworkPriorityRecheck)
Expect(err).ToNot(HaveOccurred())
Expect(n).To(BeZero())
got, _ := repo.DequeueBatch(10)
Expect(got).To(HaveLen(1))
Expect(got[0].Priority).To(Equal(model.ArtworkPriorityBump))
})
It("counts without enqueueing", func() {
n, err := repo.CountBySource(model.KindArtistArtwork, []string{"external:deezer"})
Expect(err).ToNot(HaveOccurred())
Expect(n).To(Equal(int64(1)))
queued, err := repo.Count()
Expect(err).ToNot(HaveOccurred())
Expect(queued).To(BeZero(), "CountBySource must not enqueue")
})
It("counts the absent source and every source", func() {
Expect(repo.CountBySource(model.KindArtistArtwork, []string{""})).To(Equal(int64(1)))
Expect(repo.CountBySource(model.KindArtistArtwork, nil)).To(Equal(int64(3)))
})
It("lists the distinct sources in use by a kind", func() {
Expect(repo.SourcesInUse(model.KindArtistArtwork)).To(ConsistOf("", "external:deezer", "external:lastfm"))
Expect(repo.SourcesInUse(model.KindAlbumArtwork)).To(ConsistOf("external:deezer"))
Expect(repo.SourcesInUse(model.KindRadioArtwork)).To(BeEmpty())
})
})
It("does not disturb an already-queued entity when enqueueing missing rows", func() {
Expect(repo.Enqueue(item("al", albumRadioactivity.ID, model.ArtworkPriorityBump))).To(Succeed())
@ -281,4 +380,43 @@ var _ = Describe("ArtworkQueueRepository", func() {
}
Expect(count).To(Equal(1), "the already-queued row must not be duplicated")
})
Describe("status counters", func() {
It("groups queue rows by kind and priority", func() {
Expect(repo.Enqueue(item("ar", "a1", model.ArtworkPriorityBackfill))).To(Succeed())
Expect(repo.Enqueue(item("ar", "a2", model.ArtworkPriorityBackfill))).To(Succeed())
Expect(repo.Enqueue(item("ar", "a3", model.ArtworkPriorityBump))).To(Succeed())
Expect(repo.Enqueue(item("al", "b1", model.ArtworkPriorityScan))).To(Succeed())
Expect(repo.CountByKindAndPriority()).To(ConsistOf(
model.ArtworkQueueStat{ItemKind: "ar", Priority: model.ArtworkPriorityBackfill, Count: 2},
model.ArtworkQueueStat{ItemKind: "ar", Priority: model.ArtworkPriorityBump, Count: 1},
model.ArtworkQueueStat{ItemKind: "al", Priority: model.ArtworkPriorityScan, Count: 1},
))
})
It("reports an empty queue as no rows", func() {
Expect(repo.CountByKindAndPriority()).To(BeEmpty())
})
It("counts absent states and how many are due for recheck", func() {
awRepo := NewArtworkRepository(context.Background(), GetDBXBuilder())
old := time.Now().Add(-48 * time.Hour)
for _, ia := range []model.ItemArtwork{
{ItemKind: "ar", ItemID: "stale1", ImageType: model.ImageTypePrimary, Hash: "", AttemptedAt: old},
{ItemKind: "ar", ItemID: "fresh1", ImageType: model.ImageTypePrimary, Hash: "", AttemptedAt: time.Now()},
{ItemKind: "ar", ItemID: "found1", ImageType: model.ImageTypePrimary, Hash: "hX", AttemptedAt: old},
{ItemKind: "al", ItemID: "stale2", ImageType: model.ImageTypePrimary, Hash: "", AttemptedAt: old},
} {
Expect(awRepo.PutItemArtwork(&ia)).To(Succeed())
}
Expect(repo.CountAbsent(model.KindArtistArtwork, time.Now().Add(-24*time.Hour))).
To(Equal(model.ArtworkAbsentStat{Total: 2, Stale: 1}))
})
It("reports a kind with no absent state as zero, not as an error", func() {
Expect(repo.CountAbsent(model.KindRadioArtwork, time.Now())).To(Equal(model.ArtworkAbsentStat{}))
})
})
})

View File

@ -129,7 +129,7 @@ var smartPlaylistFields = map[string]smartPlaylistField{
"random": {order: "random()"},
}
func (c smartPlaylistCriteria) Where() (squirrel.Sqlizer, error) {
func (c smartPlaylistCriteria) where() (squirrel.Sqlizer, error) {
if c.Criteria.Expression == nil {
return squirrel.Expr("1 = 1"), nil
}
@ -786,7 +786,7 @@ func fieldJoinType(name string) smartPlaylistJoinType {
return field.joinType
}
func (c smartPlaylistCriteria) ExpressionJoins() smartPlaylistJoinType {
func (c smartPlaylistCriteria) expressionJoins() smartPlaylistJoinType {
var joins smartPlaylistJoinType
_ = criteria.Walk(c.Criteria.Expression, func(expr criteria.Expression) error {
for field := range criteria.Fields(expr) {
@ -797,15 +797,50 @@ func (c smartPlaylistCriteria) ExpressionJoins() smartPlaylistJoinType {
return joins
}
func (c smartPlaylistCriteria) RequiredJoins() smartPlaylistJoinType {
joins := c.ExpressionJoins()
func (c smartPlaylistCriteria) requiredJoins() smartPlaylistJoinType {
joins := c.expressionJoins()
for _, name := range c.Criteria.SortFieldNames() {
joins |= fieldJoinType(name)
}
return joins
}
func (c smartPlaylistCriteria) OrderBy() string {
// applyExpressionJoins adds every join the criteria's WHERE clause resolves against.
func (c smartPlaylistCriteria) applyExpressionJoins(sq squirrel.SelectBuilder, userID string) squirrel.SelectBuilder {
return c.applyJoins(sq, c.expressionJoins(), userID)
}
// applyRequiredJoins adds the WHERE joins plus any the ORDER BY resolves against.
func (c smartPlaylistCriteria) applyRequiredJoins(sq squirrel.SelectBuilder, userID string) squirrel.SelectBuilder {
return c.applyJoins(sq, c.requiredJoins(), userID)
}
// applyJoins joins the media_file annotation unconditionally — annotation fields
// COALESCE a missing row to a default, so the row has to be reachable to be absent.
func (c smartPlaylistCriteria) applyJoins(sq squirrel.SelectBuilder, joins smartPlaylistJoinType, userID string) squirrel.SelectBuilder {
sq = sq.LeftJoin("annotation on ("+
"annotation.item_id = media_file.id"+
" AND annotation.item_type = 'media_file'"+
" AND annotation.user_id = ?)", userID)
if joins.has(smartPlaylistJoinAlbumAnnotation) {
sq = sq.LeftJoin("annotation AS album_annotation ON ("+
"album_annotation.item_id = media_file.album_id"+
" AND album_annotation.item_type = 'album'"+
" AND album_annotation.user_id = ?)", userID)
}
if joins.has(smartPlaylistJoinArtistAnnotation) {
sq = sq.LeftJoin("annotation AS artist_annotation ON ("+
"artist_annotation.item_id = media_file.artist_id"+
" AND artist_annotation.item_type = 'artist'"+
" AND artist_annotation.user_id = ?)", userID)
}
if joins.has(smartPlaylistJoinAlbum) {
sq = sq.LeftJoin("album ON album.id = media_file.album_id")
}
return sq
}
func (c smartPlaylistCriteria) orderBy() string {
sortFields := c.Criteria.OrderByFields()
parts := make([]string, 0, len(sortFields))
for _, sf := range sortFields {

View File

@ -124,7 +124,7 @@ func benchmarkCriteriaPipeline(b *testing.B, ctx context.Context, expr criteria.
// Build the full query matching buildSmartPlaylistQuery + addCriteria
sq := squirrel.Select("media_file.id").From("media_file")
cond, err := cSQL.Where()
cond, err := cSQL.where()
if err != nil {
b.Fatal(err)
}
@ -132,7 +132,7 @@ func benchmarkCriteriaPipeline(b *testing.B, ctx context.Context, expr criteria.
if expr.Limit > 0 {
sq = sq.Limit(uint64(expr.Limit))
}
if order := cSQL.OrderBy(); order != "" {
if order := cSQL.orderBy(); order != "" {
sq = sq.OrderBy(order)
}

View File

@ -20,7 +20,7 @@ var _ = Describe("Smart playlist criteria SQL", func() {
DescribeTable("expressions",
func(expr criteria.Expression, expectedSQL string, expectedArgs ...any) {
sqlizer, err := newSmartPlaylistCriteria(criteria.Criteria{Expression: expr}).Where()
sqlizer, err := newSmartPlaylistCriteria(criteria.Criteria{Expression: expr}).where()
Expect(err).ToNot(HaveOccurred())
sql, args, err := sqlizer.ToSql()
@ -204,7 +204,7 @@ var _ = Describe("Smart playlist criteria SQL", func() {
sqlizer, err := newSmartPlaylistCriteria(
criteria.Criteria{Expression: criteria.InPlaylist{"id": "deadbeef-dead-beef"}},
withSmartPlaylistOwner(model.User{ID: "owner-id", IsAdmin: false}),
).Where()
).where()
Expect(err).ToNot(HaveOccurred())
sql, args, err := sqlizer.ToSql()
@ -217,7 +217,7 @@ var _ = Describe("Smart playlist criteria SQL", func() {
sqlizer, err := newSmartPlaylistCriteria(
criteria.Criteria{Expression: criteria.InPlaylist{"id": "deadbeef-dead-beef"}},
withSmartPlaylistOwner(model.User{ID: "admin-id", IsAdmin: true}),
).Where()
).where()
Expect(err).ToNot(HaveOccurred())
sql, args, err := sqlizer.ToSql()
@ -228,7 +228,7 @@ var _ = Describe("Smart playlist criteria SQL", func() {
})
It("builds relative date expressions", func() {
sqlizer, err := newSmartPlaylistCriteria(criteria.Criteria{Expression: criteria.InTheLast{"lastPlayed": 30}}).Where()
sqlizer, err := newSmartPlaylistCriteria(criteria.Criteria{Expression: criteria.InTheLast{"lastPlayed": 30}}).where()
Expect(err).ToNot(HaveOccurred())
sql, args, err := sqlizer.ToSql()
@ -238,7 +238,7 @@ var _ = Describe("Smart playlist criteria SQL", func() {
})
It("builds negated relative date expressions", func() {
sqlizer, err := newSmartPlaylistCriteria(criteria.Criteria{Expression: criteria.NotInTheLast{"lastPlayed": 30}}).Where()
sqlizer, err := newSmartPlaylistCriteria(criteria.Criteria{Expression: criteria.NotInTheLast{"lastPlayed": 30}}).where()
Expect(err).ToNot(HaveOccurred())
sql, args, err := sqlizer.ToSql()
@ -248,76 +248,76 @@ var _ = Describe("Smart playlist criteria SQL", func() {
})
It("returns an error for unknown fields", func() {
_, err := newSmartPlaylistCriteria(criteria.Criteria{Expression: criteria.EndsWith{"unknown": "value"}}).Where()
_, err := newSmartPlaylistCriteria(criteria.Criteria{Expression: criteria.EndsWith{"unknown": "value"}}).where()
Expect(err).To(MatchError("invalid field in criteria: unknown"))
})
It("returns an error when isMissing is used with a regular field", func() {
_, err := newSmartPlaylistCriteria(criteria.Criteria{Expression: criteria.IsMissing{"year": true}}).Where()
_, err := newSmartPlaylistCriteria(criteria.Criteria{Expression: criteria.IsMissing{"year": true}}).where()
Expect(err).To(MatchError(ContainSubstring("isMissing/isPresent operator is not supported for field")))
})
It("returns an error when isPresent is used with a regular field", func() {
_, err := newSmartPlaylistCriteria(criteria.Criteria{Expression: criteria.IsPresent{"title": true}}).Where()
_, err := newSmartPlaylistCriteria(criteria.Criteria{Expression: criteria.IsPresent{"title": true}}).where()
Expect(err).To(MatchError(ContainSubstring("isMissing/isPresent operator is not supported for field")))
})
It("returns an error when isMissing has a non-boolean value", func() {
_, err := newSmartPlaylistCriteria(criteria.Criteria{Expression: criteria.IsMissing{"genre": "hello"}}).Where()
_, err := newSmartPlaylistCriteria(criteria.Criteria{Expression: criteria.IsMissing{"genre": "hello"}}).where()
Expect(err).To(MatchError(ContainSubstring("invalid boolean value for 'missing' expression")))
})
It("returns an error for a range over a tag/role field", func() {
_, err := newSmartPlaylistCriteria(criteria.Criteria{Expression: criteria.InTheRange{"rate": []int{1, 5}}}).Where()
_, err := newSmartPlaylistCriteria(criteria.Criteria{Expression: criteria.InTheRange{"rate": []int{1, 5}}}).where()
Expect(err).To(MatchError(ContainSubstring("range operator not supported for tag/role field")))
})
It("returns a clear error for a malformed range value", func() {
_, err := newSmartPlaylistCriteria(criteria.Criteria{Expression: criteria.InTheRange{"playCount": []int{1, 2, 3}}}).Where()
_, err := newSmartPlaylistCriteria(criteria.Criteria{Expression: criteria.InTheRange{"playCount": []int{1, 2, 3}}}).where()
Expect(err).To(MatchError(ContainSubstring("must be a [min, max] pair")))
})
Describe("sort", func() {
It("sorts by regular fields", func() {
Expect(newSmartPlaylistCriteria(criteria.Criteria{Sort: "title"}).OrderBy()).To(Equal("media_file.title asc"))
Expect(newSmartPlaylistCriteria(criteria.Criteria{Sort: "title"}).orderBy()).To(Equal("media_file.title asc"))
})
It("sorts by tag fields", func() {
Expect(newSmartPlaylistCriteria(criteria.Criteria{Sort: "genre"}).OrderBy()).To(Equal("COALESCE(json_extract(media_file.tags, '$.genre[0].value'), '') asc"))
Expect(newSmartPlaylistCriteria(criteria.Criteria{Sort: "genre"}).orderBy()).To(Equal("COALESCE(json_extract(media_file.tags, '$.genre[0].value'), '') asc"))
})
It("sorts by role fields", func() {
Expect(newSmartPlaylistCriteria(criteria.Criteria{Sort: "artist"}).OrderBy()).To(Equal("COALESCE(json_extract(media_file.participants, '$.artist[0].name'), '') asc"))
Expect(newSmartPlaylistCriteria(criteria.Criteria{Sort: "artist"}).orderBy()).To(Equal("COALESCE(json_extract(media_file.participants, '$.artist[0].name'), '') asc"))
})
It("casts numeric tags when sorting", func() {
Expect(newSmartPlaylistCriteria(criteria.Criteria{Sort: "rate"}).OrderBy()).To(Equal("CAST(COALESCE(json_extract(media_file.tags, '$.rate[0].value'), '') AS REAL) asc"))
Expect(newSmartPlaylistCriteria(criteria.Criteria{Sort: "rate"}).orderBy()).To(Equal("CAST(COALESCE(json_extract(media_file.tags, '$.rate[0].value'), '') AS REAL) asc"))
})
It("sorts by albumtype alias", func() {
Expect(newSmartPlaylistCriteria(criteria.Criteria{Sort: "albumtype"}).OrderBy()).To(Equal("COALESCE(json_extract(media_file.tags, '$.releasetype[0].value'), '') asc"))
Expect(newSmartPlaylistCriteria(criteria.Criteria{Sort: "albumtype"}).orderBy()).To(Equal("COALESCE(json_extract(media_file.tags, '$.releasetype[0].value'), '') asc"))
})
It("sorts by random", func() {
Expect(newSmartPlaylistCriteria(criteria.Criteria{Sort: "random"}).OrderBy()).To(Equal("random() asc"))
Expect(newSmartPlaylistCriteria(criteria.Criteria{Sort: "random"}).orderBy()).To(Equal("random() asc"))
})
It("sorts by album columns bare, with no COALESCE default", func() {
Expect(newSmartPlaylistCriteria(criteria.Criteria{Sort: "-albumDateAdded,trackNumber"}).OrderBy()).
Expect(newSmartPlaylistCriteria(criteria.Criteria{Sort: "-albumDateAdded,trackNumber"}).orderBy()).
To(Equal("album.created_at desc, media_file.track_number asc"))
})
It("sorts by multiple fields", func() {
Expect(newSmartPlaylistCriteria(criteria.Criteria{Sort: "title,-rating"}).OrderBy()).To(Equal("media_file.title asc, COALESCE(annotation.rating, 0) desc"))
Expect(newSmartPlaylistCriteria(criteria.Criteria{Sort: "title,-rating"}).orderBy()).To(Equal("media_file.title asc, COALESCE(annotation.rating, 0) desc"))
})
It("reverts order when order is desc", func() {
Expect(newSmartPlaylistCriteria(criteria.Criteria{Sort: "-date,artist", Order: "desc"}).OrderBy()).To(Equal("media_file.date asc, COALESCE(json_extract(media_file.participants, '$.artist[0].name'), '') desc"))
Expect(newSmartPlaylistCriteria(criteria.Criteria{Sort: "-date,artist", Order: "desc"}).orderBy()).To(Equal("media_file.date asc, COALESCE(json_extract(media_file.participants, '$.artist[0].name'), '') desc"))
})
It("ignores invalid sort fields", func() {
Expect(newSmartPlaylistCriteria(criteria.Criteria{Sort: "bogus,title"}).OrderBy()).To(Equal("media_file.title asc"))
Expect(newSmartPlaylistCriteria(criteria.Criteria{Sort: "bogus,title"}).orderBy()).To(Equal("media_file.title asc"))
})
})
@ -362,7 +362,7 @@ var _ = Describe("Smart playlist criteria SQL", func() {
criteria.Contains{"artist": "Kraftwerk"},
criteria.Contains{"artist": "Pink Floyd"},
}
sqlizer, err := newSmartPlaylistCriteria(criteria.Criteria{Expression: expr}).Where()
sqlizer, err := newSmartPlaylistCriteria(criteria.Criteria{Expression: expr}).where()
Expect(err).ToNot(HaveOccurred())
sql, args, err := sqlizer.ToSql()
@ -376,7 +376,7 @@ var _ = Describe("Smart playlist criteria SQL", func() {
criteria.Contains{"artist": "Beatles"},
criteria.Contains{"composer": "Lennon"},
}
sqlizer, err := newSmartPlaylistCriteria(criteria.Criteria{Expression: expr}).Where()
sqlizer, err := newSmartPlaylistCriteria(criteria.Criteria{Expression: expr}).where()
Expect(err).ToNot(HaveOccurred())
sql, _, err := sqlizer.ToSql()
@ -391,7 +391,7 @@ var _ = Describe("Smart playlist criteria SQL", func() {
criteria.NotContains{"artist": "Beatles"},
criteria.NotContains{"artist": "Kraftwerk"},
}
sqlizer, err := newSmartPlaylistCriteria(criteria.Criteria{Expression: expr}).Where()
sqlizer, err := newSmartPlaylistCriteria(criteria.Criteria{Expression: expr}).where()
Expect(err).ToNot(HaveOccurred())
sql, _, err := sqlizer.ToSql()
@ -406,7 +406,7 @@ var _ = Describe("Smart playlist criteria SQL", func() {
for i := range anyExprs {
anyExprs[i] = criteria.Contains{"artist": fmt.Sprintf("Artist%d", i)}
}
sqlizer, err := newSmartPlaylistCriteria(criteria.Criteria{Expression: anyExprs}).Where()
sqlizer, err := newSmartPlaylistCriteria(criteria.Criteria{Expression: anyExprs}).where()
Expect(err).ToNot(HaveOccurred())
sql, args, err := sqlizer.ToSql()
@ -424,7 +424,7 @@ var _ = Describe("Smart playlist criteria SQL", func() {
criteria.Contains{"artist": "Beatles"},
criteria.Contains{"artist": "Kraftwerk"},
}
sqlizer, err := newSmartPlaylistCriteria(criteria.Criteria{Expression: expr}).Where()
sqlizer, err := newSmartPlaylistCriteria(criteria.Criteria{Expression: expr}).where()
Expect(err).ToNot(HaveOccurred())
sql, args, err := sqlizer.ToSql()
@ -440,7 +440,7 @@ var _ = Describe("Smart playlist criteria SQL", func() {
criteria.Contains{"genre": "Metal"},
criteria.Contains{"genre": "Punk"},
}
sqlizer, err := newSmartPlaylistCriteria(criteria.Criteria{Expression: expr}).Where()
sqlizer, err := newSmartPlaylistCriteria(criteria.Criteria{Expression: expr}).where()
Expect(err).ToNot(HaveOccurred())
sql, args, err := sqlizer.ToSql()
@ -454,7 +454,7 @@ var _ = Describe("Smart playlist criteria SQL", func() {
criteria.Contains{"genre": "Rock"},
criteria.Contains{"mood": "Happy"},
}
sqlizer, err := newSmartPlaylistCriteria(criteria.Criteria{Expression: expr}).Where()
sqlizer, err := newSmartPlaylistCriteria(criteria.Criteria{Expression: expr}).where()
Expect(err).ToNot(HaveOccurred())
sql, _, err := sqlizer.ToSql()
@ -467,7 +467,7 @@ var _ = Describe("Smart playlist criteria SQL", func() {
criteria.NotContains{"genre": "Rock"},
criteria.NotContains{"genre": "Metal"},
}
sqlizer, err := newSmartPlaylistCriteria(criteria.Criteria{Expression: expr}).Where()
sqlizer, err := newSmartPlaylistCriteria(criteria.Criteria{Expression: expr}).where()
Expect(err).ToNot(HaveOccurred())
sql, _, err := sqlizer.ToSql()
@ -482,7 +482,7 @@ var _ = Describe("Smart playlist criteria SQL", func() {
criteria.Contains{"genre": "Rock"},
criteria.Contains{"genre": "Metal"},
}
sqlizer, err := newSmartPlaylistCriteria(criteria.Criteria{Expression: expr}).Where()
sqlizer, err := newSmartPlaylistCriteria(criteria.Criteria{Expression: expr}).where()
Expect(err).ToNot(HaveOccurred())
sql, args, err := sqlizer.ToSql()
@ -499,7 +499,7 @@ var _ = Describe("Smart playlist criteria SQL", func() {
criteria.IsNot{"artist": "Beatles"},
criteria.IsNot{"artist": "Kraftwerk"},
}
sqlizer, err := newSmartPlaylistCriteria(criteria.Criteria{Expression: expr}).Where()
sqlizer, err := newSmartPlaylistCriteria(criteria.Criteria{Expression: expr}).where()
Expect(err).ToNot(HaveOccurred())
sql, args, err := sqlizer.ToSql()
@ -515,7 +515,7 @@ var _ = Describe("Smart playlist criteria SQL", func() {
criteria.NotContains{"artist": "Beatles"},
criteria.NotContains{"artist": "Kraftwerk"},
}
sqlizer, err := newSmartPlaylistCriteria(criteria.Criteria{Expression: expr}).Where()
sqlizer, err := newSmartPlaylistCriteria(criteria.Criteria{Expression: expr}).where()
Expect(err).ToNot(HaveOccurred())
sql, args, err := sqlizer.ToSql()
@ -530,7 +530,7 @@ var _ = Describe("Smart playlist criteria SQL", func() {
criteria.NotContains{"genre": "Rock"},
criteria.NotContains{"genre": "Metal"},
}
sqlizer, err := newSmartPlaylistCriteria(criteria.Criteria{Expression: expr}).Where()
sqlizer, err := newSmartPlaylistCriteria(criteria.Criteria{Expression: expr}).where()
Expect(err).ToNot(HaveOccurred())
sql, args, err := sqlizer.ToSql()
@ -547,7 +547,7 @@ var _ = Describe("Smart playlist criteria SQL", func() {
criteria.Contains{"artist": "Beatles"},
criteria.IsNot{"artist": "Kraftwerk"},
}
sqlizer, err := newSmartPlaylistCriteria(criteria.Criteria{Expression: expr}).Where()
sqlizer, err := newSmartPlaylistCriteria(criteria.Criteria{Expression: expr}).where()
Expect(err).ToNot(HaveOccurred())
sql, _, err := sqlizer.ToSql()
@ -562,7 +562,7 @@ var _ = Describe("Smart playlist criteria SQL", func() {
criteria.IsNot{"artist": "Beatles"},
criteria.IsNot{"composer": "Lennon"},
}
sqlizer, err := newSmartPlaylistCriteria(criteria.Criteria{Expression: expr}).Where()
sqlizer, err := newSmartPlaylistCriteria(criteria.Criteria{Expression: expr}).where()
Expect(err).ToNot(HaveOccurred())
sql, _, err := sqlizer.ToSql()
@ -575,7 +575,7 @@ var _ = Describe("Smart playlist criteria SQL", func() {
for i := range allExprs {
allExprs[i] = criteria.IsNot{"artist": fmt.Sprintf("Artist%d", i)}
}
sqlizer, err := newSmartPlaylistCriteria(criteria.Criteria{Expression: allExprs}).Where()
sqlizer, err := newSmartPlaylistCriteria(criteria.Criteria{Expression: allExprs}).where()
Expect(err).ToNot(HaveOccurred())
sql, args, err := sqlizer.ToSql()
@ -591,14 +591,14 @@ var _ = Describe("Smart playlist criteria SQL", func() {
c := criteria.Criteria{Expression: criteria.All{criteria.Contains{"title": "love"}}, Sort: "albumRating"}
cSQL := newSmartPlaylistCriteria(c)
Expect(cSQL.ExpressionJoins()).To(Equal(smartPlaylistJoinNone))
Expect(cSQL.RequiredJoins().has(smartPlaylistJoinAlbumAnnotation)).To(BeTrue())
Expect(cSQL.expressionJoins()).To(Equal(smartPlaylistJoinNone))
Expect(cSQL.requiredJoins().has(smartPlaylistJoinAlbumAnnotation)).To(BeTrue())
})
It("includes expression-based joins", func() {
c := criteria.Criteria{Expression: criteria.All{criteria.Gt{"albumRating": 3}}}
Expect(newSmartPlaylistCriteria(c).ExpressionJoins().has(smartPlaylistJoinAlbumAnnotation)).To(BeTrue())
Expect(newSmartPlaylistCriteria(c).expressionJoins().has(smartPlaylistJoinAlbumAnnotation)).To(BeTrue())
})
It("detects nested album and artist joins", func() {
@ -607,7 +607,7 @@ var _ = Describe("Smart playlist criteria SQL", func() {
criteria.Any{criteria.Gt{"artistPlayCount": 10}},
}}
joins := newSmartPlaylistCriteria(c).RequiredJoins()
joins := newSmartPlaylistCriteria(c).requiredJoins()
Expect(joins.has(smartPlaylistJoinAlbumAnnotation)).To(BeTrue())
Expect(joins.has(smartPlaylistJoinArtistAnnotation)).To(BeTrue())
})
@ -615,20 +615,20 @@ var _ = Describe("Smart playlist criteria SQL", func() {
It("detects join types from sort fields with direction prefixes", func() {
c := criteria.Criteria{Expression: criteria.All{criteria.Contains{"title": "love"}}, Sort: "-artistRating"}
Expect(newSmartPlaylistCriteria(c).RequiredJoins().has(smartPlaylistJoinArtistAnnotation)).To(BeTrue())
Expect(newSmartPlaylistCriteria(c).requiredJoins().has(smartPlaylistJoinArtistAnnotation)).To(BeTrue())
})
It("keeps a sort-only album join out of the expression joins", func() {
c := criteria.Criteria{Expression: criteria.All{criteria.Contains{"title": "love"}}, Sort: "-albumDateAdded"}
cSQL := newSmartPlaylistCriteria(c)
Expect(cSQL.ExpressionJoins()).To(Equal(smartPlaylistJoinNone))
Expect(cSQL.RequiredJoins().has(smartPlaylistJoinAlbum)).To(BeTrue())
Expect(cSQL.expressionJoins()).To(Equal(smartPlaylistJoinNone))
Expect(cSQL.requiredJoins().has(smartPlaylistJoinAlbum)).To(BeTrue())
})
It("distinguishes the album join from the album annotation join", func() {
c := criteria.Criteria{Expression: criteria.All{criteria.Gt{"albumRating": 3}}}
joins := newSmartPlaylistCriteria(c).RequiredJoins()
joins := newSmartPlaylistCriteria(c).requiredJoins()
Expect(joins.has(smartPlaylistJoinAlbumAnnotation)).To(BeTrue())
Expect(joins.has(smartPlaylistJoinAlbum)).To(BeFalse())

View File

@ -6,9 +6,7 @@ import (
"fmt"
"iter"
"maps"
"os"
"path"
"path/filepath"
"slices"
"strings"
"time"
@ -147,9 +145,8 @@ func (r folderRepository) getFolderUpdateInfoBatch(lib model.Library, targetPath
pathConditions := make(Or, 0, len(targetPaths)*2)
for _, targetPath := range targetPaths {
// Clean the path to normalize it. Paths stored in the folder table do not have leading/trailing slashes.
cleanPath := strings.TrimPrefix(targetPath, string(os.PathSeparator))
cleanPath = filepath.Clean(cleanPath)
// Slash-form like the stored paths; filepath.Clean would backslash them on Windows.
cleanPath := path.Clean(strings.TrimPrefix(targetPath, "/"))
// Include the target folder itself by ID
folderIDs = append(folderIDs, model.FolderID(lib, cleanPath))
@ -172,11 +169,13 @@ func (r folderRepository) getFolderUpdateInfoBatch(lib model.Library, targetPath
// queryFolderUpdateInfo executes the query and returns the result map
func (r folderRepository) queryFolderUpdateInfo(where And) (map[string]model.FolderUpdateInfo, error) {
sq := r.newSelect().Columns("id", "updated_at", "hash").Where(where)
sq := r.newSelect().Columns("id", "updated_at", "hash", "image_files", "images_updated_at").Where(where)
var res []struct {
ID string
UpdatedAt time.Time
Hash string
ID string
UpdatedAt time.Time
Hash string
ImageFiles string
ImagesUpdatedAt time.Time
}
err := r.queryAll(sq, &res)
if err != nil {
@ -184,11 +183,41 @@ func (r folderRepository) queryFolderUpdateInfo(where And) (map[string]model.Fol
}
m := make(map[string]model.FolderUpdateInfo, len(res))
for _, f := range res {
m[f.ID] = model.FolderUpdateInfo{UpdatedAt: f.UpdatedAt, Hash: f.Hash}
info := model.FolderUpdateInfo{UpdatedAt: f.UpdatedAt, Hash: f.Hash, ImagesUpdatedAt: f.ImagesUpdatedAt}
if f.ImageFiles != "" {
if err := json.Unmarshal([]byte(f.ImageFiles), &info.ImageFiles); err != nil {
return nil, fmt.Errorf("parsing folder image_files: %w", err)
}
}
m[f.ID] = info
}
return m, nil
}
// subtreePathChunkSize bounds how many paths one folderSubtreeFilter may expand into: each adds
// 3 OR terms, and SQLite rejects an expression tree deeper than 1000 (measured: 166 paths).
const subtreePathChunkSize = 100
// folderSubtreeFilter matches the folders at the given library-relative paths and all their
// descendants. A path of "" or "." selects the whole library, so it drops the path conditions.
func folderSubtreeFilter(lib model.Library, paths []string) Sqlizer {
conds := make(Or, 0, len(paths)*3)
for _, p := range paths {
// Paths are io/fs slash-form; filepath.Clean would backslash them on Windows.
cleanPath := path.Clean(strings.TrimPrefix(p, "/"))
if cleanPath == "." {
return And{Eq{"folder.library_id": lib.ID}, Eq{"folder.missing": false}}
}
conds = append(conds,
Eq{"folder.id": model.FolderID(lib, cleanPath)},
// Direct children have path = cleanPath; deeper descendants match the prefix
Eq{"folder.path": cleanPath},
Expr(`folder.path LIKE ? ESCAPE '\'`, escapeLikePrefix(cleanPath)+"/%"),
)
}
return And{Eq{"folder.library_id": lib.ID}, Eq{"folder.missing": false}, conds}
}
// HasAudioOutsideFolders reports whether any folder in parent's subtree
// (including parent itself) contains audio files and is not one of the given
// folder IDs. LIKE wildcards in the parent path are escaped, so it is always

View File

@ -9,6 +9,7 @@ import (
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/model/request"
"github.com/navidrome/navidrome/tests"
"github.com/navidrome/navidrome/utils/slice"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
"github.com/pocketbase/dbx"
@ -43,6 +44,42 @@ var _ = Describe("FolderRepository", func() {
_, _ = conn.NewQuery(fmt.Sprintf("DELETE FROM library WHERE id = %d", otherLib.ID)).Execute()
})
Describe("folderSubtreeFilter", func() {
var parent, child, grandchild, other *model.Folder
matching := func(paths ...string) []string {
GinkgoHelper()
folders, err := repo.GetAll(model.QueryOptions{Filters: folderSubtreeFilter(testLib, paths)})
Expect(err).ToNot(HaveOccurred())
return slice.Map(folders, func(f model.Folder) string { return f.ID })
}
BeforeEach(func() {
parent = model.NewFolder(testLib, "TestSubtree")
child = model.NewFolder(testLib, "TestSubtree/Child")
grandchild = model.NewFolder(testLib, "TestSubtree/Child/Grandchild")
other = model.NewFolder(testLib, "TestSubtreeOther")
for _, f := range []*model.Folder{parent, child, grandchild, other} {
Expect(repo.Put(f)).To(Succeed())
}
DeferCleanup(func() {
_, _ = conn.NewQuery("DELETE FROM folder WHERE name LIKE 'TestSubtree%' OR path LIKE 'TestSubtree%'").Execute()
})
})
It("matches a folder and all its descendants", func() {
Expect(matching("TestSubtree")).To(ConsistOf(parent.ID, child.ID, grandchild.ID))
})
It("matches the descendants of a nested slash-form path", func() {
Expect(matching("TestSubtree/Child")).To(ConsistOf(child.ID, grandchild.ID))
})
It("matches the whole library for the root path", func() {
Expect(matching(".")).To(ContainElements(parent.ID, child.ID, grandchild.ID, other.ID))
})
})
Describe("GetFolderUpdateInfo", func() {
Context("with no target paths", func() {
It("returns all folders in the library", func() {

View File

@ -15,6 +15,7 @@ import (
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/model/criteria"
"github.com/navidrome/navidrome/utils/slice"
"github.com/navidrome/navidrome/utils/str"
"github.com/pocketbase/dbx"
@ -317,6 +318,26 @@ func (r *mediaFileRepository) GetAllIDs(options ...model.QueryOptions) ([]string
return ids, err
}
func (r *mediaFileRepository) GetAlbumIDsByFolder(lib model.Library, folderIDs ...string) ([]string, error) {
ids := []string{}
for chunk := range slices.Chunk(folderIDs, 200) {
// A folder's own cover also covers albums whose tracks sit in its disc subfolders.
inFolders := Select("f.id").From("folder f").Where(And{
Eq{"f.library_id": lib.ID},
Eq{"f.missing": false},
Or{Eq{"f.id": chunk}, Eq{"f.parent_id": chunk}},
})
sq := Select("distinct album_id").From("media_file").
Where(And{Eq{"missing": false}, ConcatExpr("folder_id IN (", inFolders, ")")})
var chunkIDs []string
if err := r.queryAllSlice(sq, &chunkIDs); err != nil {
return nil, err
}
ids = append(ids, chunkIDs...)
}
return ids, nil
}
// GetCursorWithArtwork streams the same rows as GetCursor, hydrated, via an id pre-pass.
func (r *mediaFileRepository) GetCursorWithArtwork(options ...model.QueryOptions) (model.MediaFileCursor, error) {
ids, err := r.GetAllIDs(options...)
@ -517,6 +538,23 @@ var mediaFileSearchConfig = searchConfig{
MBIDFields: []string{"mbz_recording_id", "mbz_release_track_id"},
}
func (r *mediaFileRepository) MatchesCriteria(id string, c criteria.Criteria) (bool, error) {
usr := loggedUser(r.ctx)
rulesSQL := newSmartPlaylistCriteria(c, withSmartPlaylistOwner(*usr))
cond, err := rulesSQL.where()
if err != nil {
return false, err
}
sq := Select("count(*) as count").From("media_file")
sq = rulesSQL.applyExpressionJoins(sq, usr.ID)
sq = sq.Where(And{Eq{"media_file.id": id}, cond})
var res struct{ Count int64 }
if err := r.queryOne(sq, &res); err != nil {
return false, err
}
return res.Count > 0, nil
}
func (r *mediaFileRepository) Search(q string, options ...model.QueryOptions) (model.MediaFiles, error) {
var opts model.QueryOptions
if len(options) > 0 {

View File

@ -13,6 +13,7 @@ import (
"github.com/navidrome/navidrome/conf/configtest"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/model/criteria"
"github.com/navidrome/navidrome/model/id"
"github.com/navidrome/navidrome/model/request"
. "github.com/onsi/ginkgo/v2"
@ -29,6 +30,54 @@ var _ = Describe("MediaRepository", func() {
mr = NewMediaFileRepository(ctx, GetDBXBuilder())
})
Describe("GetAlbumIDsByFolder", func() {
var lib model.Library
var albumRoot, disc1, sibling *model.Folder
BeforeEach(func() {
ctx := request.WithUser(log.NewContext(context.TODO()), model.User{ID: "userid"})
libPtr, err := NewLibraryRepository(ctx, GetDBXBuilder()).Get(1)
Expect(err).ToNot(HaveOccurred())
lib = *libPtr
folderRepo := newFolderRepository(ctx, GetDBXBuilder())
albumRoot = model.NewFolder(lib, "ByFolder/Album")
disc1 = model.NewFolder(lib, "ByFolder/Album/CD1")
sibling = model.NewFolder(lib, "ByFolder/Other")
for _, f := range []*model.Folder{albumRoot, disc1, sibling} {
Expect(folderRepo.Put(f)).To(Succeed())
}
// Tracks live in the disc subfolder; the sibling album is the negative control.
Expect(mr.Put(&model.MediaFile{ID: "fol-mf-1", LibraryID: 1, AlbumID: "fol-al-1", FolderID: disc1.ID, Path: "t/1.mp3"})).To(Succeed())
Expect(mr.Put(&model.MediaFile{ID: "fol-mf-2", LibraryID: 1, AlbumID: "fol-al-1", FolderID: disc1.ID, Path: "t/2.mp3"})).To(Succeed())
Expect(mr.Put(&model.MediaFile{ID: "fol-mf-3", LibraryID: 1, AlbumID: "fol-al-2", FolderID: sibling.ID, Path: "t/3.mp3"})).To(Succeed())
Expect(mr.Put(&model.MediaFile{ID: "fol-mf-4", LibraryID: 1, AlbumID: "fol-al-3", FolderID: disc1.ID, Path: "t/4.mp3", Missing: true})).To(Succeed())
DeferCleanup(func() {
_, _ = GetDBXBuilder().NewQuery("DELETE FROM media_file WHERE id LIKE 'fol-mf-%'").Execute()
_, _ = GetDBXBuilder().NewQuery("DELETE FROM folder WHERE path LIKE 'ByFolder%' OR name = 'ByFolder'").Execute()
})
})
It("returns the distinct album IDs of non-missing tracks in the folder", func() {
ids, err := mr.GetAlbumIDsByFolder(lib, disc1.ID)
Expect(err).ToNot(HaveOccurred())
Expect(ids).To(ConsistOf("fol-al-1"))
})
It("also matches albums whose tracks are in a direct child of the folder", func() {
// A cover in the album root must reach the album whose tracks sit in CD1
ids, err := mr.GetAlbumIDsByFolder(lib, albumRoot.ID)
Expect(err).ToNot(HaveOccurred())
Expect(ids).To(ConsistOf("fol-al-1"))
})
It("does not match albums outside the folder", func() {
ids, err := mr.GetAlbumIDsByFolder(lib, albumRoot.ID)
Expect(err).ToNot(HaveOccurred())
Expect(ids).ToNot(ContainElement("fol-al-2"))
})
})
Describe("GetCursor", func() {
It("yields the same media files as GetAll", func() {
opts := model.QueryOptions{Sort: "title"}
@ -1129,4 +1178,31 @@ var _ = Describe("MediaRepository", func() {
Expect(NewMediaFileRepository(rctx, GetDBXBuilder()).Exists(songAntenna.ID)).To(BeFalse())
})
})
Describe("MatchesCriteria", func() {
It("returns true when the track matches", func() {
c := criteria.Criteria{Expression: criteria.All{criteria.Contains{"title": "Day"}}}
match, err := mr.MatchesCriteria(songDayInALife.ID, c)
Expect(err).ToNot(HaveOccurred())
Expect(match).To(BeTrue())
})
It("returns false when the track does not match", func() {
c := criteria.Criteria{Expression: criteria.All{criteria.Contains{"title": "Nickelback"}}}
match, err := mr.MatchesCriteria(songDayInALife.ID, c)
Expect(err).ToNot(HaveOccurred())
Expect(match).To(BeFalse())
})
It("treats missing annotations as their COALESCE default", func() {
// unrated track: rating coalesces to 0, so "rating < 4" matches
c := criteria.Criteria{Expression: criteria.All{criteria.Lt{"rating": 4}}}
match, err := mr.MatchesCriteria(songDayInALife.ID, c)
Expect(err).ToNot(HaveOccurred())
Expect(match).To(BeTrue())
})
It("returns an error for an invalid field", func() {
c := criteria.Criteria{Expression: criteria.All{criteria.Is{"bogusfield": 1}}}
_, err := mr.MatchesCriteria(songDayInALife.ID, c)
Expect(err).To(HaveOccurred())
})
})
})

View File

@ -2,6 +2,7 @@ package persistence
import (
"database/sql"
"slices"
. "github.com/Masterminds/squirrel"
"github.com/deluan/rest"
@ -224,10 +225,14 @@ func (r *playlistTrackRepository) AddDiscs(discs []model.DiscID) (int, error) {
return r.addMediaFileIds(clauses)
}
// deleteChunkSize keeps each DELETE under SQLITE_MAX_VARIABLE_NUMBER, matching addTracks.
const deleteChunkSize = 200
func (r *playlistTrackRepository) Delete(ids ...string) error {
err := r.delete(And{Eq{"playlist_id": r.playlistId}, Eq{"id": ids}})
if err != nil {
return err
for chunk := range slices.Chunk(ids, deleteChunkSize) {
if err := r.delete(And{Eq{"playlist_id": r.playlistId}, Eq{"id": chunk}}); err != nil {
return err
}
}
return r.playlistRepo.renumber(r.playlistId)

View File

@ -1,6 +1,8 @@
package persistence
import (
"strconv"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/model/request"
@ -8,6 +10,9 @@ import (
. "github.com/onsi/gomega"
)
// sqliteMaxVariables is SQLITE_MAX_VARIABLE_NUMBER as compiled into the driver
const sqliteMaxVariables = 32766
var _ = Describe("PlaylistTrackRepository", func() {
var repo model.PlaylistTrackRepository
@ -72,4 +77,49 @@ var _ = Describe("PlaylistTrackRepository", func() {
To(Equal([]string{songRadioactivity.ID}))
})
})
Describe("Delete", func() {
var tracks model.PlaylistTrackRepository
const numTracks = deleteChunkSize*2 + 1
positionsUpTo := func(n int) []string {
positions := make([]string, 0, n)
for i := 1; i <= n; i++ {
positions = append(positions, strconv.Itoa(i))
}
return positions
}
BeforeEach(func() {
ctx := log.NewContext(GinkgoT().Context())
ctx = request.WithUser(ctx, model.User{ID: "userid", UserName: "userid", IsAdmin: true})
plsRepo := NewPlaylistRepository(ctx, GetDBXBuilder())
pls := model.Playlist{Name: "Chunked Delete", OwnerID: "userid", OwnerName: "userid"}
Expect(plsRepo.Put(&pls)).To(Succeed())
DeferCleanup(func() { Expect(plsRepo.Delete(pls.ID)).To(Succeed()) })
tracks = plsRepo.Tracks(pls.ID, false)
songIds := make([]string, numTracks)
for i := range songIds {
songIds[i] = songDayInALife.ID
}
Expect(tracks.Add(songIds)).To(Equal(numTracks))
})
It("removes positions spanning several chunks, and renumbers what is left", func() {
Expect(tracks.Delete(positionsUpTo(numTracks - 1)...)).To(Succeed())
Expect(tracks.CountAll()).To(Equal(int64(1)))
remaining, err := tracks.GetAll(model.QueryOptions{Sort: "id"})
Expect(err).ToNot(HaveOccurred())
Expect(remaining[0].ID).To(Equal("1"), "the surviving track must be renumbered to position 1")
})
It("accepts more ids than SQLite allows as bind variables", func() {
Expect(tracks.Delete(positionsUpTo(sqliteMaxVariables + 100)...)).To(Succeed())
Expect(tracks.CountAll()).To(BeZero())
})
})
})

View File

@ -119,13 +119,11 @@ func (r *playlistRepository) resolvePercentageLimit(pls *model.Playlist, rulesSQ
return nil
}
exprJoins := rulesSQL.ExpressionJoins()
countSq := Select("count(*) as count").From("media_file")
countSq = r.addMediaFileAnnotationJoin(countSq, userID)
countSq = r.addSmartPlaylistJoins(countSq, exprJoins, userID)
countSq = rulesSQL.applyExpressionJoins(countSq, userID)
countSq = r.applyLibraryFilter(countSq, "media_file")
cond, err := rulesSQL.Where()
cond, err := rulesSQL.where()
if err != nil {
log.Error(r.ctx, "Error building smart playlist criteria", "playlist", pls.Name, "id", pls.ID, err)
return err
@ -146,50 +144,18 @@ func (r *playlistRepository) resolvePercentageLimit(pls *model.Playlist, rulesSQ
// buildSmartPlaylistQuery constructs the SQL query to select media files matching the smart playlist criteria,
// including the joins its fields require and library filtering.
func (r *playlistRepository) buildSmartPlaylistQuery(pls *model.Playlist, rulesSQL smartPlaylistCriteria, userID string) SelectBuilder {
orderBy := rulesSQL.OrderBy()
orderBy := rulesSQL.orderBy()
sq := Select("row_number() over (order by "+orderBy+") as id", "'"+pls.ID+"' as playlist_id", "media_file.id as media_file_id").
From("media_file")
sq = r.addMediaFileAnnotationJoin(sq, userID)
requiredJoins := rulesSQL.RequiredJoins()
sq = r.addSmartPlaylistJoins(sq, requiredJoins, userID)
sq = rulesSQL.applyRequiredJoins(sq, userID)
sq = r.applyLibraryFilter(sq, "media_file")
return sq
}
// addMediaFileAnnotationJoin adds a left join to the annotation table for media files, filtering by user ID to include
// user-specific annotations in the smart playlist criteria evaluation.
func (r *playlistRepository) addMediaFileAnnotationJoin(sq SelectBuilder, userID string) SelectBuilder {
return sq.LeftJoin("annotation on ("+
"annotation.item_id = media_file.id"+
" AND annotation.item_type = 'media_file'"+
" AND annotation.user_id = ?)", userID)
}
// addSmartPlaylistJoins adds the left joins required by the criteria's fields.
func (r *playlistRepository) addSmartPlaylistJoins(sq SelectBuilder, joins smartPlaylistJoinType, userID string) SelectBuilder {
if joins.has(smartPlaylistJoinAlbumAnnotation) {
sq = sq.LeftJoin("annotation AS album_annotation ON ("+
"album_annotation.item_id = media_file.album_id"+
" AND album_annotation.item_type = 'album'"+
" AND album_annotation.user_id = ?)", userID)
}
if joins.has(smartPlaylistJoinArtistAnnotation) {
sq = sq.LeftJoin("annotation AS artist_annotation ON ("+
"artist_annotation.item_id = media_file.artist_id"+
" AND artist_annotation.item_type = 'artist'"+
" AND artist_annotation.user_id = ?)", userID)
}
if joins.has(smartPlaylistJoinAlbum) {
sq = sq.LeftJoin("album ON album.id = media_file.album_id")
}
return sq
}
// addCriteria applies the where conditions, limit, offset, and order by clauses to the SQL query based on the
// smart playlist criteria.
func (r *playlistRepository) addCriteria(sql SelectBuilder, cSQL smartPlaylistCriteria) (SelectBuilder, error) {
cond, err := cSQL.Where()
cond, err := cSQL.where()
if err != nil {
return sql, err
}
@ -197,7 +163,7 @@ func (r *playlistRepository) addCriteria(sql SelectBuilder, cSQL smartPlaylistCr
if cSQL.Criteria.Limit > 0 {
sql = sql.Limit(uint64(cSQL.Criteria.Limit)).Offset(uint64(cSQL.Criteria.Offset))
}
if order := cSQL.OrderBy(); order != "" {
if order := cSQL.orderBy(); order != "" {
sql = sql.OrderBy(order)
}
return sql, nil

View File

@ -16,6 +16,7 @@ import (
"github.com/navidrome/navidrome/consts"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/model/criteria"
"github.com/navidrome/navidrome/model/id"
"github.com/navidrome/navidrome/utils"
"github.com/navidrome/navidrome/utils/slice"
@ -252,6 +253,9 @@ func (r *userRepository) Save(entity any) (string, error) {
if err := validateUsernameUnique(r, u); err != nil {
return "", err
}
if err := validateScrobbleFilter(u); err != nil {
return "", err
}
err := r.Put(u)
if err != nil {
return "", err
@ -284,6 +288,9 @@ func (r *userRepository) Update(id string, entity any, _ ...string) error {
if err := validateUsernameUnique(r, u); err != nil {
return err
}
if err := validateScrobbleFilter(u); err != nil {
return err
}
err := r.Put(u)
if errors.Is(err, model.ErrNotFound) {
return rest.ErrNotFound
@ -331,6 +338,33 @@ func validateUsernameUnique(r model.UserRepository, u *model.User) error {
return nil
}
func validateScrobbleFilter(u *model.User) error {
u.ScrobbleFilter = strings.TrimSpace(u.ScrobbleFilter)
if u.ScrobbleFilter == "" {
return nil
}
var c criteria.Criteria
if err := json.Unmarshal([]byte(u.ScrobbleFilter), &c); err != nil {
return invalidScrobbleFilter()
}
// A filter is a per-track test, so a result-set size means nothing here. Reject it
// rather than silently ignoring part of a rule copied from a smart playlist.
if c.Limit > 0 || c.LimitPercent > 0 || c.Offset > 0 || c.RefreshDelay > 0 {
return invalidScrobbleFilter()
}
// Building the WHERE clause is what validates field names and operators
if _, err := newSmartPlaylistCriteria(c).where(); err != nil {
return invalidScrobbleFilter()
}
return nil
}
func invalidScrobbleFilter() error {
return &rest.ValidationError{Errors: map[string]string{
"scrobbleFilter": "resources.user.validation.invalidScrobbleFilter",
}}
}
func (r *userRepository) Delete(id string) error {
usr := loggedUser(r.ctx)
if !usr.IsAdmin {

View File

@ -70,6 +70,26 @@ var _ = Describe("UserRepository", func() {
Expect(err).ToNot(HaveOccurred())
Expect(actual.Password).To(Equal("newpass"))
})
It("persists and reads back the scrobble filter", func() {
usr := model.User{ID: "u-filter", UserName: "u-filter", Name: "Filter User",
ScrobbleFilter: `{"all":[{"contains":{"title":"????"}}]}`}
Expect(repo.Put(&usr)).To(Succeed())
saved, err := repo.Get("u-filter")
Expect(err).ToNot(HaveOccurred())
Expect(saved.ScrobbleFilter).To(Equal(`{"all":[{"contains":{"title":"????"}}]}`))
})
It("reads back a user row inserted without scrobble_filter", func() {
// Guards the column's NOT NULL DEFAULT '': rows predating the migration must stay scannable
_, err := GetDBXBuilder().NewQuery(
"insert into user (id, user_name, name, email, password, created_at, updated_at) " +
"values ('u-rawsql', 'u-rawsql', 'Raw', '', '', datetime('now'), datetime('now'))").Execute()
Expect(err).ToNot(HaveOccurred())
saved, err := repo.Get("u-rawsql")
Expect(err).ToNot(HaveOccurred())
Expect(saved.ScrobbleFilter).To(Equal(""))
})
})
Describe("validatePasswordChange", func() {
@ -607,6 +627,52 @@ var _ = Describe("UserRepository", func() {
})
})
Describe("validateScrobbleFilter", func() {
It("accepts an empty filter", func() {
u := &model.User{}
Expect(validateScrobbleFilter(u)).To(Succeed())
})
It("trims a whitespace-only filter to empty", func() {
u := &model.User{ScrobbleFilter: " "}
Expect(validateScrobbleFilter(u)).To(Succeed())
Expect(u.ScrobbleFilter).To(Equal(""))
})
It("accepts valid criteria JSON", func() {
u := &model.User{ScrobbleFilter: `{"all":[{"lt":{"rating":4}}]}`}
Expect(validateScrobbleFilter(u)).To(Succeed())
})
It("rejects malformed JSON", func() {
u := &model.User{ScrobbleFilter: `{not json`}
var vErr *rest.ValidationError
err := validateScrobbleFilter(u)
Expect(errors.As(err, &vErr)).To(BeTrue())
Expect(vErr.Errors).To(HaveKey("scrobbleFilter"))
})
It("rejects criteria without rules", func() {
u := &model.User{ScrobbleFilter: `{"sort":"title"}`}
Expect(validateScrobbleFilter(u)).ToNot(Succeed())
})
It("rejects selection options that mean nothing for a single track", func() {
for _, f := range []string{
`{"all":[{"lt":{"rating":4}}],"limit":100}`,
`{"all":[{"lt":{"rating":4}}],"limitPercent":10}`,
`{"all":[{"lt":{"rating":4}}],"offset":5}`,
`{"all":[{"lt":{"rating":4}}],"refreshDelay":"1h"}`,
} {
u := &model.User{ScrobbleFilter: f}
Expect(validateScrobbleFilter(u)).ToNot(Succeed(), f)
}
})
It("accepts a sort, which cannot change a single-track match", func() {
u := &model.User{ScrobbleFilter: `{"all":[{"lt":{"rating":4}}],"sort":"title"}`}
Expect(validateScrobbleFilter(u)).To(Succeed())
})
It("rejects unknown fields", func() {
u := &model.User{ScrobbleFilter: `{"all":[{"is":{"bogusfield":1}}]}`}
Expect(validateScrobbleFilter(u)).ToNot(Succeed())
})
})
Describe("filters", func() {
It("qualifies id filter with table name", func() {
r := repo.(*userRepository)

View File

@ -61,6 +61,9 @@ type Manager struct {
debounceTimers map[string]*time.Timer
debounceMu sync.Mutex
// transient is set by LoadPlugins, and nil for a server Start.
transient *transientLoad
// SubsonicAPI host function dependencies (set once before Start, not modified after)
subsonicRouter SubsonicRouter
ds model.DataStore
@ -110,27 +113,14 @@ func (m *Manager) Start(ctx context.Context) error {
}
if m.subsonicRouter == nil {
log.Fatal(ctx, "Plugin manager requires DataStore to be configured")
log.Fatal(ctx, "Plugin manager requires the SubsonicAPI router to be configured")
}
// Set extism log level based on plugin-specific config or global log level
pluginLogLevel := conf.Server.Plugins.LogLevel
if pluginLogLevel == "" {
pluginLogLevel = conf.Server.LogLevel
}
extism.SetLogLevel(toExtismLogLevel(log.ParseLogLevel(pluginLogLevel)))
m.ctx, m.cancel = context.WithCancel(ctx)
// Initialize wazero compilation cache for better performance
cacheDir := filepath.Join(conf.Server.CacheFolder.MustPath(), "plugins")
purgeCacheBySize(ctx, cacheDir, conf.Server.Plugins.CacheSize)
var err error
m.cache, err = wazero.NewCompilationCacheWithDir(cacheDir)
if err != nil {
log.Error(ctx, "Failed to create wazero compilation cache", err)
return fmt.Errorf("creating wazero compilation cache: %w", err)
if err := m.initRuntime(ctx, cacheDir); err != nil {
return err
}
if conf.Server.Plugins.Folder.String() == "" {
@ -171,6 +161,49 @@ func (m *Manager) Start(ctx context.Context) error {
return nil
}
// initRuntime prepares the extism/wazero runtime that instantiating a plugin needs.
func (m *Manager) initRuntime(ctx context.Context, cacheDir string) error {
pluginLogLevel := conf.Server.Plugins.LogLevel
if pluginLogLevel == "" {
pluginLogLevel = conf.Server.LogLevel
}
extism.SetLogLevel(toExtismLogLevel(log.ParseLogLevel(pluginLogLevel)))
m.ctx, m.cancel = context.WithCancel(ctx)
var err error
m.cache, err = wazero.NewCompilationCacheWithDir(cacheDir)
if err != nil {
log.Error(ctx, "Failed to create wazero compilation cache", err)
return fmt.Errorf("creating wazero compilation cache: %w", err)
}
return nil
}
// transientLoad scopes a load that will not outlive the command asking for it; see LoadPlugins.
type transientLoad struct {
only []string
runInit bool
}
// LoadPlugins loads the plugins named in only, so a CLI sees the agents a server would. Each is
// instantiated, creating any KVStore, TaskQueue or Storage it declares; call Stop when done.
func (m *Manager) LoadPlugins(ctx context.Context, only []string, runInit bool) error {
if !conf.Server.Plugins.Enabled || conf.Server.Plugins.Folder.String() == "" || len(only) == 0 {
return nil
}
m.transient = &transientLoad{only: only, runInit: runInit}
cacheDir := filepath.Join(conf.Server.CacheFolder.MustPath(), "plugins")
if err := m.initRuntime(ctx, cacheDir); err != nil {
return err
}
if err := m.loadEnabledPlugins(ctx); err != nil {
return fmt.Errorf("loading enabled plugins: %w", err)
}
return nil
}
// Stop shuts down the plugin manager and releases all resources.
func (m *Manager) Stop() error {
// Mark as stopped first to prevent new operations

View File

@ -5,6 +5,7 @@ import (
"encoding/json"
"fmt"
"io"
"slices"
"time"
extism "github.com/extism/go-sdk"
@ -232,6 +233,11 @@ func (m *Manager) loadEnabledPlugins(ctx context.Context) error {
if !p.Enabled {
continue
}
// Instantiating a plugin creates its host services, so a transient load takes only the
// ones it may actually consult.
if m.transient != nil && !slices.Contains(m.transient.only, p.ID) {
continue
}
plugin := p // Capture for goroutine
g.Go(func() error {
@ -246,19 +252,21 @@ func (m *Manager) loadEnabledPlugins(ctx context.Context) error {
}()
if err := m.loadPluginWithConfig(&plugin); err != nil {
// Store error in DB
plugin.LastError = err.Error()
plugin.Enabled = false
plugin.UpdatedAt = time.Now()
if putErr := repo.Put(&plugin); putErr != nil {
log.Error(ctx, "Failed to update plugin error in DB", "plugin", plugin.ID, putErr)
// A transient load must not disable the user's plugin just for looking at it.
if m.transient == nil {
plugin.LastError = err.Error()
plugin.Enabled = false
plugin.UpdatedAt = time.Now()
if putErr := repo.Put(&plugin); putErr != nil {
log.Error(ctx, "Failed to update plugin error in DB", "plugin", plugin.ID, putErr)
}
}
log.Error(ctx, "Failed to load plugin", "plugin", plugin.ID, err)
return nil
}
// Clear any previous error
if plugin.LastError != "" {
if plugin.LastError != "" && m.transient == nil {
plugin.LastError = ""
plugin.UpdatedAt = time.Now()
if putErr := repo.Put(&plugin); putErr != nil {
@ -444,8 +452,11 @@ func (m *Manager) loadPluginWithConfig(p *model.Plugin) error {
m.mu.Unlock()
loaded = true
// Call plugin init function
callPluginInit(ctx, m.plugins[p.ID])
// Init is the plugin's first chance to run arbitrary code: open sockets, create task queues,
// schedule work. Only a caller that already intends to reach the network asks for it.
if m.transient == nil || m.transient.runInit {
callPluginInit(ctx, m.plugins[p.ID])
}
return nil
}

View File

@ -0,0 +1,125 @@
//go:build !windows
package plugins
import (
"os"
"path/filepath"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/conf/configtest"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/tests"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
var _ = Describe("Manager.LoadPlugins", func() {
var (
mgr *Manager
repo *tests.MockPluginRepo
tmpDir string
)
// newManager builds a manager over rows the caller can corrupt, with no Subsonic router: a CLI
// has none, and Start would log.Fatal on that.
newManager := func(rows model.Plugins) *Manager {
DeferCleanup(configtest.SetupConfig())
var err error
tmpDir, err = os.MkdirTemp("", "plugins-readonly-*")
Expect(err).ToNot(HaveOccurred())
DeferCleanup(func() { _ = os.RemoveAll(tmpDir) })
conf.Server.Plugins.Enabled = true
conf.Server.Plugins.Folder = conf.NewDir(tmpDir)
conf.Server.Plugins.AutoReload = false
conf.Server.CacheFolder = conf.NewDir(tmpDir)
if rows == nil {
rows = installTestPlugins(tmpDir, "test-metadata-agent"+PackageExtension)
for i := range rows {
rows[i].AllUsers = true
}
}
repo = tests.CreateMockPluginRepo()
repo.Permitted = true
repo.SetData(rows)
m := &Manager{
plugins: make(map[string]*plugin),
ds: &tests.MockDataStore{MockedPlugin: repo},
metrics: noopMetricsRecorder{},
}
DeferCleanup(func() { _ = m.Stop() })
return m
}
It("detects capabilities without a Subsonic router configured", func() {
mgr = newManager(nil)
Expect(mgr.LoadPlugins(GinkgoT().Context(), []string{"test-metadata-agent", "broken"}, false)).To(Succeed())
Expect(mgr.PluginNames(string(CapabilityMetadataAgent))).To(ContainElement("test-metadata-agent"))
})
Context("when a plugin cannot be loaded", func() {
brokenRows := func() model.Plugins {
return model.Plugins{{
ID: "broken", Path: filepath.Join(GinkgoT().TempDir(), "does-not-exist.ndp"),
Enabled: true, AllUsers: true,
}}
}
It("leaves the stored row untouched", func() {
mgr = newManager(brokenRows())
Expect(mgr.LoadPlugins(GinkgoT().Context(), []string{"test-metadata-agent", "broken"}, false)).To(Succeed())
stored, err := repo.Get("broken")
Expect(err).ToNot(HaveOccurred())
Expect(stored.Enabled).To(BeTrue(), "inspecting a plugin must never disable it")
Expect(stored.LastError).To(BeEmpty())
})
// Without this the test above would pass for the wrong reason. Start cannot be used: it
// syncs the folder first, dropping a row whose file is missing before any load.
It("still disables it when not read-only", func() {
mgr = newManager(brokenRows())
Expect(mgr.loadEnabledPlugins(GinkgoT().Context())).To(Succeed())
stored, err := repo.Get("broken")
Expect(err).ToNot(HaveOccurred())
Expect(stored.Enabled).To(BeFalse())
Expect(stored.LastError).ToNot(BeEmpty())
})
})
// Loading a plugin creates its host services — a KVStore or task queue database on disk — so a
// plugin that could never supply an image must not be instantiated just to be ignored.
It("does not load a plugin that is not in the agent list", func() {
mgr = newManager(nil)
Expect(mgr.LoadPlugins(GinkgoT().Context(), []string{"some-other-agent"}, false)).To(Succeed())
Expect(mgr.PluginNames(string(CapabilityMetadataAgent))).To(BeEmpty())
})
It("does nothing when no agents are configured", func() {
mgr = newManager(nil)
Expect(mgr.LoadPlugins(GinkgoT().Context(), nil, false)).To(Succeed())
Expect(mgr.PluginNames(string(CapabilityMetadataAgent))).To(BeEmpty())
// Not even the wazero cache: with nothing to load there is nothing to compile.
Expect(filepath.Join(tmpDir, "plugins")).ToNot(BeADirectory())
})
It("does nothing when the plugin system is disabled", func() {
mgr = newManager(nil)
conf.Server.Plugins.Enabled = false
Expect(mgr.LoadPlugins(GinkgoT().Context(), []string{"test-metadata-agent", "broken"}, false)).To(Succeed())
Expect(mgr.PluginNames(string(CapabilityMetadataAgent))).To(BeEmpty())
})
})

View File

@ -155,11 +155,13 @@
"newPassword": "Nova Senha",
"token": "Token",
"lastAccessAt": "Últ. Acesso",
"libraries": "Bibliotecas"
"libraries": "Bibliotecas",
"scrobbleFilter": "Filtro de scrobble"
},
"helperTexts": {
"name": "Alterações no seu nome só serão refletidas no próximo login",
"libraries": "Selecione bibliotecas específicas para este usuário, ou deixe vazio para usar bibliotecas padrão"
"libraries": "Selecione bibliotecas específicas para este usuário, ou deixe vazio para usar bibliotecas padrão",
"scrobbleFilter": "Músicas que correspondem a estas regras de playlist inteligente não são enviadas ao Last.fm, ListenBrainz ou plugins de scrobble. Usa a mesma sintaxe JSON e o mesmo comportamento das playlists inteligentes. Exemplo: {\"all\":[{\"lt\":{\"rating\":4}}]}. Deixe vazio para enviar scrobbles de todas as músicas. As contagens de reprodução locais não são afetadas."
},
"notifications": {
"created": "Novo usuário criado",
@ -173,7 +175,8 @@
"adminAutoLibraries": "Usuários administradores têm acesso automático a todas as bibliotecas"
},
"validation": {
"librariesRequired": "Pelo menos uma biblioteca deve ser selecionada para usuários não-administradores"
"librariesRequired": "Pelo menos uma biblioteca deve ser selecionada para usuários não-administradores",
"invalidScrobbleFilter": "Devem ser regras válidas de playlist inteligente. Limite, deslocamento e intervalo de atualização não são suportados."
}
},
"player": {

View File

@ -37,6 +37,9 @@ types:
.webp: image/webp
.png: image/png
.bmp: image/bmp
.jxl: image/jxl
.heic: image/heic
.heif: image/heif
# List of audio formats that are considered lossless
lossless:

View File

@ -10,21 +10,24 @@ import (
"slices"
"time"
"github.com/navidrome/navidrome/core/artwork"
"github.com/navidrome/navidrome/core/playlists"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/utils/chrono"
)
func newFolderEntry(job *scanJob, id, path string, updTime time.Time, hash string) *folderEntry {
func newFolderEntry(job *scanJob, id, path string, info model.FolderUpdateInfo) *folderEntry {
f := &folderEntry{
id: id,
job: job,
path: path,
audioFiles: make(map[string]fs.DirEntry),
imageFiles: make(map[string]fs.DirEntry),
albumIDMap: make(map[string]string),
updTime: updTime,
prevHash: hash,
id: id,
job: job,
path: path,
audioFiles: make(map[string]fs.DirEntry),
imageFiles: make(map[string]fs.DirEntry),
albumIDMap: make(map[string]string),
updTime: info.UpdatedAt,
prevHash: info.Hash,
prevImageFiles: info.ImageFiles,
prevImagesUpdatedAt: info.ImagesUpdatedAt,
}
return f
}
@ -42,12 +45,15 @@ type folderEntry struct {
numSubFolders int
imagesUpdatedAt time.Time
prevHash string // Previous hash from DB
tracks model.MediaFiles
albums model.Albums
albumIDMap map[string]string
artists model.Artists
tags model.TagList
missingTracks []*model.MediaFile
// Previous image state from DB, to detect image-only changes
prevImageFiles []string
prevImagesUpdatedAt time.Time
tracks model.MediaFiles
albums model.Albums
albumIDMap map[string]string
artists model.Artists
tags model.TagList
missingTracks []*model.MediaFile
}
func (f *folderEntry) hasNoFiles() bool {
@ -69,6 +75,21 @@ func (f *folderEntry) isOutdated() bool {
return f.prevHash != f.hash()
}
// imagesChanged reports whether the folder's image files differ from the previously persisted
// state, and whether an artist image is involved (present in the old or the new list).
func (f *folderEntry) imagesChanged() (changed, artistImage bool) {
newNames := slices.Sorted(maps.Keys(f.imageFiles))
prevNames := slices.Sorted(slices.Values(f.prevImageFiles))
// Both empty also skips the timestamp check, which is noise for image-less folders.
if len(prevNames) == 0 && len(newNames) == 0 {
return false, false
}
if slices.Equal(prevNames, newNames) && f.prevImagesUpdatedAt.Equal(f.imagesUpdatedAt) {
return false, false
}
return true, slices.ContainsFunc(slices.Concat(prevNames, newNames), artwork.IsArtistImageFile)
}
func (f *folderEntry) toFolder() *model.Folder {
folder := model.NewFolder(f.job.lib, f.path)
folder.NumAudioFiles = len(f.audioFiles)

View File

@ -41,7 +41,7 @@ var _ = Describe("folder_entry", func() {
Hash: "previous-hash",
}
entry := newFolderEntry(job, folderID, path, updateInfo.UpdatedAt, updateInfo.Hash)
entry := newFolderEntry(job, folderID, path, updateInfo)
Expect(entry.id).To(Equal(folderID))
Expect(entry.job).To(Equal(job))
@ -76,7 +76,7 @@ var _ = Describe("folder_entry", func() {
BeforeEach(func() {
folderID := model.FolderID(lib, path)
entry = newFolderEntry(job, folderID, path, time.Time{}, "")
entry = newFolderEntry(job, folderID, path, model.FolderUpdateInfo{})
})
Describe("hasNoFiles", func() {
@ -457,7 +457,7 @@ var _ = Describe("folder_entry", func() {
// Create new folder entry
folderPath := "music/rock/album"
folderID := model.FolderID(lib, folderPath)
entry := newFolderEntry(job, folderID, folderPath, time.Time{}, "")
entry := newFolderEntry(job, folderID, folderPath, model.FolderUpdateInfo{})
// Initially new and has no files
Expect(entry.isNew()).To(BeTrue())

112
scanner/image_changes.go Normal file
View File

@ -0,0 +1,112 @@
package scanner
import (
"context"
"sync"
"github.com/navidrome/navidrome/consts"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model"
)
// imageChangedFolder records a folder whose image files changed during the scan, so the
// affected albums/artists can be re-enqueued for artwork resolution at the end of phase 1.
type imageChangedFolder struct {
id string
path string
artistImage bool
}
// imageChangeCollector gathers those folders per library while phase 1 persists them, then turns
// them into artwork queue items once.
type imageChangeCollector struct {
libs map[int]model.Library
folders map[int][]imageChangedFolder
ds model.DataStore
mutex sync.Mutex
}
func (c *imageChangeCollector) record(lib model.Library, folder imageChangedFolder) {
c.mutex.Lock()
defer c.mutex.Unlock()
if c.folders == nil {
c.folders = map[int][]imageChangedFolder{}
c.libs = map[int]model.Library{}
}
c.libs[lib.ID] = lib
c.folders[lib.ID] = append(c.folders[lib.ID], folder)
}
// enqueue is best-effort: failures are logged and never fail the scan.
func (c *imageChangeCollector) enqueue(ctx context.Context) {
c.mutex.Lock()
foldersMap, libsMap := c.folders, c.libs
c.folders, c.libs = nil, nil
c.mutex.Unlock()
for libID, folders := range foldersMap {
lib := libsMap[libID]
items, err := c.queueItems(ctx, lib, folders)
if err != nil {
log.Warn(ctx, "Scanner: could not map image changes to artwork items", "lib", lib.Name, err)
continue
}
if len(items) == 0 {
continue
}
if err := c.ds.ArtworkQueue(ctx).Enqueue(items...); err != nil {
log.Warn(ctx, "Scanner: could not enqueue artwork for image changes", "lib", lib.Name, err)
continue
}
log.Debug(ctx, "Scanner: Enqueued artwork resolution for image changes", "lib", lib.Name,
"changedFolders", len(folders), "items", len(items))
}
}
func (c *imageChangeCollector) queueItems(ctx context.Context, lib model.Library,
folders []imageChangedFolder,
) ([]model.ArtworkQueueItem, error) {
folderIDs := make([]string, len(folders))
var artistFolderPaths []string
for i, f := range folders {
folderIDs[i] = f.id
if f.artistImage {
artistFolderPaths = append(artistFolderPaths, f.path)
}
}
var items []model.ArtworkQueueItem
albumIDs, err := c.ds.MediaFile(ctx).GetAlbumIDsByFolder(lib, folderIDs...)
if err != nil {
return nil, err
}
for _, id := range albumIDs {
items = append(items, scanArtworkItem(model.KindAlbumArtwork, id))
}
if len(artistFolderPaths) == 0 {
return items, nil
}
// The resolver climbs to the library root, so the subtree below the folder is the affected set.
// A failure here must not discard the album items already collected.
artistIDs, err := c.ds.Album(ctx).GetSoleAlbumArtistIDsInSubtrees(lib, artistFolderPaths...)
if err != nil {
log.Warn(ctx, "Scanner: could not map image changes to artists", "lib", lib.Name, err)
return items, nil
}
for _, id := range artistIDs {
if id == "" || id == consts.UnknownArtistID || id == consts.VariousArtistsID {
continue
}
items = append(items, scanArtworkItem(model.KindArtistArtwork, id))
}
return items, nil
}
func scanArtworkItem(kind model.Kind, id string) model.ArtworkQueueItem {
return model.ArtworkQueueItem{
ItemKind: kind.Prefix(), ItemID: id, ImageType: model.ImageTypePrimary,
Priority: model.ArtworkPriorityScan,
}
}

View File

@ -45,7 +45,7 @@ func createPhaseFolders(ctx context.Context, state *scanState, ds model.DataStor
jobs = append(jobs, job)
}
return &phaseFolders{jobs: jobs, ctx: ctx, ds: ds, state: state}
return &phaseFolders{jobs: jobs, ctx: ctx, ds: ds, state: state, imageChanges: &imageChangeCollector{ds: ds}}
}
type scanJob struct {
@ -105,7 +105,7 @@ func (j *scanJob) popLastUpdate(folderID string) model.FolderUpdateInfo {
func (j *scanJob) createFolderEntry(path string) *folderEntry {
id := model.FolderID(j.lib, path)
info := j.popLastUpdate(id)
return newFolderEntry(j, id, path, info.UpdatedAt, info.Hash)
return newFolderEntry(j, id, path, info)
}
// phaseFolders represents the first phase of the scanning process, which is responsible
@ -125,6 +125,7 @@ type phaseFolders struct {
ctx context.Context
state *scanState
prevAlbumPIDConf string
imageChanges *imageChangeCollector
}
func (p *phaseFolders) description() string {
@ -169,8 +170,10 @@ func (p *phaseFolders) producer() ppl.Producer[*folderEntry] {
// Check if folder is outdated
if folder.isOutdated() {
if !p.state.fullScan {
if folder.hasNoFiles() && folder.isNew() {
log.Trace(p.ctx, "Scanner: Skipping new folder with no files", "folder", folder.path, "lib", job.lib.Name)
// Ancestor folders need a row even with no files of their own: artwork
// resolution climbs them, and an image added later needs a state to diff.
if folder.isEmpty() && folder.isNew() {
log.Trace(p.ctx, "Scanner: Skipping new empty folder", "folder", folder.path, "lib", job.lib.Name)
continue
}
log.Debug(p.ctx, "Scanner: Detected changes in folder", "folder", folder.path, "lastUpdate", folder.modTime, "lib", job.lib.Name)
@ -197,7 +200,8 @@ func (p *phaseFolders) measure(entry *folderEntry) func() time.Duration {
func (p *phaseFolders) stages() []ppl.Stage[*folderEntry] {
return []ppl.Stage[*folderEntry]{
ppl.NewStage(p.processFolder, ppl.Name("process folder"), ppl.Concurrency(conf.Server.DevScannerThreads)),
ppl.NewStage(p.persistChanges, ppl.Name("persist changes")),
// persistChanges is not reentrant, so it always has to run with concurrency=1
ppl.NewStage(p.persistChanges, ppl.Name("persist changes"), ppl.Concurrency(1)),
ppl.NewStage(p.logFolder, ppl.Name("log results")),
}
}
@ -339,6 +343,15 @@ func (p *phaseFolders) persistChanges(entry *folderEntry) (*folderEntry, error)
albumRepo := tx.Album(p.ctx)
mfRepo := tx.MediaFile(p.ctx)
// A new folder's albums/artists are enqueued below; only pre-existing folders need the diff.
if !entry.isNew() {
if changed, artistImage := entry.imagesChanged(); changed {
p.imageChanges.record(entry.job.lib, imageChangedFolder{
id: entry.id, path: entry.path, artistImage: artistImage,
})
}
}
// Save folder to DB
folder := entry.toFolder()
err := folderRepo.Put(folder)
@ -368,10 +381,7 @@ func (p *phaseFolders) persistChanges(entry *folderEntry) (*folderEntry, error)
return err
}
if entry.artists[i].Name != consts.UnknownArtist && entry.artists[i].Name != consts.VariousArtists {
queueItems = append(queueItems, model.ArtworkQueueItem{
ItemKind: model.KindArtistArtwork.Prefix(), ItemID: entry.artists[i].ID, ImageType: model.ImageTypePrimary,
Priority: model.ArtworkPriorityScan,
})
queueItems = append(queueItems, scanArtworkItem(model.KindArtistArtwork, entry.artists[i].ID))
}
}
@ -383,10 +393,7 @@ func (p *phaseFolders) persistChanges(entry *folderEntry) (*folderEntry, error)
return err
}
if entry.albums[i].Name != consts.UnknownAlbum {
queueItems = append(queueItems, model.ArtworkQueueItem{
ItemKind: model.KindAlbumArtwork.Prefix(), ItemID: entry.albums[i].ID, ImageType: model.ImageTypePrimary,
Priority: model.ArtworkPriorityScan,
})
queueItems = append(queueItems, scanArtworkItem(model.KindAlbumArtwork, entry.albums[i].ID))
}
}
@ -518,6 +525,7 @@ func (p *phaseFolders) finalize(err error) error {
}
return nil
}, "scanner: finalize phaseFolders")
p.imageChanges.enqueue(p.ctx)
return errors.Join(err, errF)
}

View File

@ -278,6 +278,131 @@ var _ = Describe("Scanner", Ordered, func() {
})
})
Context("Library with image files", func() {
var fsys storagetest.FakeFS
image := func(data string) *fstest.MapFile { return &fstest.MapFile{Data: []byte(data)} }
albumID := func(name string) string {
GinkgoHelper()
albums, err := ds.Album(ctx).GetAll(model.QueryOptions{Filters: squirrel.Eq{"album.name": name}})
Expect(err).ToNot(HaveOccurred())
Expect(albums).To(HaveLen(1))
return albums[0].ID
}
artistID := func(name string) string {
GinkgoHelper()
artists, err := ds.Artist(ctx).GetAll(model.QueryOptions{Filters: squirrel.Eq{"artist.name": name}})
Expect(err).ToNot(HaveOccurred())
Expect(artists).To(HaveLen(1))
return artists[0].ID
}
queuedItems := func() []model.ArtworkQueueItem {
GinkgoHelper()
queued, err := ds.ArtworkQueue(ctx).DequeueBatch(1000)
Expect(err).ToNot(HaveOccurred())
return queued
}
queueItemFor := func(kind, id string) OmegaMatcher {
return ContainElement(SatisfyAll(
HaveField("ItemKind", kind),
HaveField("ItemID", id),
HaveField("Priority", model.ArtworkPriorityScan),
))
}
BeforeEach(func() {
revolver := template(_t{"albumartist": "The Beatles", "album": "Revolver", "year": 1966})
wall := template(_t{"albumartist": "Pink Floyd", "album": "The Wall", "year": 1979})
fsys = createFS(fstest.MapFS{
"The Beatles/artist.jpg": image("beatles-artist-v1"),
"The Beatles/Revolver/cover.jpg": image("revolver-cover-v1"),
"The Beatles/Revolver/01 - Taxman.mp3": revolver(track(1, "Taxman")),
"Pink Floyd/The Wall/cover.jpg": image("wall-cover-v1"),
"Pink Floyd/The Wall/CD1/01 - In the Flesh.mp3": wall(track(1, "In the Flesh?")),
"Pink Floyd/The Wall/CD2/01 - Hey You.mp3": wall(track(1, "Hey You")),
})
Expect(runScanner(ctx, true)).To(Succeed())
resolveQueuedArtwork()
})
It("re-enqueues only the album whose cover was replaced in place", func() {
fsys.Add("The Beatles/Revolver/cover.jpg", image("revolver-cover-v2"))
Expect(runScanner(ctx, false)).To(Succeed())
queued := queuedItems()
Expect(queued).To(queueItemFor("al", albumID("Revolver")))
Expect(queued).ToNot(ContainElement(HaveField("ItemID", albumID("The Wall"))))
Expect(queued).ToNot(ContainElement(HaveField("ItemKind", "ar")))
})
It("re-enqueues the album when the cover above its disc folders changes", func() {
fsys.Add("Pink Floyd/The Wall/cover.jpg", image("wall-cover-v2"))
Expect(runScanner(ctx, false)).To(Succeed())
Expect(queuedItems()).To(queueItemFor("al", albumID("The Wall")))
})
It("re-enqueues the album when its cover is removed", func() {
fsys.Remove("The Beatles/Revolver/cover.jpg")
Expect(runScanner(ctx, false)).To(Succeed())
Expect(queuedItems()).To(queueItemFor("al", albumID("Revolver")))
})
It("enqueues the artist when an artist image is added to their folder", func() {
fsys.Add("Pink Floyd/artist.jpg", image("floyd-artist-v1"))
Expect(runScanner(ctx, false)).To(Succeed())
queued := queuedItems()
Expect(queued).To(queueItemFor("ar", artistID("Pink Floyd")))
Expect(queued).ToNot(ContainElement(HaveField("ItemID", artistID("The Beatles"))))
})
It("re-enqueues the artist when their artist image is replaced in place", func() {
fsys.Add("The Beatles/artist.jpg", image("beatles-artist-v2"))
Expect(runScanner(ctx, false)).To(Succeed())
Expect(queuedItems()).To(queueItemFor("ar", artistID("The Beatles")))
})
It("enqueues every artist under the folder when a shared artist image is added", func() {
fsys.Add("artist.png", image("shared-artist-v1"))
Expect(runScanner(ctx, false)).To(Succeed())
queued := queuedItems()
Expect(queued).To(queueItemFor("ar", artistID("The Beatles")))
Expect(queued).To(queueItemFor("ar", artistID("Pink Floyd")))
})
It("enqueues the artist when an image lands in a folder first seen by a quick scan", func() {
// A quick scan must persist an artist folder that holds only subfolders, or the
// artist.jpg added later has no previous state to diff against.
kraftwerk := template(_t{"albumartist": "Kraftwerk", "album": "Autobahn", "year": 1974})
files := fsys.MapFS
files["Kraftwerk/Autobahn/01 - Autobahn.mp3"] = kraftwerk(track(1, "Autobahn"))
fsys.SetFiles(files)
Expect(runScanner(ctx, false)).To(Succeed())
resolveQueuedArtwork()
fsys.Add("Kraftwerk/artist.jpg", image("kraftwerk-artist-v1"))
Expect(runScanner(ctx, false)).To(Succeed())
Expect(queuedItems()).To(queueItemFor("ar", artistID("Kraftwerk")))
})
It("does not enqueue anything on a repeat full scan with no image changes", func() {
Expect(runScanner(ctx, true)).To(Succeed())
Expect(queuedItems()).To(BeEmpty())
})
})
Context("Artist with atomic non-ASCII letters, 'GØGGS'", func() {
BeforeEach(func() {
goggs := template(_t{"albumartist": "GØGGS", "album": "Pre Strike Sweep", "year": 2018})

View File

@ -112,7 +112,7 @@ returns direct children only (no tracks — no track is a library's direct child
| Area | Endpoints |
|---|---|
| Handshake / system | `GET System/Info/Public`, `GET System/Info` (authenticated), `GET`/`POST System/Ping`, `GET QuickConnect/Enabled` |
| Handshake / system | `GET System/Info/Public`, `GET System/Info` (authenticated), `GET`/`POST System/Ping`, `GET System/Endpoint` (authenticated), `GET QuickConnect/Enabled` |
| Auth | `POST Users/AuthenticateByName`, `GET Users/Public` |
| Users | `GET UserViews`, `GET Users/{userId}/Views`, `GET Users/Me`, `GET Users/{userId}` |
| Browsing | `GET Items`, `GET Users/{userId}/Items`, `GET Items/{itemId}`, `GET Users/{userId}/Items/{itemId}`, `GET Users/{userId}/Items/Latest`, `DELETE Items/{itemId}` (playlists only) |

View File

@ -104,6 +104,7 @@ func (api *Router) routes() http.Handler {
// player) even before the first playback report.
r.Use(api.withPlayer)
r.Get("/system/info", api.getSystemInfo)
r.Get("/system/endpoint", api.getEndpointInfo)
r.Get("/userviews", api.getUserViews)
r.Get("/users/{userId}/views", api.getUserViews)
r.Get("/users/me", api.getCurrentUser)

View File

@ -20,6 +20,12 @@ type SystemInfo struct {
CachePath string `json:"CachePath,omitempty"`
}
// EndPointInfo describes the caller's network location (GET /System/Endpoint).
type EndPointInfo struct {
IsLocal bool `json:"IsLocal"`
IsInNetwork bool `json:"IsInNetwork"`
}
type NameGuidPair struct {
Name string `json:"Name"`
Id string `json:"Id"`

View File

@ -51,6 +51,22 @@ var _ = Describe("System", func() {
})
})
Describe("GET /System/Endpoint", func() {
It("always reports IsInNetwork, which Finamp's connection test probes for", func() {
w := getAs(regularUser, "/System/Endpoint")
Expect(w.Code).To(Equal(http.StatusOK))
var info map[string]any
parseInto(w, &info)
Expect(info).To(HaveKey("IsInNetwork"))
Expect(info).To(HaveKey("IsLocal"))
})
It("rejects unauthenticated requests", func() {
w := rawReq("GET", "/System/Endpoint", "")
Expect(w.Code).To(Equal(http.StatusUnauthorized))
})
})
Describe("GET/POST /System/Ping", func() {
It("answers GET with a plain-text server name", func() {
w := rawReq("GET", "/System/Ping", "")

View File

@ -5,7 +5,9 @@ import (
"encoding/hex"
"errors"
"fmt"
"net"
"net/http"
"net/netip"
"path"
"strings"
@ -108,6 +110,48 @@ func (api *Router) ping(w http.ResponseWriter, r *http.Request) {
_, _ = w.Write([]byte(api.serverName()))
}
// getEndpointInfo answers /System/Endpoint, which Finamp's connection test uses to pick between a
// dual-connection setup's addresses; a missing IsInNetwork reads to it as "not a Jellyfin server".
func (api *Router) getEndpointInfo(w http.ResponseWriter, r *http.Request) {
remote := remoteIP(r)
api.ok(w, r, dto.EndPointInfo{
IsLocal: isSameMachine(r, remote),
IsInNetwork: isInLocalNetwork(remote),
})
}
// isInLocalNetwork mirrors Jellyfin's default LAN set (NetworkManager.UpdateSettings with no
// LocalNetworkSubnets configured): loopback, the RFC 1918 ranges, fc00::/7 and fe80::/10.
func isInLocalNetwork(ip netip.Addr) bool {
return ip.IsLoopback() || ip.IsPrivate() || (ip.Is6() && ip.IsLinkLocalUnicast())
}
// isSameMachine mirrors Jellyfin's HttpContext.IsLocal(): the caller shares the connection's local
// address. The local address is missing in tests and unreliable behind a proxy, so fall back to loopback.
func isSameMachine(r *http.Request, remote netip.Addr) bool {
local, ok := r.Context().Value(http.LocalAddrContextKey).(net.Addr)
if !ok {
return remote.IsLoopback()
}
return parseIP(local.String()) == remote
}
// remoteIP parses RemoteAddr, which the RealIP middleware may have rewritten to a bare IP.
func remoteIP(r *http.Request) netip.Addr {
return parseIP(r.RemoteAddr)
}
func parseIP(addr string) netip.Addr {
if h, _, err := net.SplitHostPort(addr); err == nil {
addr = h
}
ip, err := netip.ParseAddr(addr)
if err != nil {
return netip.Addr{}
}
return ip.Unmap()
}
func (api *Router) quickConnectEnabled(w http.ResponseWriter, r *http.Request) {
api.ok(w, r, false)
}

View File

@ -4,6 +4,7 @@ import (
"context"
"encoding/json"
"errors"
"net"
"net/http"
"net/http/httptest"
@ -88,6 +89,61 @@ var _ = Describe("System", func() {
Expect(w.Body.String()).To(HavePrefix("Navidrome"))
})
DescribeTable("reports the caller's network location on /System/Endpoint",
func(remoteAddr string, isLocal, isInNetwork bool) {
w := httptest.NewRecorder()
r := httptest.NewRequest("GET", "/System/Endpoint", nil)
r.RemoteAddr = remoteAddr
api.getEndpointInfo(w, r)
Expect(w.Code).To(Equal(http.StatusOK))
// Finamp's connection test only probes for the key's presence, so it must always be emitted.
Expect(w.Body.String()).To(ContainSubstring(`"IsInNetwork"`))
var info dto.EndPointInfo
Expect(json.Unmarshal(w.Body.Bytes(), &info)).To(Succeed())
Expect(info.IsLocal).To(Equal(isLocal))
Expect(info.IsInNetwork).To(Equal(isInNetwork))
},
Entry("loopback", "127.0.0.1:12345", true, true),
Entry("IPv6 loopback", "[::1]:12345", true, true),
Entry("LAN address", "192.168.1.20:54321", false, true),
Entry("bare IP, as left by the RealIP middleware", "10.0.0.5", false, true),
Entry("IPv4-mapped IPv6 LAN address", "[::ffff:172.16.0.9]:80", false, true),
Entry("IPv6 link-local", "[fe80::1]:80", false, true),
Entry("IPv6 unique-local", "[fd00::1]:80", false, true),
// Jellyfin's default LAN set omits 169.254.0.0/16, so we do too.
Entry("IPv4 link-local", "169.254.1.1:80", false, false),
Entry("public address", "8.8.8.8:443", false, false),
Entry("unparseable address", "not-an-ip", false, false),
)
It("reports IsLocal when the caller shares the connection's local address", func() {
w := httptest.NewRecorder()
r := httptest.NewRequest("GET", "/System/Endpoint", nil)
r.RemoteAddr = "192.168.1.20:54321"
ctx := context.WithValue(r.Context(), http.LocalAddrContextKey,
&net.TCPAddr{IP: net.ParseIP("192.168.1.20"), Port: 4533})
api.getEndpointInfo(w, r.WithContext(ctx))
var info dto.EndPointInfo
Expect(json.Unmarshal(w.Body.Bytes(), &info)).To(Succeed())
Expect(info.IsLocal).To(BeTrue())
})
It("does not report IsLocal for a different host on the same LAN", func() {
w := httptest.NewRecorder()
r := httptest.NewRequest("GET", "/System/Endpoint", nil)
r.RemoteAddr = "192.168.1.99:54321"
ctx := context.WithValue(r.Context(), http.LocalAddrContextKey,
&net.TCPAddr{IP: net.ParseIP("192.168.1.20"), Port: 4533})
api.getEndpointInfo(w, r.WithContext(ctx))
var info dto.EndPointInfo
Expect(json.Unmarshal(w.Body.Bytes(), &info)).To(Succeed())
Expect(info.IsLocal).To(BeFalse())
Expect(info.IsInNetwork).To(BeTrue())
})
It("reports quick connect as disabled", func() {
w := httptest.NewRecorder()
r := httptest.NewRequest("GET", "/QuickConnect/Enabled", nil)

View File

@ -10,14 +10,6 @@ import (
"github.com/navidrome/navidrome/model"
)
var refreshableArtworkKinds = []model.Kind{
model.KindAlbumArtwork,
model.KindArtistArtwork,
model.KindPlaylistArtwork,
model.KindRadioArtwork,
model.KindMediaFileArtwork,
}
func (api *Router) addArtworkRoute(r chi.Router) {
r.Post("/artwork/{kind}/{id}/refresh", api.refreshArtwork())
}
@ -28,7 +20,7 @@ func (api *Router) refreshArtwork() http.HandlerFunc {
ctx := r.Context()
kind, _ := model.ParseKind(chi.URLParam(r, "kind"))
id := chi.URLParam(r, "id")
if !slices.Contains(refreshableArtworkKinds, kind) {
if !slices.Contains(artwork.RefreshableKinds, kind) {
http.Error(w, "invalid artwork kind", http.StatusBadRequest)
return
}

View File

@ -7,6 +7,7 @@ import (
"time"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/utils/slice"
)
type MockArtworkQueueRepo struct {
@ -25,6 +26,19 @@ func CreateMockArtworkQueueRepo() *MockArtworkQueueRepo {
return &MockArtworkQueueRepo{Data: map[string]model.ArtworkQueueItem{}}
}
func (m *MockArtworkQueueRepo) Get(kind model.Kind, id, imageType string) (*model.ArtworkQueueItem, error) {
m.mu.Lock()
defer m.mu.Unlock()
if m.Err != nil {
return nil, m.Err
}
it, ok := m.Data[iaKey(kind.Prefix(), id, imageType)]
if !ok {
return nil, model.ErrNotFound
}
return &it, nil
}
func (m *MockArtworkQueueRepo) Enqueue(items ...model.ArtworkQueueItem) error {
m.mu.Lock()
defer m.mu.Unlock()
@ -161,6 +175,49 @@ func (m *MockArtworkQueueRepo) Count() (int64, error) {
return int64(len(m.Data)), nil
}
func (m *MockArtworkQueueRepo) CountByKindAndPriority() ([]model.ArtworkQueueStat, error) {
m.mu.Lock()
defer m.mu.Unlock()
if m.Err != nil {
return nil, m.Err
}
var res []model.ArtworkQueueStat
for _, it := range m.Data {
i := slices.IndexFunc(res, func(s model.ArtworkQueueStat) bool {
return s.ItemKind == it.ItemKind && s.Priority == it.Priority
})
if i < 0 {
res = append(res, model.ArtworkQueueStat{ItemKind: it.ItemKind, Priority: it.Priority, Count: 1})
continue
}
res[i].Count++
}
slices.SortFunc(res, func(a, b model.ArtworkQueueStat) int {
return cmp.Or(cmp.Compare(a.ItemKind, b.ItemKind), cmp.Compare(b.Priority, a.Priority))
})
return res, nil
}
// CountAbsent mirrors the SQL predicate: an absent state is one with no hash.
func (m *MockArtworkQueueRepo) CountAbsent(kind model.Kind, attemptedBefore time.Time) (model.ArtworkAbsentStat, error) {
m.mu.Lock()
defer m.mu.Unlock()
var res model.ArtworkAbsentStat
if m.Err != nil || m.ItemArtworkSource == nil {
return res, m.Err
}
for _, ia := range m.ItemArtworkSource.ItemData {
if ia.ItemKind != kind.Prefix() || ia.Hash != "" {
continue
}
res.Total++
if ia.AttemptedAt.Before(attemptedBefore) {
res.Stale++
}
}
return res, nil
}
func (m *MockArtworkQueueRepo) EnqueuePreservingBackoff(items ...model.ArtworkQueueItem) error {
m.mu.Lock()
defer m.mu.Unlock()
@ -216,6 +273,65 @@ func (m *MockArtworkQueueRepo) EnqueueStaleAbsent(kind model.Kind, attemptedBefo
return inserted, nil
}
// matchingSource mirrors the SQL filter: no sources means every source, "" the absent state.
func (m *MockArtworkQueueRepo) matchingSource(kind model.Kind, sources []string) []model.ItemArtwork {
if m.ItemArtworkSource == nil {
return nil
}
var res []model.ItemArtwork
for _, ia := range m.ItemArtworkSource.ItemData {
if ia.ItemKind == kind.Prefix() && (len(sources) == 0 || slices.Contains(sources, ia.Source)) {
res = append(res, ia)
}
}
return res
}
func (m *MockArtworkQueueRepo) CountBySource(kind model.Kind, sources []string) (int64, error) {
m.mu.Lock()
defer m.mu.Unlock()
if m.Err != nil {
return 0, m.Err
}
return int64(len(m.matchingSource(kind, sources))), nil
}
func (m *MockArtworkQueueRepo) SourcesInUse(kind model.Kind) ([]string, error) {
m.mu.Lock()
defer m.mu.Unlock()
if m.Err != nil {
return nil, m.Err
}
sources := slice.Map(m.matchingSource(kind, nil), func(ia model.ItemArtwork) string { return ia.Source })
return slice.Unique(sources), nil
}
func (m *MockArtworkQueueRepo) EnqueueBySource(kind model.Kind, sources []string, priority int) (int64, error) {
m.mu.Lock()
defer m.mu.Unlock()
if m.Err != nil {
return 0, m.Err
}
now := time.Now()
var inserted int64
for _, ia := range m.matchingSource(kind, sources) {
k := iaKey(ia.ItemKind, ia.ItemID, ia.ImageType)
if _, ok := m.Data[k]; ok { // DO NOTHING: never touch existing queue rows
continue
}
m.Data[k] = model.ArtworkQueueItem{
ItemKind: ia.ItemKind,
ItemID: ia.ItemID,
ImageType: ia.ImageType,
Priority: priority,
RetryAt: now,
EnqueuedAt: now,
}
inserted++
}
return inserted, nil
}
// EnqueueMissing mirrors the SQL set-difference insert: ExistingIDs[kind] minus ItemArtworkSource.
func (m *MockArtworkQueueRepo) EnqueueAllMissing(kind model.Kind, priority int) (int64, error) {
m.mu.Lock()

View File

@ -9,6 +9,7 @@ import (
"github.com/deluan/rest"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/model/criteria"
"github.com/navidrome/navidrome/model/id"
"github.com/navidrome/navidrome/utils/slice"
)
@ -31,6 +32,8 @@ type MockMediaFileRepo struct {
// Add fields for cross-library move detection tests
FindRecentFilesByMBZTrackIDFunc func(missing model.MediaFile, since time.Time) (model.MediaFiles, error)
FindRecentFilesByPropertiesFunc func(missing model.MediaFile, since time.Time) (model.MediaFiles, error)
MatchesCriteriaValue bool
MatchesCriteriaErr error
}
func (m *MockMediaFileRepo) SetError(err bool) {
@ -369,5 +372,12 @@ func (m *MockMediaFileRepo) FindRecentFilesByProperties(missing model.MediaFile,
return result, nil
}
func (m *MockMediaFileRepo) MatchesCriteria(string, criteria.Criteria) (bool, error) {
if m.MatchesCriteriaErr != nil {
return false, m.MatchesCriteriaErr
}
return m.MatchesCriteriaValue, nil
}
var _ model.MediaFileRepository = (*MockMediaFileRepo)(nil)
var _ model.ResourceRepository = (*MockMediaFileRepo)(nil)

View File

@ -156,11 +156,13 @@
"currentPassword": "Current Password",
"newPassword": "New Password",
"token": "Token",
"libraries": "Libraries"
"libraries": "Libraries",
"scrobbleFilter": "Scrobble filter"
},
"helperTexts": {
"name": "Changes to your name will only be reflected on next login",
"libraries": "Select specific libraries for this user, or leave empty to use default libraries"
"libraries": "Select specific libraries for this user, or leave empty to use default libraries",
"scrobbleFilter": "Songs matching these smart playlist rules are not sent to Last.fm, ListenBrainz or scrobbler plugins. Uses the same JSON syntax and behavior as smart playlists. Example: {\"all\":[{\"lt\":{\"rating\":4}}]}. Leave empty to scrobble everything. Local play counts are not affected."
},
"notifications": {
"created": "User created",
@ -168,7 +170,8 @@
"deleted": "User deleted"
},
"validation": {
"librariesRequired": "At least one library must be selected for non-admin users"
"librariesRequired": "At least one library must be selected for non-admin users",
"invalidScrobbleFilter": "Must be valid smart playlist rules. Limit, offset and refresh delay are not supported."
},
"message": {
"listenBrainzToken": "Enter your ListenBrainz user token.",

View File

@ -172,6 +172,17 @@ const UserEdit = (props) => {
</FormDataConsumer>
)}
<TextInput
source="scrobbleFilter"
multiline
minRows={2}
fullWidth
spellCheck={false}
style={{ maxWidth: '40em' }}
inputProps={{ style: { resize: 'vertical' } }}
helperText={translate('resources.user.helperTexts.scrobbleFilter')}
/>
<DateField variant="body1" source="lastLoginAt" showTime />
<DateField variant="body1" source="lastAccessAt" showTime />
<DateField variant="body1" source="updatedAt" showTime />

View File

@ -127,6 +127,12 @@ describe('<UserEdit />', () => {
expect(screen.getByTestId('date-field-createdAt')).toBeInTheDocument()
})
it('should render the scrobble filter input', () => {
render(<UserEdit id="user1" permissions="admin" />)
expect(screen.getByTestId('text-input-scrobbleFilter')).toBeInTheDocument()
})
it('should not render username input for non-admin users', () => {
render(<UserEdit id="user1" permissions="user" />)

View File

@ -15,5 +15,15 @@ export const validateUserForm = (values, translate) => {
}
}
if (values.scrobbleFilter && values.scrobbleFilter.trim() !== '') {
try {
JSON.parse(values.scrobbleFilter)
} catch {
errors.scrobbleFilter = translate(
'resources.user.validation.invalidScrobbleFilter',
)
}
}
return errors
}

View File

@ -67,4 +67,27 @@ describe('User Validation Utilities', () => {
)
})
})
describe('scrobbleFilter validation', () => {
it('accepts an empty filter', () => {
const errors = validateUserForm({ isAdmin: true }, mockTranslate)
expect(errors.scrobbleFilter).toBeUndefined()
})
it('accepts valid JSON', () => {
const errors = validateUserForm(
{ isAdmin: true, scrobbleFilter: '{"all":[{"lt":{"rating":4}}]}' },
mockTranslate,
)
expect(errors.scrobbleFilter).toBeUndefined()
})
it('rejects malformed JSON', () => {
const errors = validateUserForm(
{ isAdmin: true, scrobbleFilter: '{broken' },
mockTranslate,
)
expect(errors.scrobbleFilter).toEqual(
'resources.user.validation.invalidScrobbleFilter',
)
})
})
})