Merge branch 'master' into error-handling-and-response-bugs/5896

This commit is contained in:
Adrián Sánchez Zapico 2026-08-24 08:36:03 +02:00 committed by GitHub
commit 5f766827f9
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
72 changed files with 2646 additions and 429 deletions

View File

@ -68,6 +68,11 @@ runs:
- name: Set up Docker Buildx
id: buildx
uses: docker/setup-buildx-action@v4
with:
# Runner IPs are shared, so anonymous base image pulls get rate-limited.
buildkitd-config-inline: |
[registry."docker.io"]
mirrors = ["mirror.gcr.io"]
- name: Extract metadata for Docker image
id: meta

View File

@ -68,10 +68,16 @@ jobs:
with:
go-version-file: go.mod
# Keep CI on the same version `make lint` installs, so a clean local run
# cannot turn red in CI just because a new golangci-lint was released.
- name: Resolve golangci-lint version
id: golangci-version
run: echo "version=$(grep '^GOLANGCI_LINT_VERSION' Makefile | cut -d ' ' -f 3)" >> "$GITHUB_OUTPUT"
- name: golangci-lint
uses: golangci/golangci-lint-action@v9
with:
version: latest
version: ${{ steps.golangci-version.outputs.version }}
problem-matchers: true
args: --timeout 2m

View File

@ -2,7 +2,7 @@ FROM --platform=$BUILDPLATFORM ghcr.io/crazy-max/osxcross:14.5-debian AS osxcros
########################################################################################################################
### Build xx (original image: tonistiigi/xx)
FROM --platform=$BUILDPLATFORM public.ecr.aws/docker/library/alpine:3.20 AS xx-build
FROM --platform=$BUILDPLATFORM alpine:3.20 AS xx-build
# v1.9.0
ENV XX_VERSION=a5592eab7a57895e8d385394ff12241bc65ecd50
@ -26,7 +26,7 @@ COPY --from=xx-build /out/ /usr/bin/
########################################################################################################################
### Build Navidrome UI
FROM --platform=$BUILDPLATFORM public.ecr.aws/docker/library/node:lts-alpine AS ui
FROM --platform=$BUILDPLATFORM node:lts-alpine AS ui
WORKDIR /app
# Install node dependencies
@ -43,7 +43,7 @@ COPY --from=ui /build /build
########################################################################################################################
### Build Navidrome binary for Docker image (dynamic musl, enables native libwebp via dlopen)
FROM --platform=$BUILDPLATFORM public.ecr.aws/docker/library/golang:1.26-alpine AS build-alpine
FROM --platform=$BUILDPLATFORM golang:1.26-alpine AS build-alpine
COPY --from=xx / /
ARG TARGETPLATFORM
@ -85,7 +85,7 @@ EOT
########################################################################################################################
### Build Navidrome binary for standalone distribution (static glibc, cross-compiled)
FROM --platform=$BUILDPLATFORM public.ecr.aws/docker/library/golang:1.26-trixie AS base
FROM --platform=$BUILDPLATFORM golang:1.26-trixie AS base
RUN apt-get update && apt-get install -y clang lld
COPY --from=xx / /
WORKDIR /workspace
@ -154,7 +154,7 @@ COPY --from=build /out /
########################################################################################################################
### Build Final Image
FROM public.ecr.aws/docker/library/alpine:3.20 AS final
FROM alpine:3.20 AS final
LABEL maintainer="deluan@navidrome.org"
LABEL org.opencontainers.image.source="https://github.com/navidrome/navidrome"

View File

@ -214,5 +214,14 @@ var _ = Describe("auth_router", func() {
_, err = verifyLinkToken(nonExpiringToken)
Expect(err).To(MatchError("link token missing expiration"))
})
It("rejects a Jellyfin access token", func() {
usr := &model.User{ID: "u1", UserName: "johndoe"}
tokenStr, err := auth.CreateAPIToken(usr, auth.AudienceJellyfin)
Expect(err).ToNot(HaveOccurred())
_, err = verifyLinkToken(tokenStr)
Expect(err).To(HaveOccurred())
})
})
})

View File

@ -26,29 +26,41 @@ import (
var explainLive bool
// Only one subcommand runs per invocation, so reprocess and cancel bind the same flag targets.
var (
reprocessKinds []string
reprocessSources []string
reprocessAll bool
reprocessDryRun bool
reprocessYes bool
artworkKinds []string
artworkSources []string
artworkPriorities []string
artworkAll bool
artworkDryRun bool
artworkYes 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,
"walk the chain again now, performing real external lookups, instead of reporting the "+
"stored trace of the last resolution; also initializes plugin agents, which may open "+
"external connections")
artworkReprocessCmd.Flags().StringSliceVar(&artworkKinds, "kind", nil,
"kinds to reprocess ("+kindPrefixes(artwork.RecheckKinds)+"); repeatable")
artworkReprocessCmd.Flags().StringSliceVar(&reprocessSources, "source", nil,
artworkReprocessCmd.Flags().StringSliceVar(&artworkSources, "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,
artworkReprocessCmd.Flags().BoolVar(&artworkAll, "all", false, "reprocess every kind")
artworkReprocessCmd.Flags().BoolVar(&artworkDryRun, "dry-run", false,
"report what would be queued and exit without queueing")
artworkReprocessCmd.Flags().BoolVarP(&reprocessYes, "yes", "y", false, "skip the confirmation prompt")
artworkReprocessCmd.Flags().BoolVarP(&artworkYes, "yes", "y", false, "skip the confirmation prompt")
artworkCancelCmd.Flags().StringSliceVar(&artworkKinds, "kind", nil,
"kinds to cancel ("+kindPrefixes(artwork.RefreshableKinds)+"); repeatable")
artworkCancelCmd.Flags().StringSliceVar(&artworkPriorities, "priority", nil,
"only rows queued at these priorities ("+priorityNames()+"); repeatable")
artworkCancelCmd.Flags().BoolVar(&artworkAll, "all", false, "cancel every kind at every priority")
artworkCancelCmd.Flags().BoolVar(&artworkDryRun, "dry-run", false,
"report what would be cancelled and exit without cancelling")
artworkCancelCmd.Flags().BoolVarP(&artworkYes, "yes", "y", false, "skip the confirmation prompt")
artworkCmd.AddCommand(artworkExplainCmd)
artworkCmd.AddCommand(artworkRefreshCmd)
artworkCmd.AddCommand(artworkReprocessCmd)
artworkCmd.AddCommand(artworkCancelCmd)
artworkCmd.AddCommand(artworkStatusCmd)
rootCmd.AddCommand(artworkCmd)
}
@ -92,6 +104,22 @@ var artworkReprocessCmd = &cobra.Command{
},
}
var artworkCancelCmd = &cobra.Command{
Use: "cancel",
Short: "Cancel pending artwork work in bulk, by kind and/or queue priority",
Long: "Cancel pending artwork work in bulk, by kind and/or queue priority.\n\n" +
"Only the queue is touched: resolved artwork and the state behind `artwork explain` are\n" +
"left alone, and the trace of why a cancelled item last failed goes with its queue row.\n\n" +
"Work already picked up is not interrupted, and an item with no artwork yet can be\n" +
"queued again by the hourly re-check. The selection is applied again when you confirm,\n" +
"so anything queued after the preview is cancelled too. Use it to call off a bulk\n" +
"backfill, not to stop the worker.",
Args: cobra.NoArgs,
Run: func(cmd *cobra.Command, args []string) {
runCancel(cmd.Context())
},
}
var artworkStatusCmd = &cobra.Command{
Use: "status",
Short: "Report the artwork queue, where artwork resolves from, and the backfill state",
@ -132,9 +160,11 @@ type statusReport struct {
current string
}
func (r statusReport) queueTotal() int64 {
func (r statusReport) queueTotal() int64 { return queueTotal(r.queue) }
func queueTotal(stats []model.ArtworkQueueStat) int64 {
var n int64
for _, s := range r.queue {
for _, s := range stats {
n += s.Count
}
return n
@ -154,7 +184,7 @@ 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 {
if rep.queue, err = q.CountQueued(nil, nil); err != nil {
return rep, fmt.Errorf("breaking the artwork queue down by kind: %w", err)
}
@ -194,11 +224,7 @@ func formatStatus(rep statusReport) string {
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())
printQueueStats(w, rep.queue, rep.queueTotal(), "ITEMS", " ")
}
fmt.Fprintln(w, "\nSources")
@ -212,7 +238,8 @@ func formatStatus(rep statusReport) string {
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.Fprintf(w, " (eligible once the last attempt is older than %gh; re-queued %d per kind per hour, oldest first)\n",
artwork.StaleAbsentAge.Hours(), artwork.StaleAbsentRecheckBatch)
fmt.Fprintln(w, "\nBackfill")
fmt.Fprintf(w, " State:\t%s\n", backfillState(rep))
@ -245,6 +272,15 @@ func backfillState(rep statusReport) string {
return "up to date"
}
// printQueueStats writes the shared queue breakdown; the caller owns the tab writer and flushes it.
func printQueueStats(w io.Writer, stats []model.ArtworkQueueStat, total int64, countHeader, indent string) {
fmt.Fprintf(w, "%sKIND\tPRIORITY\t%s\n", indent, countHeader)
for _, s := range stats {
fmt.Fprintf(w, "%s%s\t%s\t%d\n", indent, kindName(s.ItemKind), priorityName(s.Priority), s.Count)
}
fmt.Fprintf(w, "%sTOTAL\t\t%d\n", indent, total)
}
func kindName(prefix string) string {
if k, ok := model.ParseKind(prefix); ok {
return k.String()
@ -252,22 +288,44 @@ func kindName(prefix string) string {
return prefix
}
type artworkPriority struct {
name string
value int
}
// knownPriorities is the one listing behind both the name and the parse, so they cannot drift.
var knownPriorities = []artworkPriority{
{"bump", model.ArtworkPriorityBump},
{"scan", model.ArtworkPriorityScan},
{"backfill", model.ArtworkPriorityBackfill},
{"recheck", model.ArtworkPriorityRecheck},
}
// priorityName falls back to the number: a row written by a newer version still has to print.
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"
for _, ap := range knownPriorities {
if ap.value == p {
return ap.name
}
}
return strconv.Itoa(p)
}
func priorityNames() string {
return strings.Join(slice.Map(knownPriorities, func(ap artworkPriority) string { return ap.name }), ", ")
}
func parseArtworkPriority(s string) (int, error) {
for _, ap := range knownPriorities {
if ap.name == s {
return ap.value, nil
}
}
return 0, fmt.Errorf("invalid priority %q, expected one of: %s", s, priorityNames())
}
func runReprocess(ctx context.Context) {
kinds, err := selectedKinds(reprocessKinds, reprocessSources, reprocessAll)
kinds, err := selectedKinds(artworkKinds, artworkSources, artworkAll)
if err != nil {
log.Fatal(ctx, err)
}
@ -281,11 +339,11 @@ func runReprocess(ctx context.Context) {
if needsImageAgents(kinds) {
mgr := loadPluginAgents(ctx, false)
defer func() { _ = mgr.Stop() }()
imageAgents = imageAgentCount(ds, mgr)
imageAgents = artwork.NewImageAgentCount(agents.GetAgents(ds, mgr))
}
if err := reprocessArtwork(ctx, ds, kinds, repositorySources(reprocessSources), imageAgents,
reprocessDryRun, reprocessConfirm(reprocessYes, os.Stdin), os.Stdout); err != nil {
if err := reprocessArtwork(ctx, ds, kinds, repositorySources(artworkSources), imageAgents,
artworkDryRun, confirmUnlessYes(artworkYes, os.Stdin, "re-resolve"), os.Stdout); err != nil {
log.Fatal(ctx, err)
}
}
@ -298,16 +356,9 @@ func selectedKinds(kinds, sources []string, all bool) ([]model.Kind, error) {
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
return parseAll(kinds, func(s string) (model.Kind, error) {
return parseArtworkKind(s, artwork.RecheckKinds)
})
}
// absentSource is how the stored empty source — resolved, no image — is spelled on the CLI.
@ -326,11 +377,11 @@ 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 {
func confirmUnlessYes(yes bool, in io.Reader, verb string) confirmFunc {
if yes {
return func(io.Writer, int64, int64) bool { return true }
}
return promptConfirm(in)
return promptConfirm(in, verb)
}
// externalEstimate claims no bound: a local hit ends the walk before any agent is asked, and the
@ -346,11 +397,6 @@ 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 {
@ -378,13 +424,13 @@ func configuredAgents() []string {
return names
}
func promptConfirm(in io.Reader) confirmFunc {
func promptConfirm(in io.Reader, verb string) 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)
fmt.Fprintf(out, "\nThis will %s %d items.%s Continue? [y/N] ", verb, total, cost)
var answer string
if _, err := fmt.Fscanln(in, &answer); err != nil {
return false
@ -476,6 +522,90 @@ func reprocessArtwork(ctx context.Context, ds model.DataStore, kinds []model.Kin
return nil
}
func runCancel(ctx context.Context) {
kinds, priorities, err := cancelSelection(artworkKinds, artworkPriorities, artworkAll)
if err != nil {
log.Fatal(ctx, err)
}
defer db.Init(ctx)()
ds, ctx := getAdminContext(ctx)
if err := cancelArtwork(ctx, ds, kinds, priorities, artworkDryRun,
confirmUnlessYes(artworkYes, os.Stdin, "cancel"), os.Stdout); err != nil {
log.Fatal(ctx, err)
}
}
// cancelSelection leaves --all as the empty filter the repository reads as "every one", so a row
// whose kind this build does not know still gets cancelled.
func cancelSelection(kinds, priorities []string, all bool) ([]model.Kind, []int, error) {
if all {
return nil, nil, nil
}
if len(kinds) == 0 && len(priorities) == 0 {
return nil, nil, fmt.Errorf("no selector given: pass --kind, --priority or --all")
}
// RefreshableKinds, not RecheckKinds: media files are queued, so --kind must reach them.
outKinds, err := parseAll(kinds, func(s string) (model.Kind, error) {
return parseArtworkKind(s, artwork.RefreshableKinds)
})
if err != nil {
return nil, nil, err
}
outPriorities, err := parseAll(priorities, parseArtworkPriority)
if err != nil {
return nil, nil, err
}
return outKinds, outPriorities, nil
}
// parseAll drops repeats: a doubled selector would overstate the total the operator confirms.
func parseAll[T comparable](values []string, parse func(string) (T, error)) ([]T, error) {
out := make([]T, 0, len(values))
for _, v := range values {
parsed, err := parse(v)
if err != nil {
return nil, err
}
out = append(out, parsed)
}
return slice.Unique(out), nil
}
func cancelArtwork(ctx context.Context, ds model.DataStore, kinds []model.Kind, priorities []int,
dryRun bool, confirm confirmFunc, out io.Writer) error {
q := ds.ArtworkQueue(ctx)
matched, err := q.CountQueued(kinds, priorities)
if err != nil {
return fmt.Errorf("counting queued artwork: %w", err)
}
total := queueTotal(matched)
w := newTabWriter(out)
printQueueStats(w, matched, total, "MATCHED", "")
w.Flush()
switch {
case total == 0:
fmt.Fprintln(out, "\nNothing matches this selection.")
return nil
case dryRun:
fmt.Fprintln(out, "\nDry run: nothing was cancelled.")
return nil
case !confirm(out, total, 0):
fmt.Fprintln(out, "Aborted: nothing was cancelled.")
return nil
}
cancelled, err := q.PurgeQueued(kinds, priorities)
if err != nil {
return fmt.Errorf("cancelling queued artwork: %w", err)
}
// Count and delete are separate statements, so a drain in between makes these two differ.
fmt.Fprintf(out, "Cancelled %d of %d matched items.\n", cancelled, total)
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) {
@ -541,7 +671,7 @@ var explainKinds = []model.Kind{
}
func kindPrefixes(kinds []model.Kind) string {
return strings.Join(slice.Map(kinds, func(k model.Kind) string { return k.Prefix() }), ", ")
return strings.Join(model.KindPrefixes(kinds), ", ")
}
func parseArtworkKind(s string, valid []model.Kind) (model.Kind, error) {
@ -640,10 +770,6 @@ func explainResult(source string, steps []artwork.TraceStep) string {
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) {
@ -655,14 +781,12 @@ func explainResult(source string, steps []artwork.TraceStep) string {
}
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)"
// A stage error or an unreadable candidate means a source was found but not processed; the
// worker retries rather than settling absent, so neither reads as a clean miss.
case s.Outcome == artwork.OutcomeError, s.Outcome == artwork.OutcomeUnreadable:
return "indeterminate (a candidate was found but could not be processed; the worker retries rather than settling absent)"
}
}
return "not resolved"
@ -684,22 +808,55 @@ func explainConfig(kind model.Kind) (name, value string) {
}
type explainReport struct {
kind model.Kind
id string
name string
stored *model.ItemArtwork
queued *model.ArtworkQueueItem
agents string
kind model.Kind
id string
name string
stored *model.ItemArtwork
queued *model.ArtworkQueueItem
agents string
// steps is the chain walk: recorded when the item was resolved, or performed just now when walked.
steps []artwork.TraceStep
source string
walked bool
resolveErr error
}
// explainChainOrigin says whether the operator is reading history or a walk performed just now,
// since the two can disagree after a config change.
func explainChainOrigin(rep explainReport) string {
if rep.walked {
return "walked now"
}
if rep.stored != nil {
return "recorded " + formatTime(rep.stored.AttemptedAt)
}
return "not recorded"
}
// writeSteps prints the trace rows. An empty last cell would end tabwriter's column block and
// break the alignment, so a missing detail is rendered as a dash.
func writeSteps(w io.Writer, indent string, steps []artwork.TraceStep) {
for _, s := range steps {
fmt.Fprintf(w, "%s%s\t%s\t%s\n", indent, s.Candidate, s.Outcome, cmp.Or(s.Detail, "-"))
}
}
// writeStepTable prints a secondary trace, and nothing at all when there is none to show.
func writeStepTable(w io.Writer, title string, steps []artwork.TraceStep) {
if len(steps) == 0 {
return
}
// No tab on the title: it closes the preceding column block, so these rows align among themselves.
fmt.Fprintf(w, " %s:\n", title)
writeSteps(w, " ", steps)
}
func formatExplain(rep explainReport) string {
var sb strings.Builder
w := newTabWriter(&sb)
explainable := artwork.Explainable(rep.kind)
stateful := artwork.KeepsState(rep.kind)
unrecorded := !rep.walked && rep.stored == nil
fmt.Fprintln(w, "Item")
fmt.Fprintf(w, " Kind:\t%s (%s)\n", rep.kind, rep.kind.Prefix())
@ -732,6 +889,12 @@ func formatExplain(rep explainReport) string {
fmt.Fprintf(w, " Attempts:\t%d\n", rep.queued.Attempts)
fmt.Fprintf(w, " Retry at:\t%s\n", formatTime(rep.queued.RetryAt))
}
if rep.queued != nil {
writeStepTable(w, "Last attempt failed", artwork.DecodeTrace(rep.queued.Trace, ""))
}
if rep.stored != nil {
writeStepTable(w, "Gave up after", artwork.DecodeTrace(rep.stored.LastFailure, ""))
}
fmt.Fprintln(w, "\nConfig")
if setting, value := explainConfig(rep.kind); setting == "" {
@ -743,15 +906,22 @@ func formatExplain(rep explainReport) string {
}
}
fmt.Fprintln(w, "\nChain")
if !explainable {
fmt.Fprintf(w, "\nChain (%s)\n", explainChainOrigin(rep))
switch {
case !explainable:
fmt.Fprintf(w, " (%s artwork does not walk a priority chain)\n", rep.kind)
} else {
case unrecorded:
fmt.Fprintln(w, " (no resolution recorded yet; re-run with --live to walk the chain now)")
case !rep.walked && len(rep.steps) == 0 && rep.stored.Hash != "":
// A stored image with no chain can only predate trace recording: a recorded resolution that
// found an image always records its winning candidate.
fmt.Fprintln(w, " (this item was resolved before traces were recorded; re-run with --live)")
case !rep.walked && len(rep.steps) == 0:
// Absent with no chain: an empty priority list walked nothing, or a pre-tracing absent row.
fmt.Fprintln(w, " (no candidates were recorded; re-run with --live to walk the chain now)")
default:
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, "-"))
}
writeSteps(w, " ", rep.steps)
}
fmt.Fprintln(w, "\nResult")
@ -760,6 +930,8 @@ func formatExplain(rep explainReport) string {
fmt.Fprintf(w, " resolution failed: %s\n", rep.resolveErr)
case !explainable:
fmt.Fprintln(w, " not evaluated (no chain was walked; see Stored above)")
case unrecorded:
fmt.Fprintln(w, " not evaluated (nothing recorded; re-run with --live to walk the chain now)")
default:
fmt.Fprintf(w, " %s\n", explainResult(rep.source, rep.steps))
}
@ -807,6 +979,8 @@ func runExplain(ctx context.Context, args []string) {
}
}
// Disc artwork keeps no row, so it has no stored trace and can only be explained by walking now.
rep.walked = explainLive || !artwork.KeepsState(kind)
if artwork.Explainable(kind) {
// Only artist and album reach an agent, and the load must precede the resolver, which reads
// the same manager.
@ -815,11 +989,16 @@ func runExplain(ctx context.Context, args []string) {
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()
switch {
case rep.walked:
trace := &artwork.ChainTrace{}
rep.source, rep.resolveErr = CreateArtworkResolver(trace, explainLive).Resolve(ctx, kind, id)
rep.steps = trace.Steps()
case rep.stored != nil:
rep.steps = artwork.DecodeTrace(rep.stored.Trace, rep.stored.SourcePath)
rep.source = rep.stored.Source
}
}
fmt.Print(formatExplain(rep))
// The steps taken before a failed walk are the diagnosis, so report them before exiting.
if rep.resolveErr != nil {

View File

@ -3,6 +3,7 @@ package cmd
import (
"context"
"errors"
"fmt"
"io"
"strings"
"time"
@ -145,6 +146,15 @@ var _ = Describe("explainResult", func() {
"the worker retries an unreadable candidate instead of settling absent, so this is not a clean miss")
})
It("reports indeterminate when a processing stage errored after a candidate was found", func() {
steps := []artwork.TraceStep{
{Candidate: "cover.*", Outcome: "hit", Detail: "/music/cover.jpg"},
{Candidate: "store", Outcome: "error", Detail: "disk full"},
}
Expect(explainResult("", steps)).To(ContainSubstring("indeterminate"),
"a stage error is a processing failure the worker retries, not a definitive 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.
@ -164,34 +174,6 @@ var _ = Describe("explainResult", func() {
"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"},
@ -252,9 +234,10 @@ var _ = Describe("formatExplain", func() {
id: "ar-1",
name: "Radiohead",
agents: "lastfm,spotify",
walked: true,
steps: []artwork.TraceStep{
{Candidate: "upload", Outcome: "skipped", Detail: "no uploaded image"},
{Candidate: "external:deezer", Outcome: "would-try"},
{Candidate: "external:deezer", Outcome: "error", Detail: "context deadline exceeded"},
},
source: "",
}
@ -267,7 +250,6 @@ var _ = Describe("formatExplain", func() {
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"))
})
@ -304,7 +286,7 @@ var _ = Describe("formatExplain", func() {
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")
Expect(out).To(ContainSubstring("external:deezer"), "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() {
@ -329,6 +311,7 @@ var _ = Describe("formatExplain", func() {
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",
walked: true,
}
out := formatExplain(rep)
@ -341,10 +324,74 @@ var _ = Describe("formatExplain", func() {
Expect(out).To(ContainSubstring("resolved from folder"))
})
Context("stored traces", func() {
BeforeEach(func() {
rep.walked = false
rep.steps = nil
})
It("labels a recorded chain with when it was recorded, not as a walk done now", func() {
attempted := time.Date(2026, 8, 13, 10, 0, 0, 0, time.UTC)
rep.stored = &model.ItemArtwork{Source: "folder", Hash: "abc", AttemptedAt: attempted}
rep.steps = []artwork.TraceStep{{Candidate: "artist.*", Outcome: "hit", Detail: "/music/artist.jpg"}}
rep.source = "folder"
out := formatExplain(rep)
Expect(out).To(ContainSubstring("Chain (recorded 2026-08-13T10:00:00Z)"))
Expect(out).To(ContainSubstring("/music/artist.jpg"))
Expect(out).To(ContainSubstring("resolved from folder"))
})
It("says so when the item has never been resolved", func() {
out := formatExplain(rep)
Expect(out).To(ContainSubstring("no resolution recorded yet"))
Expect(out).To(ContainSubstring("--live"))
Expect(out).ToNot(ContainSubstring("not resolved"),
"nothing was recorded, which is not the same as resolving to nothing")
})
It("distinguishes a row written before traces existed from one with an empty chain", func() {
rep.stored = &model.ItemArtwork{Source: "folder", Hash: "abc", AttemptedAt: time.Now()}
Expect(formatExplain(rep)).To(ContainSubstring("resolved before traces were recorded"))
})
It("does not call an absent row with an empty recorded chain a pre-tracing row", func() {
// An empty priority list records a real but empty chain and resolves absent; that is not a
// legacy row, so it must not be reported as resolved before tracing existed.
rep.stored = &model.ItemArtwork{Source: "", Hash: "", AttemptedAt: time.Now()}
out := formatExplain(rep)
Expect(out).ToNot(ContainSubstring("resolved before traces were recorded"))
Expect(out).To(ContainSubstring("no candidates were recorded"))
Expect(out).To(ContainSubstring("not resolved"), "the Result still reports the absence plainly")
})
It("prints why the last attempt failed and why it gave up", func() {
rep.queued = &model.ArtworkQueueItem{Priority: model.ArtworkPriorityScan, Attempts: 3,
Trace: `[{"c":"decode","o":"error","d":"bad header"}]`}
rep.stored = &model.ItemArtwork{Source: "folder", Hash: "abc", AttemptedAt: time.Now(),
LastFailure: `[{"c":"read","o":"error","d":"i/o timeout"}]`}
out := formatExplain(rep)
Expect(out).To(ContainSubstring("Last attempt failed"))
Expect(out).To(ContainSubstring("bad header"))
Expect(out).To(ContainSubstring("Gave up after"))
Expect(out).To(ContainSubstring("i/o timeout"))
})
It("omits the failure tables when there is no failure to report", func() {
out := formatExplain(rep)
Expect(out).ToNot(ContainSubstring("Last attempt failed"))
Expect(out).ToNot(ContainSubstring("Gave up after"))
})
})
It("reports the setting that governs media file artwork", func() {
conf.Server.EnableMediaFileCoverArt = false
rep = explainReport{
kind: model.KindMediaFileArtwork, id: "mf-1", name: "Airbag",
walked: true,
steps: []artwork.TraceStep{
{Candidate: "embedded", Outcome: "skipped", Detail: "EnableMediaFileCoverArt is off"},
},
@ -503,36 +550,36 @@ var _ = Describe("promptConfirm", func() {
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(promptConfirm(strings.NewReader("y\n"), "re-resolve")(&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())
Expect(promptConfirm(strings.NewReader("\n"), "re-resolve")(&out, 1, 1)).To(BeFalse())
Expect(promptConfirm(strings.NewReader("nope\n"), "re-resolve")(&out, 1, 1)).To(BeFalse())
Expect(promptConfirm(strings.NewReader(""), "re-resolve")(&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(promptConfirm(strings.NewReader("y\n"), "cancel")(&out, 3, 0)).To(BeTrue())
Expect(out.String()).To(ContainSubstring("cancel 3 items."))
Expect(out.String()).ToNot(ContainSubstring("External lookups"))
})
})
var _ = Describe("reprocessConfirm", func() {
var _ = Describe("confirmUnlessYes", 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(confirmUnlessYes(false, strings.NewReader("n\n"), "re-resolve")(&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(confirmUnlessYes(true, strings.NewReader(""), "re-resolve")(&out, 5, 5)).To(BeTrue())
Expect(out.String()).To(BeEmpty(), "--yes must not print a prompt it never reads")
})
})
@ -772,7 +819,7 @@ var _ = Describe("collectStatus", func() {
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-2", "", "", time.Now().Add(-artwork.StaleAbsentAge-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",
@ -863,8 +910,9 @@ var _ = Describe("formatStatus", func() {
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("states the recheck window and the drip rate the absent counts are bucketed against", func() {
Expect(formatStatus(rep)).To(ContainSubstring(fmt.Sprintf("%gh", artwork.StaleAbsentAge.Hours())))
Expect(formatStatus(rep)).To(ContainSubstring("100 per kind per hour"))
})
It("leads with the queued backlog, which is the finding, not with the fingerprint verdict", func() {
@ -1013,3 +1061,153 @@ var _ = Describe("configuredAgents", func() {
Expect(configuredAgents()).To(BeEmpty())
})
})
var _ = Describe("parseArtworkPriority", func() {
It("accepts every name status prints", func() {
for _, p := range []int{model.ArtworkPriorityRecheck, model.ArtworkPriorityBackfill,
model.ArtworkPriorityScan, model.ArtworkPriorityBump} {
Expect(parseArtworkPriority(priorityName(p))).To(Equal(p))
}
})
It("rejects an unknown name and lists the valid ones", func() {
_, err := parseArtworkPriority("urgent")
Expect(err).To(MatchError(ContainSubstring(`invalid priority "urgent"`)))
Expect(err).To(MatchError(ContainSubstring("backfill")))
})
// Accepting the raw numbers would make the help text a lie and let a typo like 11 select nothing.
It("rejects the numeric form", func() {
_, err := parseArtworkPriority("10")
Expect(err).To(HaveOccurred())
})
})
var _ = Describe("artwork cancel selection", func() {
It("errors when no selector is given", func() {
_, _, err := cancelSelection(nil, nil, false)
Expect(err).To(MatchError(ContainSubstring("no selector given")))
})
// Empty, not an enumeration of the known kinds: --all must also take a queue row whose kind
// this build does not recognise.
It("selects with no filter at all for --all", func() {
kinds, priorities, err := cancelSelection(nil, nil, true)
Expect(err).ToNot(HaveOccurred())
Expect(kinds).To(BeEmpty())
Expect(priorities).To(BeEmpty())
})
// The queue holds media file rows, so --all must reach them.
It("accepts media file artwork, which reprocess does not", func() {
kinds, _, err := cancelSelection([]string{"mf"}, nil, false)
Expect(err).ToNot(HaveOccurred())
Expect(kinds).To(Equal([]model.Kind{model.KindMediaFileArtwork}))
})
It("treats a priority filter on its own as a complete selection", func() {
kinds, priorities, err := cancelSelection(nil, []string{"backfill"}, false)
Expect(err).ToNot(HaveOccurred())
Expect(kinds).To(BeEmpty(), "no kind filter means every kind")
Expect(priorities).To(Equal([]int{model.ArtworkPriorityBackfill}))
})
It("returns only the named kinds and priorities", func() {
kinds, priorities, err := cancelSelection([]string{"ar", "al"}, []string{"backfill", "scan"}, false)
Expect(err).ToNot(HaveOccurred())
Expect(kinds).To(Equal([]model.Kind{model.KindArtistArtwork, model.KindAlbumArtwork}))
Expect(priorities).To(Equal([]int{model.ArtworkPriorityBackfill, model.ArtworkPriorityScan}))
})
It("counts a repeated kind and a repeated priority once", func() {
kinds, priorities, err := cancelSelection([]string{"ar", "ar"}, []string{"bump", "bump"}, false)
Expect(err).ToNot(HaveOccurred())
Expect(kinds).To(HaveLen(1))
Expect(priorities).To(HaveLen(1))
})
It("rejects an unknown kind", func() {
_, _, err := cancelSelection([]string{"zz"}, nil, false)
Expect(err).To(MatchError(ContainSubstring(`invalid kind "zz"`)))
})
It("rejects a kind that is never queued", func() {
_, _, err := cancelSelection([]string{"dc"}, nil, false)
Expect(err).To(MatchError(ContainSubstring("invalid kind")))
})
It("rejects an unknown priority", func() {
_, _, err := cancelSelection(nil, []string{"urgent"}, false)
Expect(err).To(MatchError(ContainSubstring("invalid priority")))
})
})
var _ = Describe("cancelArtwork", func() {
var ds *tests.MockDataStore
var queue *tests.MockArtworkQueueRepo
var out strings.Builder
ctx := context.Background()
accept := func(io.Writer, int64, int64) bool { return true }
decline := func(io.Writer, int64, int64) bool { return false }
BeforeEach(func() {
ds = &tests.MockDataStore{}
queue = ds.ArtworkQueue(ctx).(*tests.MockArtworkQueueRepo)
out.Reset()
Expect(queue.Enqueue(
model.ArtworkQueueItem{ItemKind: "ar", ItemID: "ar-1", ImageType: model.ImageTypePrimary,
Priority: model.ArtworkPriorityBackfill},
model.ArtworkQueueItem{ItemKind: "ar", ItemID: "ar-2", ImageType: model.ImageTypePrimary,
Priority: model.ArtworkPriorityBump},
model.ArtworkQueueItem{ItemKind: "al", ItemID: "al-1", ImageType: model.ImageTypePrimary,
Priority: model.ArtworkPriorityBackfill},
)).To(Succeed())
})
It("previews the per-kind breakdown and cancels nothing on a dry run", func() {
Expect(cancelArtwork(ctx, ds, []model.Kind{model.KindArtistArtwork}, nil, true, accept, &out)).To(Succeed())
Expect(out.String()).To(ContainSubstring("artist"))
Expect(out.String()).To(ContainSubstring("backfill"))
Expect(out.String()).To(ContainSubstring("TOTAL"))
Expect(out.String()).To(ContainSubstring("Dry run"))
Expect(queue.Count()).To(BeNumerically("==", 3))
})
It("cancels nothing when the operator declines", func() {
Expect(cancelArtwork(ctx, ds, nil, nil, false, decline, &out)).To(Succeed())
Expect(out.String()).To(ContainSubstring("Aborted"))
Expect(queue.Count()).To(BeNumerically("==", 3))
})
It("deletes the selected rows and leaves the rest queued", func() {
Expect(cancelArtwork(ctx, ds, nil, []int{model.ArtworkPriorityBackfill}, false, accept, &out)).To(Succeed())
Expect(queue.Count()).To(BeNumerically("==", 1))
_, err := queue.Get(model.KindArtistArtwork, "ar-2", model.ImageTypePrimary)
Expect(err).ToNot(HaveOccurred(), "a non-matching priority must stay queued")
Expect(out.String()).To(ContainSubstring("Cancelled 2 of 2 matched items."))
})
It("cancels every kind and priority when neither filter is given", func() {
Expect(cancelArtwork(ctx, ds, nil, nil, false, accept, &out)).To(Succeed())
Expect(queue.Count()).To(BeZero())
})
It("stops at a selection that matches nothing instead of prompting", func() {
refuse := func(io.Writer, int64, int64) bool {
Fail("must not prompt when nothing matches")
return false
}
Expect(cancelArtwork(ctx, ds, []model.Kind{model.KindPlaylistArtwork}, nil, false, refuse, &out)).To(Succeed())
Expect(out.String()).To(ContainSubstring("Nothing matches this selection."))
Expect(queue.Count()).To(BeNumerically("==", 3))
})
It("reports a queue read failure instead of reporting nothing to cancel", func() {
queue.Err = errors.New("read failed")
Expect(cancelArtwork(ctx, ds, nil, nil, false, accept, &out)).To(MatchError(ContainSubstring("read failed")))
})
})

View File

@ -6,6 +6,7 @@ import (
"encoding/json"
"errors"
"fmt"
"io"
"os"
"path/filepath"
"strconv"
@ -141,14 +142,16 @@ func findPlaylist(ctx context.Context, ds model.DataStore, nameOrID string) *mod
func runExporter(ctx context.Context) {
ds, ctx := getAdminContext(ctx)
playlist := findPlaylist(ctx, ds, playlistID)
pls := playlist.ToM3U8()
if outputFile == "-" || outputFile == "" {
println(pls)
writePlaylist(playlist.ToM3U8(), os.Stdout, outputFile)
}
func writePlaylist(m3u string, out io.Writer, file string) {
if file == "" || file == "-" {
fmt.Fprint(out, m3u)
return
}
err := os.WriteFile(outputFile, []byte(pls), 0600)
if err != nil {
log.Fatal("Error writing to the output file", "file", outputFile, err)
if err := os.WriteFile(file, []byte(m3u), 0600); err != nil {
log.Fatal("Error writing to the output file", "file", file, err)
}
}
@ -157,7 +160,7 @@ func runExport(ctx context.Context) {
if playlistID != "" && outputFile == "" {
playlist := findPlaylist(ctx, ds, playlistID)
println(playlist.ToM3U8())
writePlaylist(playlist.ToM3U8(), os.Stdout, outputFile)
return
}

35
cmd/pls_test.go Normal file
View File

@ -0,0 +1,35 @@
package cmd
import (
"fmt"
"os"
"path/filepath"
"strings"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
var _ = Describe("writePlaylist", func() {
const m3u = "#EXTM3U\n#PLAYLIST:DJ Wave\n#EXTINF:364,Bel Canto - Dreaming Girl\n"
plsFile := filepath.Join(os.TempDir(), fmt.Sprintf("navidrome-pls-%d.m3u8", os.Getpid()))
BeforeEach(func() {
DeferCleanup(func() { _ = os.Remove(plsFile) })
})
DescribeTable("writes the playlist to exactly one destination",
func(file, wantStream, wantFile string) {
var out strings.Builder
writePlaylist(m3u, &out, file)
written, _ := os.ReadFile(plsFile)
Expect(out.String()).To(Equal(wantStream))
Expect(string(written)).To(Equal(wantFile))
},
Entry("no file name writes to the stream", "", m3u, ""),
Entry("a dash writes to the stream", "-", m3u, ""),
Entry("a path writes to the file", plsFile, "", m3u),
)
})

View File

@ -73,6 +73,7 @@ type configOptions struct {
Matcher matcherOptions `json:",omitzero"`
RecentlyAddedByModTime bool
PreferSortTags bool
EnableNaturalSorting bool
IgnoredArticles string
IndexGroups string
FFmpegPath string
@ -973,6 +974,7 @@ func setViperDefaults() {
viper.SetDefault("matcher.fuzzythreshold", 85)
viper.SetDefault("recentlyaddedbymodtime", false)
viper.SetDefault("prefersorttags", false)
viper.SetDefault("enablenaturalsorting", false)
viper.SetDefault("ignoredarticles", "The El La Los Las Le Les Os As O A")
viper.SetDefault("indexgroups", "A B C D E F G H I J K L M N O P Q R S T U V W X-Z(XYZ) [Unknown]([)")
viper.SetDefault("ffmpegpath", "")

View File

@ -58,7 +58,7 @@ func fetchArtistImage(ctx context.Context, ag *agents.Agents, gate gateFunc, ar
return nil, "", false
}
for _, a := range imageAgents {
reader, _, err := gate(a.Name, func() (io.ReadCloser, string, error) {
reader, path, err := gate(a.Name, func() (io.ReadCloser, string, error) {
imgs, err := a.Retriever.GetArtistImages(ctx, ar.ID, name, ar.MbzArtistID)
if err != nil {
return nil, "", err
@ -69,6 +69,7 @@ func fetchArtistImage(ctx context.Context, ag *agents.Agents, gate gateFunc, ar
}
return fromURL(ctx, u)
})
recordAgent(ctx, a.Name, reader, path, err)
if reader != nil {
return reader, a.Name, false
}
@ -90,7 +91,7 @@ func fetchAlbumImage(ctx context.Context, ag *agents.Agents, gate gateFunc, al m
return nil, "", false
}
for _, a := range imageAgents {
reader, _, err := gate(a.Name, func() (io.ReadCloser, string, error) {
reader, path, err := gate(a.Name, func() (io.ReadCloser, string, error) {
imgs, err := a.Retriever.GetAlbumImages(ctx, name, artist, al.MbzAlbumID)
if err != nil {
return nil, "", err
@ -101,6 +102,7 @@ func fetchAlbumImage(ctx context.Context, ag *agents.Agents, gate gateFunc, al m
}
return fromURL(ctx, u)
})
recordAgent(ctx, a.Name, reader, path, err)
if reader != nil {
return reader, a.Name, false
}

View File

@ -393,16 +393,15 @@ type TracingResolver struct {
trace *ChainTrace
}
// NewTracingResolver builds a TracingResolver that records its priority-chain walk. With live
// false the external tier is reported but never called.
// NewTracingResolver builds a TracingResolver that records its priority-chain walk. Without live
// it gets no agents at all, so neither a chain nor any fallback added later can reach a provider;
// with it, one item is at most one call per agent, so the rate limiter and breaker are bypassed.
func NewTracingResolver(ds model.DataStore, ag *agents.Agents, ffm ffmpeg.FFmpeg, t *ChainTrace, live bool) *TracingResolver {
gate := offlineGate(t)
inner := newLocalResolver(ds, ffm)
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)
inner = newResolver(ds, ag, ffm, passthroughGate)
}
return &TracingResolver{inner: newResolver(ds, ag, ffm, gate), trace: t}
return &TracingResolver{inner: inner, trace: t}
}
// Resolve walks kind's sources for id, recording the walk, and reports the winning source

View File

@ -18,7 +18,11 @@ import (
)
// StaleAbsentAge is how long an absent state is trusted before a recheck retries it.
const StaleAbsentAge = 24 * time.Hour
const StaleAbsentAge = 30 * 24 * time.Hour
// StaleAbsentRecheckBatch caps how many absent states each hourly tick re-queues per kind,
// oldest first, so external agents see a flat drip instead of a daily burst.
const StaleAbsentRecheckBatch = 100
// RecheckKinds omits media files: they resolve embedded-only, at scan or on view.
var RecheckKinds = []model.Kind{
@ -68,18 +72,27 @@ func ConfigFingerprint() string {
return fmt.Sprintf("%016x", xxh3.Hash([]byte(raw)))
}
// backfillSummary is what a backfill enqueued. MaxExternalLookups is an upper estimate for one
// attempt per item, not a bound: a local hit ends the walk, and a retry asks the agents again.
type backfillSummary struct {
Ran bool
PerKind map[string]int64
Items int64
MaxExternalLookups int64
}
// backfill enqueues artwork resolution for every entity when the config fingerprint changed.
func backfill(ctx context.Context, ds model.DataStore) (bool, error) {
func backfill(ctx context.Context, ds model.DataStore, agentCount func() ImageAgentCount) (backfillSummary, error) {
start := time.Now()
ctx = auth.WithAdminUser(ctx, ds)
current := ConfigFingerprint()
props := ds.Property(ctx)
stored, err := props.DefaultGet(consts.ArtConfFingerprintPropertyKey, "")
if err != nil {
return false, err
return backfillSummary{}, err
}
if stored == current {
return false, nil
return backfillSummary{}, nil
}
// Artists first: few entities, most external-dependent, so they get a queue headstart.
@ -92,21 +105,31 @@ func backfill(ctx context.Context, ds model.DataStore) (bool, error) {
{model.KindPlaylistArtwork, func() ([]string, error) { return ds.Playlist(ctx).GetAllIDs() }},
{model.KindRadioArtwork, func() ([]string, error) { return ds.Radio(ctx).GetAllIDs() }},
}
// Counted here, not by the caller: building the agent list constructs every enabled agent, and
// an unchanged fingerprint returns above without ever needing the number.
agents := agentCount()
summary := backfillSummary{Ran: true, PerKind: map[string]int64{}}
for _, k := range kinds {
ids, err := k.fetch()
if err != nil {
return false, err
return backfillSummary{}, err
}
if err := enqueueBackfillKind(ctx, ds, k.kind, ids); err != nil {
return false, err
return backfillSummary{}, err
}
n := int64(len(ids))
summary.PerKind[k.kind.Prefix()] = n
summary.Items += n
summary.MaxExternalLookups += n * ExternalLookupsPerItem(k.kind, agents)
}
if err := props.Put(consts.ArtConfFingerprintPropertyKey, current); err != nil {
return false, err
return backfillSummary{}, err
}
log.Info(ctx, "Artwork: Config fingerprint changed, backfill enqueued", "elapsed", time.Since(start))
return true, nil
log.Info(ctx, "Artwork: Config fingerprint changed, backfill enqueued", "items", summary.Items,
"byKind", summary.PerKind, "maxExternalLookups", summary.MaxExternalLookups,
"elapsed", time.Since(start))
return summary, nil
}
func enqueueBackfillKind(ctx context.Context, ds model.DataStore, kind model.Kind, ids []string) error {
@ -125,7 +148,7 @@ func enqueueStaleAbsentAll(ctx context.Context, ds model.DataStore) error {
cutoff := time.Now().Add(-StaleAbsentAge)
queue := ds.ArtworkQueue(ctx)
for _, kind := range RecheckKinds {
if _, err := queue.EnqueueStaleAbsent(kind, cutoff); err != nil {
if _, err := queue.EnqueueStaleAbsent(kind, cutoff, StaleAbsentRecheckBatch); err != nil {
return err
}
}

View File

@ -2,6 +2,7 @@ package artwork
import (
"context"
"fmt"
"slices"
"time"
@ -38,6 +39,8 @@ func adminUserRepo() *tests.MockedUserRepo {
return repo
}
func noAgents() ImageAgentCount { return ImageAgentCount{} }
// orderTrackingQueueRepo records the item kind of each Enqueue call, so tests can
// assert phase ordering (artists-first) that same-priority timestamps can't guarantee.
type orderTrackingQueueRepo struct {
@ -163,9 +166,14 @@ var _ = Describe("Housekeeping", func() {
seedEntities()
Expect(propRepo.Put(consts.ArtConfFingerprintPropertyKey, ConfigFingerprint())).To(Succeed())
did, err := backfill(ctx, ds)
counted := false
s, err := backfill(ctx, ds, func() ImageAgentCount {
counted = true
return ImageAgentCount{Artist: 3, Album: 2}
})
Expect(err).ToNot(HaveOccurred())
Expect(did).To(BeFalse())
Expect(s).To(Equal(backfillSummary{}))
Expect(counted).To(BeFalse(), "building the agent list constructs every agent; an unchanged fingerprint must not pay for it")
count, err := queueRepo.Count()
Expect(err).ToNot(HaveOccurred())
@ -175,9 +183,9 @@ var _ = Describe("Housekeeping", func() {
It("runs the backfill when no fingerprint was ever stored", func() {
seedEntities()
did, err := backfill(ctx, ds)
s, err := backfill(ctx, ds, noAgents)
Expect(err).ToNot(HaveOccurred())
Expect(did).To(BeTrue())
Expect(s.Ran).To(BeTrue())
count, err := queueRepo.Count()
Expect(err).ToNot(HaveOccurred())
@ -196,9 +204,9 @@ var _ = Describe("Housekeeping", func() {
tracks: &tests.MockPlaylistTrackRepo{},
}
did, err := backfill(ctx, vds)
s, err := backfill(ctx, vds, noAgents)
Expect(err).ToNot(HaveOccurred())
Expect(did).To(BeTrue())
Expect(s.Ran).To(BeTrue())
Expect(findQueued(queueRepo.MockArtworkQueueRepo, "pl", "plPrivate")).ToNot(BeNil())
})
@ -206,9 +214,9 @@ var _ = Describe("Housekeeping", func() {
seedEntities()
Expect(propRepo.Put(consts.ArtConfFingerprintPropertyKey, "stale-fingerprint")).To(Succeed())
did, err := backfill(ctx, ds)
s, err := backfill(ctx, ds, noAgents)
Expect(err).ToNot(HaveOccurred())
Expect(did).To(BeTrue())
Expect(s.Ran).To(BeTrue())
Expect(queueRepo.callKinds).ToNot(BeEmpty())
firstOther := slices.IndexFunc(queueRepo.callKinds, func(k string) bool { return k != "ar" })
@ -223,6 +231,22 @@ var _ = Describe("Housekeeping", func() {
Expect(it.ItemKind).To(BeElementOf("ar", "al", "pl", "ra"))
}
})
It("reports what it enqueued, per kind and as an external-lookup ceiling", func() {
conf.Server.ArtistArtPriority = "artist.*, external"
conf.Server.CoverArtPriority = "cover.*, external"
conf.Server.EnableM3UExternalAlbumArt = false
seedEntities()
s, err := backfill(ctx, ds, func() ImageAgentCount { return ImageAgentCount{Artist: 3, Album: 2} })
Expect(err).ToNot(HaveOccurred())
Expect(s.Ran).To(BeTrue())
Expect(s.PerKind).To(Equal(map[string]int64{"ar": 2, "al": 1, "pl": 1, "ra": 1}))
Expect(s.Items).To(Equal(int64(5)))
// 2 artists x 3 agents, 1 album x 2, 1 playlist grid x 2, and radios never fetch.
Expect(s.MaxExternalLookups).To(Equal(int64(6 + 2 + PlaylistGridSamples*2)))
})
})
Describe("EnqueueStaleAbsentAll", func() {
@ -235,8 +259,8 @@ var _ = Describe("Housekeeping", func() {
})
It("enqueues only absent entries older than the recheck window, across all kinds", func() {
old := time.Now().Add(-48 * time.Hour)
recent := time.Now().Add(-time.Hour)
old := time.Now().Add(-StaleAbsentAge - time.Hour)
recent := time.Now().Add(-StaleAbsentAge + time.Hour)
artRepo.ItemData["ar-stale"] = model.ItemArtwork{ItemKind: "ar", ItemID: "ar1", ImageType: model.ImageTypePrimary, Hash: "", AttemptedAt: old}
artRepo.ItemData["al-stale"] = model.ItemArtwork{ItemKind: "al", ItemID: "al1", ImageType: model.ImageTypePrimary, Hash: "", AttemptedAt: old}
@ -259,6 +283,20 @@ var _ = Describe("Housekeeping", func() {
Expect(findQueued(queueRepo.MockArtworkQueueRepo, "ar", "ar2")).To(BeNil())
Expect(findQueued(queueRepo.MockArtworkQueueRepo, "al", "al2")).To(BeNil())
})
It("caps each tick at the recheck batch, oldest attempts first", func() {
for i := range StaleAbsentRecheckBatch + 1 {
id := fmt.Sprintf("ar%d", i)
artRepo.ItemData[id] = model.ItemArtwork{ItemKind: "ar", ItemID: id, ImageType: model.ImageTypePrimary,
Hash: "", AttemptedAt: time.Now().Add(-StaleAbsentAge - time.Duration(i+1)*time.Minute)}
}
Expect(enqueueStaleAbsentAll(ctx, ds)).To(Succeed())
Expect(queueRepo.Data).To(HaveLen(StaleAbsentRecheckBatch))
// ar0 has the newest attempted_at of the cohort, so it is the one left out.
Expect(findQueued(queueRepo.MockArtworkQueueRepo, "ar", "ar0")).To(BeNil())
})
})
Describe("EnqueueMissingAll", func() {

View File

@ -2,6 +2,7 @@ package artwork
import (
"bytes"
"cmp"
"context"
"encoding/base64"
"errors"
@ -89,12 +90,21 @@ func (p *processor) acquire(ctx context.Context, item model.ArtworkQueueItem) (o
res, err := p.resolver.resolve(ctx, item)
if err != nil {
traceStage(ctx, "resolve", err)
log.Warn(ctx, "Artwork: Could not resolve item", "kind", item.ItemKind, "id", item.ItemID, err)
return outcomeFailed, nil
}
if res.reader == nil {
if res.extError || res.localError {
// A fault is not a definitive "no image": never settle absent, keep serving old state.
// A chainless resolver (playlist/radio) records no step, so leave a fallback or explain is blank.
if t := traceFrom(ctx); len(t.Steps()) == 0 {
outcome := OutcomeError
if res.localError {
outcome = OutcomeUnreadable
}
t.add(TraceStep{Candidate: cmp.Or(res.source, "source"), Outcome: outcome})
}
log.Debug(ctx, "Artwork: No image, but a source faulted; keeping previous state",
"kind", item.ItemKind, "id", item.ItemID, "extError", res.extError, "localError", res.localError)
return outcomeFailed, nil
@ -106,6 +116,7 @@ func (p *processor) acquire(ctx context.Context, item model.ArtworkQueueItem) (o
readStart := time.Now()
data, err := readCapped(res.reader)
if err != nil {
traceStage(ctx, "read", err)
log.Warn(ctx, "Artwork: Failed to read resolved image", "kind", item.ItemKind, "id", item.ItemID, "source", res.source, err)
return outcomeFailed, nil
}
@ -115,6 +126,7 @@ func (p *processor) acquire(ctx context.Context, item model.ArtworkQueueItem) (o
hashStart := time.Now()
hash, err := hashImage(bytes.NewReader(data))
if err != nil {
traceStage(ctx, "hash", err)
log.Warn(ctx, "Artwork: Failed to hash image", "kind", item.ItemKind, "id", item.ItemID, err)
return outcomeFailed, nil
}
@ -138,19 +150,22 @@ func (p *processor) acquire(ctx context.Context, item model.ArtworkQueueItem) (o
art, err = undecodedArtwork(hash), nil
}
if err != nil {
traceStage(ctx, "decode", err)
log.Warn(ctx, "Artwork: Failed to decode resolved image", "kind", item.ItemKind, "id", item.ItemID, err)
return outcomeFailed, nil
}
log.Debug(ctx, "Artwork: Decoded new image", "kind", item.ItemKind, "id", item.ItemID, "hash", hash,
"width", art.Width, "height", art.Height, "mime", art.Mime, "elapsed", time.Since(decodeStart))
default:
traceStage(ctx, "lookup", err)
log.Warn(ctx, "Artwork: Failed to look up image hash", "kind", item.ItemKind, "id", item.ItemID, err)
return outcomeFailed, nil
}
art.SizeBytes = int64(len(data))
ia, err := p.persist(repo, item, art, res, data)
ia, err := p.persist(ctx, repo, item, art, res, data)
if err != nil {
traceStage(ctx, "store", err)
log.Warn(ctx, "Artwork: Failed to persist resolved image", "kind", item.ItemKind, "id", item.ItemID, err)
return outcomeFailed, nil
}
@ -165,7 +180,7 @@ func (p *processor) acquire(ctx context.Context, item model.ArtworkQueueItem) (o
// persist places the bytes and commits the rows referencing them, excluding Prune for that
// window only so a slow resolution can never hold it off.
func (p *processor) persist(repo model.ArtworkRepository, item model.ArtworkQueueItem,
func (p *processor) persist(ctx context.Context, repo model.ArtworkRepository, item model.ArtworkQueueItem,
art *model.Artwork, res resolution, data []byte,
) (*model.ItemArtwork, error) {
if p.pruneLock != nil {
@ -188,6 +203,7 @@ func (p *processor) persist(repo model.ArtworkRepository, item model.ArtworkQueu
SourcePath: sourcePath,
RefMtime: refMtime,
AttemptedAt: time.Now(),
Trace: traceFrom(ctx).encode(sourcePath),
}
// PutItemArtwork stamps UpdatedAt on ia, so the returned struct matches the persisted row.
if err := repo.PutItemArtwork(ia); err != nil {
@ -203,6 +219,7 @@ func writeAbsent(ctx context.Context, repo model.ArtworkRepository, item model.A
ItemID: item.ItemID,
ImageType: item.ImageType,
AttemptedAt: time.Now(),
Trace: traceFrom(ctx).encode(""),
})
if err != nil {
log.Warn(ctx, "Artwork: Failed to persist absent state", "kind", item.ItemKind, "id", item.ItemID, err)

View File

@ -229,6 +229,35 @@ var _ = Describe("processor.acquire", func() {
Expect(err).To(MatchError(model.ErrNotFound), "an unreadable upload must not be recorded as absent")
})
// Playlist/radio resolvers walk no chain, so a fault records no step; without a fallback,
// explain would show a give-up with an empty "Gave up after" table.
It("chainless fault: records a fallback trace step naming the faulted source", func() {
if runtime.GOOS == "windows" {
// os.Open under a non-directory maps to a not-exist error on Windows, so no localError.
Skip("cannot provoke an open fault via a non-directory parent on Windows")
}
radioRepo := tests.CreateMockedRadioRepo()
radioRepo.Data = map[string]*model.Radio{}
ds.MockedRadio = radioRepo
dir := GinkgoT().TempDir()
conf.Server.DataFolder = conf.NewDir(dir)
upload := model.UploadedImagePath(consts.EntityRadio, "ra-tr.jpg")
// A plain file where the upload's parent should be makes os.Open fault with ENOTDIR,
// deterministically and regardless of the test user's privileges.
Expect(os.MkdirAll(filepath.Dir(filepath.Dir(upload)), 0o755)).To(Succeed())
Expect(os.WriteFile(filepath.Dir(upload), []byte("x"), 0o600)).To(Succeed())
radioRepo.Data["ra-tr"] = &model.Radio{ID: "ra-tr", Name: "Station", UploadedImage: "ra-tr.jpg"}
trace := &ChainTrace{}
out, _ := proc.acquire(withTrace(ctx, trace), model.ArtworkQueueItem{ItemKind: "ra", ItemID: "ra-tr"})
Expect(out).To(Equal(outcomeFailed))
steps := trace.Steps()
Expect(steps).To(HaveLen(1), "a radio fault must leave one step so explain is not blank")
Expect(steps[0].Candidate).To(Equal("upload"))
Expect(steps[0].Outcome).To(Equal(OutcomeUnreadable))
})
It("failed-on-extError: leaves the item's state untouched", func() {
conf.Server.CoverArtPriority = "external"
ds.MockedAlbum.(*tests.MockAlbumRepo).SetData(model.Albums{

View File

@ -37,7 +37,7 @@ type resolution struct {
// transient external failure still retries; localErr is dropped, as the scanner re-lists changes.
type chainState struct {
extErr, localErr bool
trace *ChainTrace // nil unless the CLI asked for a trace
trace *ChainTrace // nil only where no caller attached one
}
// try stamps the accumulated external failure onto a hit, and records the miss otherwise.
@ -137,6 +137,15 @@ func MayFetchExternal(kind model.Kind) bool {
// ImageAgentCount is how many enabled agents provide artist and album images.
type ImageAgentCount struct{ Artist, Album int }
// NewImageAgentCount counts what an external step would consult, so an estimate and the gate that
// guards it cannot disagree about which agents exist.
func NewImageAgentCount(ag *agents.Agents) ImageAgentCount {
if ag == nil {
return ImageAgentCount{}
}
return ImageAgentCount{Artist: len(ag.ArtistImageAgents()), Album: len(ag.AlbumImageAgents())}
}
// 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 {
@ -354,10 +363,13 @@ func (r *resolver) resolvePlaylist(ctx context.Context, playlistID string) (reso
}
if remoteImg != nil && conf.Server.EnableM3UExternalAlbumArt {
sf := func() (io.ReadCloser, string, error) { return fromURL(ctx, remoteImg) }
if res, ok, isErr := resolveExternalStep(r.ext.gate, "m3u", sf); ok {
if res, ok, err := resolveExternalStep(r.ext.gate, "m3u", sf); ok {
return res, nil
} else if isErr {
} else if err != nil {
extErr = true
// Record it here with its detail: once album sampling adds its own steps, the processor's
// empty-trace fallback no longer fires, and the error that forced the retry would be lost.
traceFrom(ctx).add(TraceStep{Candidate: ExternalPrefix + "m3u", Outcome: OutcomeError, Detail: err.Error()})
}
}
@ -461,14 +473,17 @@ func (r *resolver) resolveDisc(ctx context.Context, id string) (resolution, erro
return dr.selectImage(ctx, r.ffmpeg, conf.Server.DiscArtPriority, &chain)
}
// resolveExternalStep runs a single external sourceFunc through the named gate. extErr excludes
// a not-found, which is a definitive "no" rather than a failure.
func resolveExternalStep(gate gateFunc, name string, sf sourceFunc) (res resolution, ok bool, extErr bool) {
// resolveExternalStep runs a single external sourceFunc through the named gate. A not-found is a
// definitive "no", returned as (_, false, nil); any other error is a failure the caller records.
func resolveExternalStep(gate gateFunc, name string, sf sourceFunc) (resolution, bool, error) {
r, path, err := gate(name, sf)
if r != nil {
return resolution{reader: r, source: externalCandidate, sourcePath: path}, true, false
return resolution{reader: r, source: externalCandidate, sourcePath: path}, true, nil
}
return resolution{}, false, err != nil && !errors.Is(err, model.ErrNotFound)
if errors.Is(err, model.ErrNotFound) {
return resolution{}, false, nil
}
return resolution{}, false, err
}
// classifyPlaylistImage splits a playlist ExternalImageURL into a local filesystem path or a
@ -561,7 +576,9 @@ func resolveLocalFile(path, source string) (resolution, bool) {
}
f, err := os.Open(path)
if err != nil {
return resolution{localError: !errors.Is(err, fs.ErrNotExist)}, false
// Carry the source label even on a fault, so a resolver with no chain (playlist/radio) can
// still name what faulted in the trace.
return resolution{source: source, localError: !errors.Is(err, fs.ErrNotExist)}, false
}
return resolution{reader: f, source: source, sourcePath: path, refMtime: mtimeOf(path)}, true
}

View File

@ -520,6 +520,37 @@ var _ = Describe("resolveItem", func() {
Expect(gatedNames).To(Equal([]string{"m3u"}), "the playlist URL fetch is gated under \"m3u\"")
})
It("records the m3u failure in the trace even when album sampling adds its own steps", func() {
conf.Server.EnableM3UExternalAlbumArt = true
folderRepo.result = nil // the sampled album yields no tile, so the m3u failure is what forced the retry
plRepo := tests.CreateMockPlaylistRepo()
plRepo.SetData(model.Playlists{{ID: "plm3u", Name: "Playlist", ExternalImageURL: "http://example.com/cover.jpg"}})
plRepo.TracksRepo = &tests.MockPlaylistTrackRepo{AlbumIDs: []string{"t1"}}
ds.MockedPlaylist = plRepo
gate := func(string, func() (io.ReadCloser, string, error)) (io.ReadCloser, string, error) {
return nil, "", errors.New("network down")
}
trace := &ChainTrace{}
res, err := newResolver(ds, ag, ffm, gate).resolve(withTrace(ctx, trace),
model.ArtworkQueueItem{ItemKind: "pl", ItemID: "plm3u"})
Expect(err).ToNot(HaveOccurred())
Expect(res.extError).To(BeTrue())
steps := trace.Steps()
var m3u *TraceStep
for i := range steps {
if steps[i].Candidate == ExternalPrefix+"m3u" && steps[i].Outcome == OutcomeError {
m3u = &steps[i]
}
}
Expect(m3u).ToNot(BeNil(), "the m3u fetch error must be traced at its source, not left to the empty-trace fallback")
Expect(m3u.Detail).To(Equal("network down"),
"the trace must carry the underlying error so explain can tell a timeout from an HTTP error")
})
It("treats a missing local ExternalImageURL as a definitive miss, not extError", func() {
folderRepo.result = nil // no grid tiles, so the local-file miss is what surfaces

View File

@ -2,10 +2,12 @@ package artwork
import (
"context"
"errors"
"encoding/json"
"io"
"slices"
"sync"
"github.com/navidrome/navidrome/utils/str"
)
// Outcome is what the priority chain observed for one candidate; the CLI renders and branches on these.
@ -16,7 +18,6 @@ const (
OutcomeMiss Outcome = "miss"
OutcomeUnreadable Outcome = "unreadable"
OutcomeSkipped Outcome = "skipped"
OutcomeWouldTry Outcome = "would-try"
OutcomeError Outcome = "error"
)
@ -34,8 +35,8 @@ type TraceStep struct {
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.
// ChainTrace collects the walk of a single resolution: the worker attaches one per queue
// item so it can be stored, and the CLI attaches one per explain.
type ChainTrace struct {
mu sync.Mutex
steps []TraceStep
@ -59,6 +60,65 @@ func (t *ChainTrace) Steps() []TraceStep {
return slices.Clone(t.steps)
}
// maxTraceDetail bounds a stored Detail, which on the failure paths is an error string of
// unknown length. Past ~1kB a row spills to an overflow page, slowing every scan of the table.
const maxTraceDetail = 200
// storedStep is the persisted shape of a TraceStep. The keys are single letters because a trace
// is written for every item, and the encoded length is repeated across the whole library.
type storedStep struct {
C string `json:"c"`
O Outcome `json:"o"`
D string `json:"d,omitempty"`
}
// encode serializes the trace for storage, without the copy Steps would make for a caller
// that only wants to write it.
func (t *ChainTrace) encode(sourcePath string) string {
if t == nil {
return encodeSteps(nil, sourcePath)
}
t.mu.Lock()
defer t.mu.Unlock()
return encodeSteps(t.steps, sourcePath)
}
// encodeSteps writes the stored form. A hit's Detail is the winning source's path, which the
// same row already stores as source_path, so it is dropped and DecodeTrace puts it back.
func encodeSteps(steps []TraceStep, sourcePath string) string {
out := make([]storedStep, 0, len(steps))
for _, s := range steps {
d := s.Detail
if s.Outcome == OutcomeHit && d == sourcePath {
d = ""
}
out = append(out, storedStep{C: s.Candidate, O: s.Outcome, D: str.TruncateRunes(d, maxTraceDetail, "...")})
}
b, _ := json.Marshal(out) // []storedStep is all strings, so this cannot fail
return string(b)
}
// DecodeTrace reverses the stored form. A trace that will not parse is reported as no trace at all,
// since a diagnostic command must not fail on a bad row.
func DecodeTrace(encoded, sourcePath string) []TraceStep {
if encoded == "" {
return nil
}
var stored []storedStep
if err := json.Unmarshal([]byte(encoded), &stored); err != nil {
return nil
}
steps := make([]TraceStep, 0, len(stored))
for _, s := range stored {
d := s.D
if d == "" && s.O == OutcomeHit {
d = sourcePath
}
steps = append(steps, TraceStep{Candidate: s.C, Outcome: s.O, Detail: d})
}
return steps
}
type traceCtxKey struct{}
func withTrace(ctx context.Context, t *ChainTrace) context.Context {
@ -70,30 +130,23 @@ func traceFrom(ctx context.Context) *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
// recordAgent files what one external agent answered. The agent loops call this rather than a
// gate wrapper, because only they hold the context that carries the trace.
func recordAgent(ctx context.Context, name string, r io.ReadCloser, path string, err error) {
t := traceFrom(ctx)
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})
}
}
// 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
}
// traceStage records a failure from the stages that run after the priority chain has already
// picked a winner: most ways an item can fail are here, not in the chain walk.
func traceStage(ctx context.Context, stage string, err error) {
traceFrom(ctx).add(TraceStep{Candidate: stage, Outcome: OutcomeError, Detail: err.Error()})
}

View File

@ -24,13 +24,63 @@ var _ = Describe("trace vocabulary", func() {
// 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"}))
OutcomeHit, OutcomeMiss, OutcomeUnreadable, OutcomeSkipped, OutcomeError,
}).To(Equal([]Outcome{"hit", "miss", "unreadable", "skipped", "error"}))
Expect(externalCandidate).To(Equal("external"))
Expect(ExternalPrefix).To(Equal("external:"))
})
})
var _ = Describe("encodeSteps/DecodeTrace", func() {
It("round-trips a trace", func() {
steps := []TraceStep{
{Candidate: "cover.png", Outcome: OutcomeMiss},
{Candidate: "cover.*", Outcome: OutcomeHit, Detail: "/music/a/cover.jpg"},
}
Expect(DecodeTrace(encodeSteps(steps, ""), "")).To(Equal(steps))
})
It("encodes an empty trace as an empty JSON array", func() {
Expect(encodeSteps(nil, "")).To(Equal("[]"))
Expect(DecodeTrace("[]", "")).To(BeEmpty())
})
It("tolerates a row written before the column existed", func() {
Expect(DecodeTrace("", "")).To(BeEmpty())
})
// The hit detail repeats source_path byte for byte, and that column is on the same row.
It("drops a hit detail that repeats sourcePath, and restores it on read", func() {
path := "/music/artist/album/cover.jpg"
steps := []TraceStep{{Candidate: "cover.*", Outcome: OutcomeHit, Detail: path}}
encoded := encodeSteps(steps, path)
Expect(encoded).NotTo(ContainSubstring(path))
Expect(DecodeTrace(encoded, path)).To(Equal(steps))
})
It("keeps a detail that differs from sourcePath", func() {
steps := []TraceStep{{Candidate: "external:deezer", Outcome: OutcomeHit, Detail: "https://cdn/x.jpg"}}
Expect(DecodeTrace(encodeSteps(steps, "/music/a/cover.jpg"), "/music/a/cover.jpg")).To(Equal(steps))
})
// A row past ~1kB spills to an overflow page on these WITHOUT ROWID tables, which would
// slow every scan; Detail is an error string on the failure paths, so it needs a bound.
It("bounds a detail so one long error cannot inflate the row", func() {
steps := []TraceStep{{Candidate: "decode", Outcome: OutcomeError, Detail: strings.Repeat("x", 5000)}}
got := DecodeTrace(encodeSteps(steps, ""), "")
Expect(len(got[0].Detail)).To(BeNumerically("<=", 210))
Expect(got[0].Detail).To(HaveSuffix("..."))
Expect(got[0].Candidate).To(Equal("decode"), "truncating the detail must not disturb the step")
})
It("only restores sourcePath onto a detail-less hit", func() {
steps := []TraceStep{{Candidate: "cover.*", Outcome: OutcomeMiss}}
Expect(DecodeTrace(encodeSteps(steps, "/music/a/cover.jpg"), "/music/a/cover.jpg")).To(Equal(steps))
})
})
var _ = Describe("chainTrace", func() {
It("returns nil when no trace is attached", func() {
Expect(traceFrom(context.Background())).To(BeNil())
@ -113,63 +163,41 @@ var _ = Describe("chainState tracing", func() {
})
})
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") }
var _ = Describe("external agent tracing", func() {
var (
t *ChainTrace
ctx context.Context
body io.ReadCloser
)
BeforeEach(func() {
t = &ChainTrace{}
ctx = withTrace(context.Background(), t)
body = io.NopCloser(strings.NewReader("x"))
})
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())
recordAgent(ctx, "deezer", body, "http://img", nil)
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)
recordAgent(ctx, "deezer", nil, "", agents.ErrNotFound)
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)
recordAgent(ctx, "deezer", nil, "", model.ErrNotFound)
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)
recordAgent(ctx, "apple-music", nil, "", errors.New("returned status 429"))
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() {
@ -409,51 +437,51 @@ var _ = Describe("NewTracingResolver", func() {
t = &ChainTrace{}
})
Context("offline", func() {
Context("resolving", func() {
var fake *fakeImageAgent
BeforeEach(func() {
fake = &fakeImageAgent{name: "offline-probe"}
// Misses, so the chain falls through to the local tier and both are traced.
fake = &fakeImageAgent{name: "probe", err: agents.ErrNotFound}
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")
It("asks the agents and records what each answered", func() {
source, err := NewTracingResolver(ds, imageAgents(fake), ffm, t, true).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}))
Expect(fake.albumCalls).To(Equal(1))
Expect(t.Steps()).To(ContainElement(TraceStep{Candidate: "external:probe", Outcome: OutcomeMiss}))
})
It("records the local chain steps too", func() {
_, err := NewTracingResolver(ds, imageAgents(fake), ffm, t, false).Resolve(context.Background(), model.KindAlbumArtwork, "al1")
_, err := NewTracingResolver(ds, imageAgents(fake), ffm, t, true).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.Candidate).To(Equal("embedded"), "the local chain must be traced, not just the external tier")
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")
_, err := NewTracingResolver(ds, imageAgents(fake), ffm, t, true).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")
"explain is read-only; a diagnostic walk must never become the stored answer")
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")
source, err := NewTracingResolver(ds, imageAgents(fake), ffm, t, true).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(fake.artistCalls).To(Equal(1))
Expect(artworkRepo.ItemData).To(BeEmpty())
Expect(queueRepo.Data).To(BeEmpty())
})
@ -465,31 +493,39 @@ var _ = Describe("NewTracingResolver", func() {
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")
source, err := NewTracingResolver(ds, imageAgents(fake), ffm, t, true).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")
})
// Serving falls back disc -> album and track -> disc -> album. The resolver does not, but
// if it ever did, an explain without --live would start calling providers uninvited.
It("cannot reach a provider without live, whatever the chain does", func() {
conf.Server.DiscArtPriority = "external, cover.*"
conf.Server.CoverArtPriority = "external, cover.*"
conf.Server.EnableMediaFileCoverArt = true
mfRepo := tests.CreateMockMediaFileRepo()
mfRepo.SetData(model.MediaFiles{{ID: "mf1", LibraryID: 0, HasCoverArt: true,
Path: "tests/fixtures/artist/an-album/test.mp3"}})
ds.MockedMediaFile = mfRepo
offline := NewTracingResolver(ds, imageAgents(fake), ffm, t, false)
_, err := offline.Resolve(context.Background(), model.KindDiscArtwork, "al1:1")
Expect(err).ToNot(HaveOccurred())
_, err = offline.Resolve(context.Background(), model.KindMediaFileArtwork, "mf1")
Expect(err).ToNot(HaveOccurred())
Expect(fake.albumCalls).To(BeZero())
Expect(fake.artistCalls).To(BeZero())
})
It("propagates a lookup error", func() {
_, err := NewTracingResolver(ds, imageAgents(fake), ffm, t, false).Resolve(context.Background(), model.KindAlbumArtwork, "nope")
_, err := NewTracingResolver(ds, imageAgents(fake), ffm, t, true).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() {

View File

@ -40,6 +40,7 @@ type drainPool struct {
// independently, and pruneMu serializes prune against the store-write window.
type Worker struct {
proc *processor
agents *agents.Agents
cache cache.FileCache
ffmpeg ffmpeg.FFmpeg
broker events.Broker
@ -54,6 +55,7 @@ type Worker struct {
func NewWorker(ds model.DataStore, store *ImageStore, ag *agents.Agents, ffmpeg ffmpeg.FFmpeg, broker events.Broker, imgCache cache.FileCache) *Worker {
w := &Worker{
proc: &processor{ds: ds, store: store},
agents: ag,
cache: imgCache,
ffmpeg: ffmpeg,
broker: broker,
@ -132,12 +134,14 @@ func (w *Worker) RunPrune(ctx context.Context) error {
}
// Backfill enqueues every entity for re-resolution when the artwork config fingerprint changed,
// artists first. It reports whether anything was enqueued.
// artists first. It reports whether the backfill ran.
func (w *Worker) Backfill(ctx context.Context) (bool, error) {
return backfill(ctx, w.proc.ds)
s, err := backfill(ctx, w.proc.ds, func() ImageAgentCount { return NewImageAgentCount(w.agents) })
return s.Ran, err
}
// EnqueueStaleAbsentAll requeues known-absent entries older than StaleAbsentAge.
// EnqueueStaleAbsentAll requeues known-absent entries older than StaleAbsentAge, at most
// StaleAbsentRecheckBatch per kind, oldest first.
func (w *Worker) EnqueueStaleAbsentAll(ctx context.Context) error {
return enqueueStaleAbsentAll(ctx, w.proc.ds)
}
@ -235,6 +239,8 @@ func (w *Worker) broadcastRefresh(ctx context.Context, found []model.ArtworkQueu
func (w *Worker) process(ctx context.Context, item model.ArtworkQueueItem) (outcome, *acquired) {
item.ImageType = cmp.Or(item.ImageType, model.ImageTypePrimary)
trace := &ChainTrace{}
ctx = withTrace(ctx, trace)
out, got := w.proc.acquire(ctx, item)
queue := w.proc.ds.ArtworkQueue(ctx)
@ -247,10 +253,11 @@ func (w *Worker) process(ctx context.Context, item model.ArtworkQueueItem) (outc
}
case outcomeFoundStale, outcomeFailed:
retryAt := time.Now().Add(backoff(item.Attempts))
encoded := trace.encode("")
if retryAt.Before(item.EnqueuedAt.Add(giveUpAfter)) {
// A mid-flight re-enqueue reset retry_at; stale backoff must not stomp its
// fresh, immediate eligibility.
if err := queue.MarkFailedIfUnchanged(item.ItemKind, item.ItemID, item.ImageType, item.RetryAt, retryAt); err != nil {
if err := queue.MarkFailedIfUnchanged(item.ItemKind, item.ItemID, item.ImageType, item.RetryAt, retryAt, encoded); err != nil {
log.Warn(ctx, "Artwork: Could not reschedule failed queue item", "kind", item.ItemKind, "id", item.ItemID, err)
}
log.Debug(ctx, "Artwork: Rescheduled item", "kind", item.ItemKind, "id", item.ItemID,
@ -265,6 +272,9 @@ func (w *Worker) process(ctx context.Context, item model.ArtworkQueueItem) (outc
writeAbsent(ctx, w.proc.ds.Artwork(ctx), item)
settled = "recorded absent"
}
// The queue row is about to go, taking the only record of the failure with it. This write is
// unconditional (not CAS-guarded) — safe only because the drain resolves each item serially.
w.recordGiveUp(ctx, item, encoded)
log.Info(ctx, "Artwork: Retry budget exhausted, giving up", "kind", item.ItemKind, "id", item.ItemID,
"outcome", out, "attempts", item.Attempts+1, "budget", giveUpAfter, "settled", settled)
if err := queue.DeleteIfUnchanged(item.ItemKind, item.ItemID, item.ImageType, item.RetryAt); err != nil {
@ -274,6 +284,18 @@ func (w *Worker) process(ctx context.Context, item model.ArtworkQueueItem) (outc
return out, got
}
// recordGiveUp keeps the last failure on the state row after the queue row is deleted. An item
// that never resolved has no row to update, and creating one would settle it absent.
func (w *Worker) recordGiveUp(ctx context.Context, item model.ArtworkQueueItem, trace string) {
kind, ok := model.ParseKind(item.ItemKind)
if !ok {
return
}
if err := w.proc.ds.Artwork(ctx).PutLastFailure(kind, item.ItemID, item.ImageType, trace); err != nil {
log.Warn(ctx, "Artwork: Could not record the last failure", "kind", item.ItemKind, "id", item.ItemID, err)
}
}
func (w *Worker) hasResolvedArtwork(ctx context.Context, item model.ArtworkQueueItem) bool {
kind, ok := model.ParseKind(item.ItemKind)
if !ok {

View File

@ -95,6 +95,17 @@ func (f *fakeEventBroker) getEvents() []events.Event {
var _ events.Broker = (*fakeEventBroker)(nil)
// expireQueued ages a row past the retry budget, so the next drain settles it instead of retrying.
func expireQueued(q *tests.MockArtworkQueueRepo, id string) {
GinkgoHelper()
for k, v := range q.Data {
if v.ItemID == id {
v.EnqueuedAt = time.Now().Add(-(giveUpAfter + time.Hour))
q.Data[k] = v
}
}
}
func findQueued(q *tests.MockArtworkQueueRepo, kind, id string) *model.ArtworkQueueItem {
for _, it := range q.Data {
if it.ItemKind == kind && it.ItemID == id {
@ -318,12 +329,7 @@ var _ = Describe("Worker", func() {
w = NewWorker(ds, store, ag, ffm, broker, imgCache)
Expect(queueRepo.Enqueue(model.ArtworkQueueItem{ItemKind: "al", ItemID: "al9"})).To(Succeed())
// Age the row past the retry budget.
for k, v := range queueRepo.Data {
if v.ItemID == "al9" {
v.EnqueuedAt = time.Now().Add(-(giveUpAfter + time.Hour))
queueRepo.Data[k] = v
}
}
expireQueued(queueRepo, "al9")
n, err := w.drain(ctx, 1)
Expect(err).ToNot(HaveOccurred())
@ -345,12 +351,7 @@ var _ = Describe("Worker", func() {
imageAgents(&fakeImageAgent{name: "failAgent", err: errors.New("agent timed out")})
w = NewWorker(ds, store, ag, ffm, broker, imgCache)
Expect(queueRepo.Enqueue(model.ArtworkQueueItem{ItemKind: "al", ItemID: "al10"})).To(Succeed())
for k, v := range queueRepo.Data {
if v.ItemID == "al10" {
v.EnqueuedAt = time.Now().Add(-(giveUpAfter + time.Hour))
queueRepo.Data[k] = v
}
}
expireQueued(queueRepo, "al10")
n, err := w.drain(ctx, 1)
Expect(err).ToNot(HaveOccurred())
@ -362,6 +363,67 @@ var _ = Describe("Worker", func() {
Expect(ia.Hash).To(Equal("cafebabe"), "a persistent outage must not discard served art")
})
It("records on the queue row why the last attempt failed", func() {
conf.Server.CoverArtPriority = "external"
ds.MockedAlbum.(*tests.MockAlbumRepo).SetData(model.Albums{{ID: "al11", Name: "Album"}})
imageAgents(&fakeImageAgent{name: "failAgent", err: errors.New("agent timed out")})
w = NewWorker(ds, store, ag, ffm, broker, imgCache)
Expect(queueRepo.Enqueue(model.ArtworkQueueItem{ItemKind: "al", ItemID: "al11"})).To(Succeed())
_, err := w.drain(ctx, 1)
Expect(err).ToNot(HaveOccurred())
it := findQueued(queueRepo, "al", "al11")
Expect(it).ToNot(BeNil())
Expect(DecodeTrace(it.Trace, "")).To(ContainElement(SatisfyAll(
HaveField("Candidate", "external:failAgent"),
HaveField("Outcome", OutcomeError),
HaveField("Detail", ContainSubstring("agent timed out")),
)), "a retrying row must say why it is retrying")
})
// The give-up path settles absent before recording, so the row exists by the time the
// failure is written. Recording first would silently lose it for every unresolved item.
It("keeps the failure for an item that never resolved at all", func() {
conf.Server.CoverArtPriority = "external"
ds.MockedAlbum.(*tests.MockAlbumRepo).SetData(model.Albums{{ID: "al13", Name: "Album"}})
imageAgents(&fakeImageAgent{name: "failAgent", err: errors.New("agent timed out")})
w = NewWorker(ds, store, ag, ffm, broker, imgCache)
Expect(queueRepo.Enqueue(model.ArtworkQueueItem{ItemKind: "al", ItemID: "al13"})).To(Succeed())
expireQueued(queueRepo, "al13")
_, err := w.drain(ctx, 1)
Expect(err).ToNot(HaveOccurred())
ia, err := artRepo.GetItemArtwork(model.KindAlbumArtwork, "al13", model.ImageTypePrimary)
Expect(err).ToNot(HaveOccurred(), "settling absent must create the row the failure is written to")
Expect(ia.Hash).To(BeEmpty())
Expect(DecodeTrace(ia.LastFailure, "")).ToNot(BeEmpty())
})
It("keeps the failure on the state row after the queue row is deleted", func() {
conf.Server.CoverArtPriority = "external"
ds.MockedAlbum.(*tests.MockAlbumRepo).SetData(model.Albums{{ID: "al12", Name: "Album"}})
Expect(artRepo.PutItemArtwork(&model.ItemArtwork{
ItemKind: "al", ItemID: "al12", ImageType: model.ImageTypePrimary,
Hash: "cafebabe", Source: "external:lastfm",
})).To(Succeed())
imageAgents(&fakeImageAgent{name: "failAgent", err: errors.New("agent timed out")})
w = NewWorker(ds, store, ag, ffm, broker, imgCache)
Expect(queueRepo.Enqueue(model.ArtworkQueueItem{ItemKind: "al", ItemID: "al12"})).To(Succeed())
expireQueued(queueRepo, "al12")
_, err := w.drain(ctx, 1)
Expect(err).ToNot(HaveOccurred())
Expect(findQueued(queueRepo, "al", "al12")).To(BeNil())
ia, err := artRepo.GetItemArtwork(model.KindAlbumArtwork, "al12", model.ImageTypePrimary)
Expect(err).ToNot(HaveOccurred())
Expect(DecodeTrace(ia.LastFailure, "")).ToNot(BeEmpty(),
"the queue row is gone, so this is the only remaining record of the failure")
Expect(ia.Hash).To(Equal("cafebabe"), "recording the failure must not disturb the served art")
})
// 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() {
@ -371,12 +433,7 @@ var _ = Describe("Worker", func() {
{ID: "mfX", LibraryID: 0, Path: "tests/fixtures/artist/an-album/gone.mp3", HasCoverArt: true},
})
Expect(queueRepo.Enqueue(model.ArtworkQueueItem{ItemKind: "mf", ItemID: "mfX"})).To(Succeed())
for k, v := range queueRepo.Data {
if v.ItemID == "mfX" {
v.EnqueuedAt = time.Now().Add(-(giveUpAfter + time.Hour))
queueRepo.Data[k] = v
}
}
expireQueued(queueRepo, "mfX")
n, err := w.drain(ctx, 1)
Expect(err).ToNot(HaveOccurred())
@ -386,6 +443,8 @@ var _ = Describe("Worker", func() {
_, err = artRepo.GetItemArtwork(model.KindMediaFileArtwork, "mfX", model.ImageTypePrimary)
Expect(err).To(MatchError(model.ErrNotFound),
"no row leaves the track unresolved, so a later view can still recover it")
// Known gap: with no row and no absent settle, there is nowhere to keep the failure.
// Creating one here would write an empty hash, which every reader treats as absent.
})
It("resolves a private playlist under an admin context instead of failing forever", func() {

View File

@ -4,6 +4,8 @@ import (
"cmp"
"context"
"crypto/sha256"
"errors"
"slices"
"sync"
"time"
@ -26,6 +28,13 @@ var (
PublicTokenAuth *jwtauth.JWTAuth
)
// Audiences a session token can be scoped to. A token with no audience is accepted anywhere.
const (
AudienceJellyfin = "jellyfin"
AudienceSubsonic = "subsonic"
AudienceNative = "native"
)
// Init creates the JWTAuth objects from the secrets stored in the DB.
// Missing or undecryptable secrets are regenerated and stored.
func Init(ds model.DataStore) {
@ -66,15 +75,20 @@ func CreateExpiringPublicToken(exp time.Time, claims Claims) (string, error) {
return token, err
}
func CreateToken(u *model.User) (string, error) {
claims := Claims{
func userClaims(u *model.User, audience []string) Claims {
return Claims{
Issuer: consts.JWTIssuer,
Subject: u.UserName,
IssuedAt: time.Now(),
UserID: u.ID,
IsAdmin: u.IsAdmin,
Epoch: u.TokenEpoch,
Audience: audience,
}
token, _, err := TokenAuth.Encode(claims.ToMap())
}
func CreateToken(u *model.User) (string, error) {
token, _, err := TokenAuth.Encode(userClaims(u, nil).ToMap())
if err != nil {
return "", err
}
@ -82,10 +96,20 @@ func CreateToken(u *model.User) (string, error) {
return TouchToken(token)
}
// CreateAPIToken mints a non-expiring token scoped to one API, matching how Jellyfin
// clients expect tokens to behave. Revocation is by token epoch, not expiry.
func CreateAPIToken(u *model.User, audience string) (string, error) {
_, token, err := TokenAuth.Encode(userClaims(u, []string{audience}).ToMap())
return token, err
}
func TouchToken(token jwt.Token) (string, error) {
claims := ClaimsFromToken(token).
WithExpiresAt(time.Now().UTC().Add(conf.Server.SessionTimeout))
_, newToken, err := TokenAuth.Encode(claims.ToMap())
return TouchClaims(ClaimsFromToken(token))
}
func TouchClaims(c Claims) (string, error) {
c = c.WithExpiresAt(time.Now().UTC().Add(conf.Server.SessionTimeout))
_, newToken, err := TokenAuth.Encode(c.ToMap())
return newToken, err
}
@ -106,6 +130,29 @@ func ValidatePublic(tokenStr string) (Claims, error) {
return ClaimsFromToken(token), nil
}
var (
ErrTokenRevoked = errors.New("token revoked")
ErrWrongAudience = errors.New("token not valid for this API")
ErrWrongUser = errors.New("token issued for a different user")
)
// CheckClaims gates a session token against the user it names. Callers must have already
// verified the signature; this adds revocation and API scoping on top.
func CheckClaims(c Claims, usr model.User, audience string) error {
// Usernames can be reused: deleting a user and recreating the name yields a new random id
// at epoch 0, which an old token would otherwise match.
if c.UserID != "" && c.UserID != usr.ID {
return ErrWrongUser
}
if c.Epoch != usr.TokenEpoch {
return ErrTokenRevoked
}
if len(c.Audience) > 0 && !slices.Contains(c.Audience, audience) {
return ErrWrongAudience
}
return nil
}
func WithAdminUser(ctx context.Context, ds model.DataStore) context.Context {
u, err := ds.User(ctx).FindFirstAdmin()
if err != nil {

View File

@ -151,4 +151,113 @@ var _ = Describe("Auth", func() {
Expect(decodedClaims.ExpiresAt.Sub(yesterday)).To(BeNumerically(">=", oneDay))
})
})
Describe("CreateAPIToken", func() {
var usr *model.User
BeforeEach(func() {
usr = &model.User{ID: "123", UserName: "johndoe", TokenEpoch: 4}
})
It("does not expire", func() {
tokenStr, err := auth.CreateAPIToken(usr, auth.AudienceJellyfin)
Expect(err).ToNot(HaveOccurred())
claims, err := auth.Validate(tokenStr)
Expect(err).ToNot(HaveOccurred())
Expect(claims.ExpiresAt.IsZero()).To(BeTrue())
})
It("carries the audience and the user's epoch", func() {
tokenStr, err := auth.CreateAPIToken(usr, auth.AudienceJellyfin)
Expect(err).ToNot(HaveOccurred())
claims, err := auth.Validate(tokenStr)
Expect(err).ToNot(HaveOccurred())
Expect(claims.Audience).To(Equal([]string{"jellyfin"}))
Expect(claims.Epoch).To(Equal(4))
Expect(claims.Subject).To(Equal("johndoe"))
Expect(claims.UserID).To(Equal("123"))
})
})
Describe("CreateToken with an epoch", func() {
It("carries the epoch and still expires", func() {
usr := &model.User{ID: "123", UserName: "johndoe", TokenEpoch: 9}
tokenStr, err := auth.CreateToken(usr)
Expect(err).ToNot(HaveOccurred())
claims, err := auth.Validate(tokenStr)
Expect(err).ToNot(HaveOccurred())
Expect(claims.Epoch).To(Equal(9))
Expect(claims.Audience).To(BeEmpty())
Expect(claims.ExpiresAt).To(BeTemporally(">", time.Now()))
})
})
Describe("TouchClaims", func() {
It("preserves custom claims and refreshes the expiry", func() {
tokenStr, err := auth.TouchClaims(auth.Claims{Subject: "johndoe", UserID: "123", Epoch: 5})
Expect(err).ToNot(HaveOccurred())
claims, err := auth.Validate(tokenStr)
Expect(err).ToNot(HaveOccurred())
Expect(claims.Epoch).To(Equal(5))
Expect(claims.Subject).To(Equal("johndoe"))
Expect(claims.ExpiresAt).To(BeTemporally(">", time.Now()))
})
})
Describe("CheckClaims", func() {
usr := model.User{ID: "123", UserName: "johndoe", TokenEpoch: 2}
It("accepts a matching epoch and audience", func() {
c := auth.Claims{Epoch: 2, Audience: []string{auth.AudienceJellyfin}}
Expect(auth.CheckClaims(c, usr, auth.AudienceJellyfin)).To(Succeed())
})
It("accepts a token with no audience on any API", func() {
c := auth.Claims{Epoch: 2}
Expect(auth.CheckClaims(c, usr, auth.AudienceNative)).To(Succeed())
Expect(auth.CheckClaims(c, usr, auth.AudienceJellyfin)).To(Succeed())
Expect(auth.CheckClaims(c, usr, auth.AudienceSubsonic)).To(Succeed())
})
It("rejects a stale epoch", func() {
c := auth.Claims{Epoch: 1, Audience: []string{auth.AudienceJellyfin}}
Expect(auth.CheckClaims(c, usr, auth.AudienceJellyfin)).To(MatchError(auth.ErrTokenRevoked))
})
It("rejects a token minted for another API", func() {
c := auth.Claims{Epoch: 2, Audience: []string{auth.AudienceJellyfin}}
Expect(auth.CheckClaims(c, usr, auth.AudienceNative)).To(MatchError(auth.ErrWrongAudience))
Expect(auth.CheckClaims(c, usr, auth.AudienceSubsonic)).To(MatchError(auth.ErrWrongAudience))
})
It("accepts a multi-audience token that includes this API", func() {
c := auth.Claims{Epoch: 2, Audience: []string{"other", auth.AudienceNative}}
Expect(auth.CheckClaims(c, usr, auth.AudienceNative)).To(Succeed())
})
It("accepts a pre-upgrade token against a never-bumped user", func() {
fresh := model.User{ID: "456", UserName: "newbie"}
Expect(auth.CheckClaims(auth.Claims{}, fresh, auth.AudienceNative)).To(Succeed())
})
It("accepts a token whose user id matches", func() {
c := auth.Claims{UserID: "123", Epoch: 2}
Expect(auth.CheckClaims(c, usr, auth.AudienceNative)).To(Succeed())
})
It("rejects a token for a deleted user recreated under the same name", func() {
recreated := model.User{ID: "new-random-id", UserName: "johndoe"}
c := auth.Claims{UserID: "123", Audience: []string{auth.AudienceJellyfin}}
Expect(auth.CheckClaims(c, recreated, auth.AudienceJellyfin)).To(MatchError(auth.ErrWrongUser))
})
It("accepts a token that carries no user id", func() {
fresh := model.User{ID: "456", UserName: "newbie"}
Expect(auth.CheckClaims(auth.Claims{}, fresh, auth.AudienceNative)).To(Succeed())
})
})
})

View File

@ -11,7 +11,8 @@ import (
type Claims struct {
// Standard JWT claims
Issuer string
Subject string // username for session tokens
Subject string // username for session tokens
Audience []string // which API may accept this token; empty means any
IssuedAt time.Time
ExpiresAt time.Time
@ -22,6 +23,7 @@ type Claims struct {
Format string // "f" - audio format
BitRate int // "b" - audio bitrate
ShareID string // "sid" - share ID for share stream tokens
Epoch int // "ep" - the user's token_epoch at mint time
}
// ToMap converts Claims to a map[string]any for use with TokenAuth.Encode().
@ -34,6 +36,9 @@ func (c Claims) ToMap() map[string]any {
if c.Subject != "" {
m[jwt.SubjectKey] = c.Subject
}
if len(c.Audience) > 0 {
m[jwt.AudienceKey] = c.Audience
}
if !c.IssuedAt.IsZero() {
m[jwt.IssuedAtKey] = c.IssuedAt.UTC().Unix()
}
@ -58,6 +63,9 @@ func (c Claims) ToMap() map[string]any {
if c.ShareID != "" {
m["sid"] = c.ShareID
}
if c.Epoch != 0 {
m["ep"] = c.Epoch
}
return m
}
@ -73,6 +81,7 @@ func ClaimsFromToken(token jwt.Token) Claims {
c.Subject, _ = token.Subject()
c.IssuedAt, _ = token.IssuedAt()
c.ExpiresAt, _ = token.Expiration()
c.Audience, _ = token.Audience()
var uid string
if err := token.Get("uid", &uid); err == nil {
@ -90,15 +99,24 @@ func ClaimsFromToken(token jwt.Token) Claims {
if err := token.Get("f", &f); err == nil {
c.Format = f
}
if err := token.Get("b", &c.BitRate); err != nil {
var bf float64
if err := token.Get("b", &bf); err == nil {
c.BitRate = int(bf)
}
}
c.BitRate = intClaim(token, "b")
var sid string
if err := token.Get("sid", &sid); err == nil {
c.ShareID = sid
}
c.Epoch = intClaim(token, "ep")
return c
}
// intClaim reads a numeric claim, which a parsed token may decode as either int or float64.
func intClaim(token jwt.Token, key string) int {
var i int
if err := token.Get(key, &i); err == nil {
return i
}
var f float64
if err := token.Get(key, &f); err == nil {
return int(f)
}
return 0
}

View File

@ -105,4 +105,44 @@ var _ = Describe("Claims", func() {
})
})
Describe("Audience and Epoch claims", func() {
It("omits both when zero", func() {
m := auth.Claims{ID: "artwork-id"}.ToMap()
Expect(m).ToNot(HaveKey("aud"))
Expect(m).ToNot(HaveKey("ep"))
})
It("includes them when set", func() {
m := auth.Claims{Subject: "u", Epoch: 3, Audience: []string{"jellyfin"}}.ToMap()
Expect(m).To(HaveKeyWithValue("ep", 3))
Expect(m).To(HaveKeyWithValue("aud", []string{"jellyfin"}))
})
It("round-trips through a signed token", func() {
tokenAuth := jwtauth.New("HS256", []byte("test-secret"), nil)
_, tokenStr, err := tokenAuth.Encode(auth.Claims{
Subject: "u", Epoch: 7, Audience: []string{"jellyfin"},
}.ToMap())
Expect(err).ToNot(HaveOccurred())
token, err := jwtauth.VerifyToken(tokenAuth, tokenStr)
Expect(err).ToNot(HaveOccurred())
claims := auth.ClaimsFromToken(token)
Expect(claims.Epoch).To(Equal(7))
Expect(claims.Audience).To(Equal([]string{"jellyfin"}))
})
It("reads a token that has neither claim", func() {
tokenAuth := jwtauth.New("HS256", []byte("test-secret"), nil)
_, tokenStr, err := tokenAuth.Encode(auth.Claims{Subject: "u"}.ToMap())
Expect(err).ToNot(HaveOccurred())
token, err := jwtauth.VerifyToken(tokenAuth, tokenStr)
Expect(err).ToNot(HaveOccurred())
claims := auth.ClaimsFromToken(token)
Expect(claims.Epoch).To(BeZero())
Expect(claims.Audience).To(BeEmpty())
})
})
})

View File

@ -232,6 +232,16 @@ var _ = Describe("Token", func() {
_, err := svc.ResolveRequestFromToken(ctx, token, mf, 0)
Expect(err).To(MatchError(ErrTokenStale))
})
It("rejects a Jellyfin access token", func() {
mf := &model.MediaFile{ID: "song-1", UpdatedAt: sourceTime}
usr := &model.User{ID: "u1", UserName: "johndoe"}
tokenStr, err := auth.CreateAPIToken(usr, auth.AudienceJellyfin)
Expect(err).ToNot(HaveOccurred())
_, err = svc.ResolveRequestFromToken(ctx, tokenStr, mf, 0)
Expect(err).To(MatchError(ErrTokenInvalid))
})
})
Describe("paramsFromToken", func() {

View File

@ -13,10 +13,15 @@ import (
_ "github.com/navidrome/navidrome/db/migrations"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/utils/hasher"
"github.com/navidrome/navidrome/utils/natural"
"github.com/navidrome/navidrome/utils/singleton"
"github.com/pressly/goose/v3"
)
// NaturalCollation sorts embedded numbers by value. It is registered on every
// connection, but only referenced when conf.Server.EnableNaturalSorting is on.
const NaturalCollation = "NATSORT"
var (
Dialect = "sqlite3"
Driver = Dialect + "_custom"
@ -32,7 +37,10 @@ func Db() *sql.DB {
return singleton.GetInstance(func() *sql.DB {
sql.Register(Driver, &sqlite3.SQLiteDriver{
ConnectHook: func(conn *sqlite3.SQLiteConn) error {
return conn.RegisterFunc("SEEDEDRAND", hasher.HashFunc(), false)
if err := conn.RegisterFunc("SEEDEDRAND", hasher.HashFunc(), false); err != nil {
return err
}
return conn.RegisterCollation(NaturalCollation, natural.CompareFold)
},
})
Path = conf.Server.DbPath

View File

@ -0,0 +1,9 @@
-- +goose Up
ALTER TABLE item_artwork ADD COLUMN trace jsonb NOT NULL DEFAULT '[]';
ALTER TABLE item_artwork ADD COLUMN last_failure jsonb NOT NULL DEFAULT '[]';
ALTER TABLE artwork_queue ADD COLUMN trace jsonb NOT NULL DEFAULT '[]';
-- +goose Down
ALTER TABLE artwork_queue DROP COLUMN trace;
ALTER TABLE item_artwork DROP COLUMN last_failure;
ALTER TABLE item_artwork DROP COLUMN trace;

View File

@ -0,0 +1,7 @@
-- +goose Up
ALTER TABLE user ADD COLUMN token_epoch INTEGER NOT NULL DEFAULT 0;
-- +goose Down
ALTER TABLE user DROP COLUMN token_epoch;

View File

@ -47,8 +47,13 @@ var redacted = &Hook{
// External services query params. Values can be JWTs (dots, dashes), so match everything up
// to the next query separator or whitespace, not just word chars. A [\w]+ class would stop
// at a JWT's first '.' and leak its payload and signature.
"([^\\w]api_key=)[^&\\s]+",
// at a JWT's first '.' and leak its payload and signature. Case-insensitive with an
// optional underscore: the API accepts api_key, apikey and ApiKey alike.
"(?i)([^\\w]api_?key=)[^&\\s]+",
// Sensitive request headers, logged as a JSON blob at trace level and never matched by the
// query-param patterns above. Blank the whole value array; values may hold escaped quotes.
`(?i)("(?:Authorization|X-Emby-Token|X-MediaBrowser-Token|X-Nd-Authorization)":\[")[^\]]*("\])`,
},
}

View File

@ -2,7 +2,9 @@ package log
import (
"context"
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"testing"
"time"
@ -92,7 +94,7 @@ var _ = Describe("Logger", func() {
SetLogSourceLine(true)
Error("A crash happened")
// NOTE: This assertion breaks if the line number above changes
Expect(hook.LastEntry().Data[" source"]).To(ContainSubstring("/log/log_test.go:93"))
Expect(hook.LastEntry().Data[" source"]).To(ContainSubstring("/log/log_test.go:95"))
Expect(hook.LastEntry().Message).To(Equal("A crash happened"))
})
@ -264,5 +266,30 @@ var _ = Describe("Logger", func() {
msg := "/jellyfin/Audio/abc/universal?static=true&api_key=eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJhZG1pbiJ9.c2ln-X_1&other=1"
Expect(Redact(msg)).To(Equal("/jellyfin/Audio/abc/universal?static=true&api_key=[REDACTED]&other=1"))
})
DescribeTable("redacts every api_key spelling the Jellyfin API accepts",
func(param string) {
msg := "/jellyfin/Audio/abc/File?" + param + "=SECRET&other=1"
Expect(Redact(msg)).To(Equal("/jellyfin/Audio/abc/File?" + param + "=[REDACTED]&other=1"))
},
Entry("api_key", "api_key"),
Entry("apikey", "apikey"),
Entry("ApiKey", "ApiKey"),
Entry("APIKEY", "APIKEY"),
)
It("redacts sensitive request headers in a logged header blob", func() {
h := http.Header{
"Authorization": {`MediaBrowser Client="Finamp", Token="jwt-secret"`},
"X-Emby-Token": {"emby-secret"},
"X-Mediabrowser-Token": {"mb-secret"},
"X-Nd-Authorization": {"Bearer nd-secret"},
"User-Agent": {"Finamp/1.0"},
}
blob, _ := json.Marshal(h)
got := Redact(string(blob))
Expect(got).ToNot(ContainSubstring("secret"))
Expect(got).To(ContainSubstring(`"User-Agent":["Finamp/1.0"]`))
})
})
})

View File

@ -51,6 +51,10 @@ type ItemArtwork struct {
SourcePath string `structs:"source_path"`
// RefMtime is SourcePath's mtime (unix-nanoseconds) at resolution; 0 when there is no SourcePath.
RefMtime int64 `structs:"ref_mtime"`
// Trace is the encoded walk that produced this state; LastFailure is the walk of the attempt
// that exhausted the retry budget. Both are JSON, read back with artwork.DecodeTrace.
Trace string `structs:"trace"`
LastFailure string `structs:"last_failure"`
// Nullable in the schema, but every insert must set them: these non-pointer fields cannot scan NULL.
AttemptedAt time.Time `structs:"attempted_at"`
UpdatedAt time.Time `structs:"updated_at"`
@ -91,6 +95,8 @@ type ArtworkQueueItem struct {
Attempts int `structs:"attempts"`
RetryAt time.Time `structs:"retry_at"`
EnqueuedAt time.Time `structs:"enqueued_at"`
// Trace is why the last attempt failed. Only Get reads it; the drain projects it away.
Trace string `structs:"trace"`
}
// Queue priorities: higher drains first.
@ -109,6 +115,8 @@ type ArtworkRepository interface {
PurgeOrphans(createdBefore time.Time) (int64, error)
GetItemArtwork(kind Kind, id, imageType string) (*ItemArtwork, error)
PutItemArtwork(ia *ItemArtwork) error
// PutLastFailure records the trace of the attempt that exhausted the retry budget.
PutLastFailure(kind Kind, id, imageType, trace string) error
DeleteForItems(kind Kind, ids []string) error
// GetInfoForItems hydrates a page in one batched query.
GetInfoForItems(kind Kind, ids []string) (map[string]ItemArtworkInfo, error)
@ -126,8 +134,9 @@ type ArtworkQueueRepository interface {
// EnqueuePreservingBackoff upserts like Enqueue but preserves an existing row's retry_at, so a
// request-triggered read-through never resets a failed resolution's backoff.
EnqueuePreservingBackoff(items ...ArtworkQueueItem) error
// EnqueueStaleAbsent inserts queue rows (priority Recheck) for absent states older than cutoff.
EnqueueStaleAbsent(kind Kind, attemptedBefore time.Time) (int64, error)
// EnqueueStaleAbsent inserts queue rows (priority Recheck) for absent states older than cutoff, oldest
// first; limit caps the selection, so already-queued rows use up budget (backpressure when the drain stalls).
EnqueueStaleAbsent(kind Kind, attemptedBefore time.Time, limit int) (int64, error)
// EnqueueAllMissing inserts queue rows for all entities with no item_artwork row, at the given priority.
EnqueueAllMissing(kind Kind, priority int) (int64, error)
// EnqueueIfMissing inserts only for items with no item_artwork row yet.
@ -145,17 +154,20 @@ type ArtworkQueueRepository interface {
DequeueBatch(n int, kinds ...string) ([]ArtworkQueueItem, error)
// MarkFailedIfUnchanged applies the failure backoff only while retry_at still matches
// seenRetryAt, so a concurrent re-enqueue keeps its fresh eligibility.
MarkFailedIfUnchanged(kind, id, imageType string, seenRetryAt, retryAt time.Time) error
MarkFailedIfUnchanged(kind, id, imageType string, seenRetryAt, retryAt time.Time, trace string) error
// 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.
// CountQueued reports the pending rows matching the kinds and priorities, grouped by both;
// an empty filter means every one.
CountQueued(kinds []Kind, priorities []int) ([]ArtworkQueueStat, error)
// CountAbsent reports the absent states of a kind, and how many are past the given cutoff,
// eligible for EnqueueStaleAbsent (which drains them limit rows per call).
CountAbsent(kind Kind, attemptedBefore time.Time) (ArtworkAbsentStat, error)
// PurgeDangling removes queue rows whose entity no longer exists.
PurgeDangling() (int64, error)
// PurgeQueued removes pending rows matching the kinds and priorities; an empty filter means every one.
PurgeQueued(kinds []Kind, priorities []int) (int64, error)
}
type ArtworkQueueStat struct {

View File

@ -6,6 +6,8 @@ import (
"strconv"
"strings"
"time"
"github.com/navidrome/navidrome/utils/slice"
)
type Kind struct {
@ -40,6 +42,11 @@ var artworkKindMap = map[string]Kind{
KindRadioArtwork.prefix: KindRadioArtwork,
}
// KindPrefixes leaves the typed Kind domain for the item_kind column, or for a help string.
func KindPrefixes(kinds []Kind) []string {
return slice.Map(kinds, func(k Kind) string { return k.prefix })
}
// ParseKind resolves an item_kind prefix (e.g. "al") to its Kind, reporting whether it was known.
// Use it at string boundaries — URL params, the item_kind column — to enter the typed Kind domain.
func ParseKind(prefix string) (Kind, bool) {

View File

@ -2,6 +2,7 @@ package request
import (
"context"
"sync/atomic"
"github.com/navidrome/navidrome/model"
)
@ -9,15 +10,16 @@ import (
type contextKey string
const (
User = contextKey("user")
Username = contextKey("username")
Client = contextKey("client")
Version = contextKey("version")
Player = contextKey("player")
Transcoding = contextKey("transcoding")
ClientUniqueId = contextKey("clientUniqueId")
ReverseProxyIp = contextKey("reverseProxyIp")
InternalAuth = contextKey("internalAuth") // Used for internal API calls, e.g., from the plugins
User = contextKey("user")
Username = contextKey("username")
Client = contextKey("client")
Version = contextKey("version")
Player = contextKey("player")
Transcoding = contextKey("transcoding")
ClientUniqueId = contextKey("clientUniqueId")
ReverseProxyIp = contextKey("reverseProxyIp")
InternalAuth = contextKey("internalAuth") // Used for internal API calls, e.g., from the plugins
TokenEpochHolder = contextKey("tokenEpochHolder")
)
var allKeys = []contextKey{
@ -125,3 +127,32 @@ func AddValues(ctx, requestCtx context.Context) context.Context {
}
return ctx
}
type tokenEpochHolder struct {
value atomic.Int64
}
// WithTokenEpochHolder installs a slot a handler can use to report a bumped token epoch
// back to middleware that has already returned from the handler's perspective.
func WithTokenEpochHolder(ctx context.Context) context.Context {
h := &tokenEpochHolder{}
h.value.Store(-1)
return context.WithValue(ctx, TokenEpochHolder, h)
}
func SetTokenEpoch(ctx context.Context, epoch int) {
if h, ok := ctx.Value(TokenEpochHolder).(*tokenEpochHolder); ok {
h.value.Store(int64(epoch))
}
}
func TokenEpochFrom(ctx context.Context) (int, bool) {
h, ok := ctx.Value(TokenEpochHolder).(*tokenEpochHolder)
if !ok {
return 0, false
}
if v := h.value.Load(); v >= 0 {
return int(v), true
}
return 0, false
}

View File

@ -0,0 +1,17 @@
package request
import (
"testing"
"github.com/navidrome/navidrome/log"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
// tests.Init is not used here: the tests package imports model/request, so importing it
// back would create an import cycle.
func TestRequest(t *testing.T) {
log.SetLevel(log.LevelFatal)
RegisterFailHandler(Fail)
RunSpecs(t, "Request Suite")
}

View File

@ -0,0 +1,40 @@
package request
import (
"context"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
var _ = Describe("Token epoch holder", func() {
It("reports nothing when unset", func() {
ctx := WithTokenEpochHolder(context.TODO())
_, ok := TokenEpochFrom(ctx)
Expect(ok).To(BeFalse())
})
It("round-trips a value set by the handler", func() {
ctx := WithTokenEpochHolder(context.TODO())
SetTokenEpoch(ctx, 7)
epoch, ok := TokenEpochFrom(ctx)
Expect(ok).To(BeTrue())
Expect(epoch).To(Equal(7))
})
It("survives being wrapped in a derived context", func() {
ctx := WithTokenEpochHolder(context.TODO())
SetTokenEpoch(context.WithValue(ctx, contextKey("unrelated"), 1), 3)
epoch, ok := TokenEpochFrom(ctx)
Expect(ok).To(BeTrue())
Expect(epoch).To(Equal(3))
})
It("is a no-op with no holder installed", func() {
Expect(func() { SetTokenEpoch(context.TODO(), 5) }).ToNot(Panic())
_, ok := TokenEpochFrom(context.TODO())
Expect(ok).To(BeFalse())
})
})

View File

@ -22,6 +22,8 @@ type User struct {
// This is only available on the backend, and it is never sent over the wire
Password string `structs:"-" json:"-"`
// Bumped on password change to invalidate every issued token for this user.
TokenEpoch int `structs:"-" json:"-"`
// This is used to set or change a password when calling Put. If it is empty, the password is not changed.
// It is received from the UI with the name "password"
NewPassword string `structs:"password,omitempty" json:"password,omitempty"` //nolint:gosec

View File

@ -113,7 +113,7 @@ func NewAlbumRepository(ctx context.Context, db dbx.Builder) model.AlbumReposito
"artist": "compilation, order_album_artist_name, order_album_name",
"album_artist": "compilation, order_album_artist_name, order_album_name",
// TODO Rename this to just year (or date)
"max_year": "coalesce(nullif(original_date,''), cast(max_year as text)), release_date, name",
"max_year": "coalesce(nullif(original_date,''), cast(max_year as text)), release_date, " + naturalSort("album.name"),
"random": "random",
"recently_added": recentlyAddedSort(),
"starred_at": "starred, starred_at",

View File

@ -10,6 +10,7 @@ import (
"github.com/Masterminds/squirrel"
"github.com/deluan/rest"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/conf/configtest"
"github.com/navidrome/navidrome/consts"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/model/id"
@ -38,6 +39,44 @@ var _ = Describe("AlbumRepository", func() {
albumRepo = NewAlbumRepository(ctx, GetDBXBuilder()).(*albumRepository)
})
Describe("natural sorting", func() {
var ids []string
BeforeEach(func() {
DeferCleanup(configtest.SetupConfig())
ids = nil
for _, n := range []string{"foo 1", "foo 10", "foo 2", "foo 20", "foo 3"} {
aid := "nat-" + n
ids = append(ids, aid)
Expect(albumRepo.Put(&model.Album{
ID: aid, LibraryID: 1, Name: n, OrderAlbumName: n,
})).To(Succeed())
}
DeferCleanup(func() {
_, _ = albumRepo.executeSQL(squirrel.Delete("album").Where(squirrel.Eq{"id": ids}))
})
})
DescribeTable("sorts albums by name",
func(naturalSorting, preferSortTags bool, expected []string) {
conf.Server.EnableNaturalSorting = naturalSorting
conf.Server.PreferSortTags = preferSortTags
albumRepo = NewAlbumRepository(ctx, GetDBXBuilder()).(*albumRepository)
albums, err := albumRepo.GetAll(model.QueryOptions{
Sort: "name", Filters: squirrel.Eq{"album.id": ids},
})
Expect(err).ToNot(HaveOccurred())
Expect(slice.Map(albums, func(a model.Album) string { return a.Name })).To(Equal(expected))
},
Entry("lexicographically by default", false, false,
[]string{"foo 1", "foo 10", "foo 2", "foo 20", "foo 3"}),
Entry("by number value when natural sorting is enabled", true, false,
[]string{"foo 1", "foo 2", "foo 3", "foo 10", "foo 20"}),
Entry("by number value with sort tags preferred too", true, true,
[]string{"foo 1", "foo 2", "foo 3", "foo 10", "foo 20"}),
)
})
Describe("Get", func() {
var Get = func(id string) (*model.Album, error) {
album, err := albumRepo.Get(id)

View File

@ -18,6 +18,7 @@ import (
const enqueueChunkSize = 100
// Every insert writes these, in this order; the INSERT..SELECT forms must project them to match.
// DequeueBatch also selects exactly these, to leave the drain's rows free of the trace it never reads.
var enqueueColumns = []string{"item_kind", "item_id", "image_type", "priority", "attempts", "retry_at", "enqueued_at"}
type artworkQueueRepository struct {
@ -42,11 +43,12 @@ func (r *artworkQueueRepository) Get(kind model.Kind, id, imageType string) (*mo
return &res, nil
}
// Enqueue also resets enqueued_at, so a fresh request does not inherit an old row's spent retry budget.
// Enqueue starts a fresh lifecycle: it resets enqueued_at (so a fresh request does not inherit an old
// row's spent retry budget) and clears trace (so explain does not show a prior failure at attempts 0).
func (r *artworkQueueRepository) Enqueue(items ...model.ArtworkQueueItem) error {
return r.enqueue(`ON CONFLICT (item_kind, item_id, image_type) DO UPDATE SET
priority = MAX(priority, excluded.priority), retry_at = excluded.retry_at,
attempts = 0, enqueued_at = excluded.enqueued_at`, items)
attempts = 0, enqueued_at = excluded.enqueued_at, trace = '[]'`, items)
}
func (r *artworkQueueRepository) EnqueuePreservingBackoff(items ...model.ArtworkQueueItem) error {
@ -54,11 +56,12 @@ func (r *artworkQueueRepository) EnqueuePreservingBackoff(items ...model.Artwork
priority = MAX(priority, excluded.priority)`, items)
}
func (r *artworkQueueRepository) EnqueueStaleAbsent(kind model.Kind, attemptedBefore time.Time) (int64, error) {
func (r *artworkQueueRepository) EnqueueStaleAbsent(kind model.Kind, attemptedBefore time.Time, limit int) (int64, error) {
now := time.Now()
return r.insertIfNotQueued("", `SELECT item_kind, item_id, image_type, ?, 0, ?, ?
FROM `+itemArtworkTable+` WHERE item_kind = ? AND hash = '' AND attempted_at < ?`,
model.ArtworkPriorityRecheck, now, now, kind.Prefix(), attemptedBefore)
FROM `+itemArtworkTable+` WHERE item_kind = ? AND hash = '' AND attempted_at < ?
ORDER BY attempted_at LIMIT ?`,
model.ArtworkPriorityRecheck, now, now, kind.Prefix(), attemptedBefore, limit)
}
func (r *artworkQueueRepository) EnqueueAllMissing(kind model.Kind, priority int) (int64, error) {
@ -159,7 +162,7 @@ func (r *artworkQueueRepository) enqueue(conflict string, items []model.ArtworkQ
}
func (r *artworkQueueRepository) DequeueBatch(n int, kinds ...string) ([]model.ArtworkQueueItem, error) {
sel := Select("*").From(r.tableName).
sel := Select(enqueueColumns...).From(r.tableName).
Where(LtOrEq{"retry_at": time.Now()}).
OrderBy("priority DESC", "enqueued_at ASC").
Limit(uint64(n))
@ -171,10 +174,11 @@ func (r *artworkQueueRepository) DequeueBatch(n int, kinds ...string) ([]model.A
return res, err
}
func (r *artworkQueueRepository) MarkFailedIfUnchanged(kind, id, imageType string, seenRetryAt, retryAt time.Time) error {
func (r *artworkQueueRepository) MarkFailedIfUnchanged(kind, id, imageType string, seenRetryAt, retryAt time.Time, trace string) error {
upd := Update(r.tableName).
Set("attempts", Expr("attempts + 1")).
Set("retry_at", retryAt).
Set("trace", trace).
Where(Eq{"item_kind": kind, "item_id": id, "image_type": imageType, "retry_at": seenRetryAt})
_, err := r.executeSQL(upd)
return err
@ -188,20 +192,46 @@ func (r *artworkQueueRepository) PurgeDangling() (int64, error) {
return purgeDangling(r.sqlRepository)
}
// artworkQueueFilter returns no conditions for an empty filter, so an unfiltered DELETE keeps
// SQLite's truncate path. It ignores retry_at: a backing-off row is pending work too.
func artworkQueueFilter(kinds []model.Kind, priorities []int) And {
var f And
if len(kinds) > 0 {
f = append(f, Eq{"item_kind": model.KindPrefixes(kinds)})
}
if len(priorities) > 0 {
f = append(f, Eq{"priority": priorities})
}
return f
}
// CountQueued shares its filter with PurgeQueued, so a preview cannot count rows the delete misses.
func (r *artworkQueueRepository) CountQueued(kinds []model.Kind, priorities []int) ([]model.ArtworkQueueStat, error) {
sel := Select("item_kind", "priority", "count(*) as count").From(r.tableName).
GroupBy("item_kind", "priority").OrderBy("item_kind", "priority desc")
if f := artworkQueueFilter(kinds, priorities); len(f) > 0 {
sel = sel.Where(f)
}
var res []model.ArtworkQueueStat
err := r.queryAll(sel, &res)
return res, err
}
func (r *artworkQueueRepository) PurgeQueued(kinds []model.Kind, priorities []int) (int64, error) {
del := Delete(r.tableName)
if f := artworkQueueFilter(kinds, priorities); len(f) > 0 {
del = del.Where(f)
}
return r.executeSQL(del)
}
func (r *artworkQueueRepository) Count() (int64, error) {
var res struct{ Count int64 }
err := r.queryOne(Select("count(*) as count").From(r.tableName), &res)
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.
// CountAbsent matches EnqueueStaleAbsent on hash, so the stale count is the pool a recheck drains from.
func (r *artworkQueueRepository) CountAbsent(kind model.Kind, attemptedBefore time.Time) (model.ArtworkAbsentStat, error) {
var res model.ArtworkAbsentStat
err := r.queryOne(Select("count(*) as total").

View File

@ -127,19 +127,42 @@ var _ = Describe("ArtworkQueueRepository", func() {
Expect(repo.Enqueue(item("al", "m1", model.ArtworkPriorityScan))).To(Succeed())
future := time.Now().Add(48 * time.Hour)
Expect(repo.MarkFailedIfUnchanged("al", "m1", model.ImageTypePrimary, original, future)).To(Succeed())
Expect(repo.MarkFailedIfUnchanged("al", "m1", model.ImageTypePrimary, original, future, "[]")).To(Succeed())
got, _ = repo.DequeueBatch(10)
Expect(got).To(HaveLen(1), "the fresh re-enqueue stays immediately eligible")
Expect(got[0].Attempts).To(BeZero(), "re-enqueue clears attempts, and the stale failure must not bump them")
current := got[0].RetryAt
Expect(repo.MarkFailedIfUnchanged("al", "m1", model.ImageTypePrimary, current, future)).To(Succeed())
Expect(repo.MarkFailedIfUnchanged("al", "m1", model.ImageTypePrimary, current, future, `[{"c":"read","o":"error"}]`)).To(Succeed())
got, _ = repo.DequeueBatch(10)
Expect(got).To(BeEmpty(), "backed-off row is hidden until the future retry_at")
all, _ := repo.Count()
Expect(all).To(Equal(int64(1)))
})
It("Enqueue clears a prior lifecycle's failure trace; EnqueuePreservingBackoff keeps it", func() {
// Fail an attempt so the queue row carries a failure trace.
Expect(repo.Enqueue(item("al", "t1", model.ArtworkPriorityScan))).To(Succeed())
backOff("al", "t1", time.Now().Add(-time.Hour))
got, _ := repo.DequeueBatch(10)
Expect(got).To(HaveLen(1))
future := time.Now().Add(48 * time.Hour)
Expect(repo.MarkFailedIfUnchanged("al", "t1", model.ImageTypePrimary, got[0].RetryAt, future, `[{"c":"read","o":"error"}]`)).To(Succeed())
// A continuation of the same lifecycle must retain the trace.
Expect(repo.EnqueuePreservingBackoff(item("al", "t1", model.ArtworkPriorityBump))).To(Succeed())
kept, err := repo.Get(model.KindAlbumArtwork, "t1", model.ImageTypePrimary)
Expect(err).ToNot(HaveOccurred())
Expect(kept.Trace).To(Equal(`[{"c":"read","o":"error"}]`))
// A fresh Enqueue resets attempts to 0, so the stale failure trace must be cleared with it.
Expect(repo.Enqueue(item("al", "t1", model.ArtworkPriorityScan))).To(Succeed())
fresh, err := repo.Get(model.KindAlbumArtwork, "t1", model.ImageTypePrimary)
Expect(err).ToNot(HaveOccurred())
Expect(fresh.Attempts).To(BeZero())
Expect(fresh.Trace).To(Equal("[]"), "a fresh lifecycle has no last-attempt trace")
})
It("Enqueue restarts the retry budget an existing row had spent", func() {
Expect(repo.Enqueue(item("al", "e1", model.ArtworkPriorityScan))).To(Succeed())
backOff("al", "e1", time.Now().Add(-time.Hour))
@ -221,7 +244,7 @@ var _ = Describe("ArtworkQueueRepository", func() {
Expect(awRepo.PutItemArtwork(&model.ItemArtwork{ItemKind: "ar", ItemID: "fresh1", ImageType: model.ImageTypePrimary, Hash: "", AttemptedAt: time.Now()})).To(Succeed())
Expect(awRepo.PutItemArtwork(&model.ItemArtwork{ItemKind: "ar", ItemID: "found1", ImageType: model.ImageTypePrimary, Hash: "hX", AttemptedAt: old})).To(Succeed())
n, err := repo.EnqueueStaleAbsent(model.KindArtistArtwork, time.Now().Add(-24*time.Hour))
n, err := repo.EnqueueStaleAbsent(model.KindArtistArtwork, time.Now().Add(-24*time.Hour), 100)
Expect(err).ToNot(HaveOccurred())
Expect(n).To(Equal(int64(1)))
@ -232,6 +255,23 @@ var _ = Describe("ArtworkQueueRepository", func() {
Expect(items[0].Priority).To(Equal(model.ArtworkPriorityRecheck))
})
It("enqueues only the oldest stale absent states up to the limit", func() {
awRepo := NewArtworkRepository(context.Background(), GetDBXBuilder())
now := time.Now()
Expect(awRepo.PutItemArtwork(&model.ItemArtwork{ItemKind: "ar", ItemID: "oldest", ImageType: model.ImageTypePrimary, Hash: "", AttemptedAt: now.Add(-72 * time.Hour)})).To(Succeed())
Expect(awRepo.PutItemArtwork(&model.ItemArtwork{ItemKind: "ar", ItemID: "older", ImageType: model.ImageTypePrimary, Hash: "", AttemptedAt: now.Add(-60 * time.Hour)})).To(Succeed())
Expect(awRepo.PutItemArtwork(&model.ItemArtwork{ItemKind: "ar", ItemID: "old", ImageType: model.ImageTypePrimary, Hash: "", AttemptedAt: now.Add(-48 * time.Hour)})).To(Succeed())
n, err := repo.EnqueueStaleAbsent(model.KindArtistArtwork, now.Add(-24*time.Hour), 2)
Expect(err).ToNot(HaveOccurred())
Expect(n).To(Equal(int64(2)))
items, err := repo.DequeueBatch(10)
Expect(err).ToNot(HaveOccurred())
ids := slice.Map(items, func(it model.ArtworkQueueItem) string { return it.ItemID })
Expect(ids).To(ConsistOf("oldest", "older"))
})
It("enqueues entities that have no item_artwork row at all", func() {
awRepo := NewArtworkRepository(context.Background(), GetDBXBuilder())
Expect(awRepo.PutItemArtwork(&model.ItemArtwork{ItemKind: "al", ItemID: albumSgtPeppers.ID, ImageType: model.ImageTypePrimary, Hash: "hX", AttemptedAt: time.Now()})).To(Succeed())
@ -388,7 +428,7 @@ var _ = Describe("ArtworkQueueRepository", func() {
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(
Expect(repo.CountQueued(nil, nil)).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},
@ -396,7 +436,7 @@ var _ = Describe("ArtworkQueueRepository", func() {
})
It("reports an empty queue as no rows", func() {
Expect(repo.CountByKindAndPriority()).To(BeEmpty())
Expect(repo.CountQueued(nil, nil)).To(BeEmpty())
})
It("counts absent states and how many are due for recheck", func() {
@ -419,4 +459,71 @@ var _ = Describe("ArtworkQueueRepository", func() {
Expect(repo.CountAbsent(model.KindRadioArtwork, time.Now())).To(Equal(model.ArtworkAbsentStat{}))
})
})
Describe("PurgeQueued", func() {
queuedIDs := func() []string {
GinkgoHelper()
got, err := repo.DequeueBatch(100)
Expect(err).ToNot(HaveOccurred())
return slice.Map(got, func(it model.ArtworkQueueItem) string { return it.ItemID })
}
BeforeEach(func() {
Expect(repo.Enqueue(
item("ar", "ar-backfill", model.ArtworkPriorityBackfill),
item("ar", "ar-bump", model.ArtworkPriorityBump),
item("al", "al-backfill", model.ArtworkPriorityBackfill),
item("mf", "mf-scan", model.ArtworkPriorityScan),
)).To(Succeed())
})
// CountQueued feeds the preview and PurgeQueued does the delete; they share one filter, so
// every selection must count exactly what it deletes.
DescribeTable("selects the same rows to count and to delete",
func(kinds []model.Kind, priorities []int, deleted int, remaining []string) {
counted, err := repo.CountQueued(kinds, priorities)
Expect(err).ToNot(HaveOccurred())
var total int64
for _, s := range counted {
total += s.Count
}
Expect(total).To(BeNumerically("==", deleted), "the preview must match the delete")
Expect(repo.PurgeQueued(kinds, priorities)).To(BeNumerically("==", deleted))
Expect(queuedIDs()).To(ConsistOf(remaining))
},
Entry("only the given kinds", []model.Kind{model.KindArtistArtwork}, nil,
2, []string{"al-backfill", "mf-scan"}),
Entry("only the given priorities", nil, []int{model.ArtworkPriorityBackfill},
2, []string{"ar-bump", "mf-scan"}),
Entry("the intersection of both", []model.Kind{model.KindArtistArtwork}, []int{model.ArtworkPriorityBackfill},
1, []string{"ar-bump", "al-backfill", "mf-scan"}),
Entry("everything, when neither filter is given", nil, nil,
4, []string{}),
Entry("several kinds and priorities at once",
[]model.Kind{model.KindArtistArtwork, model.KindMediaFileArtwork},
[]int{model.ArtworkPriorityBackfill, model.ArtworkPriorityScan},
2, []string{"ar-bump", "al-backfill"}),
Entry("nothing, leaving the queue alone", []model.Kind{model.KindPlaylistArtwork}, nil,
0, []string{"ar-backfill", "ar-bump", "al-backfill", "mf-scan"}),
)
It("deletes a row that is still backing off", func() {
backOff("ar", "ar-bump", time.Now().Add(time.Hour))
Expect(repo.PurgeQueued([]model.Kind{model.KindArtistArtwork}, nil)).To(BeNumerically("==", 2))
Expect(repo.Get(model.KindArtistArtwork, "ar-bump", model.ImageTypePrimary)).
Error().To(MatchError(model.ErrNotFound))
})
// A WHERE clause, even one that matches everything, costs SQLite its truncate optimization
// and turns `artwork cancel --all` into a full scan of the queue.
It("adds no conditions at all for an empty filter", func() {
Expect(artworkQueueFilter(nil, nil)).To(BeEmpty())
Expect(artworkQueueFilter([]model.Kind{model.KindArtistArtwork}, nil)).To(HaveLen(1))
Expect(artworkQueueFilter(nil, []int{model.ArtworkPriorityBump})).To(HaveLen(1))
Expect(artworkQueueFilter([]model.Kind{model.KindArtistArtwork}, []int{model.ArtworkPriorityBump})).
To(HaveLen(2))
})
})
})

View File

@ -134,11 +134,21 @@ func (r *artworkRepository) PutItemArtwork(ia *model.ItemArtwork) error {
}
ins := Insert(itemArtworkTable).SetMap(values).Suffix(`ON CONFLICT (item_kind, item_id, image_type) DO UPDATE SET
hash=excluded.hash, source=excluded.source, source_path=excluded.source_path, ref_mtime=excluded.ref_mtime,
trace=excluded.trace, last_failure=excluded.last_failure,
attempted_at=excluded.attempted_at, updated_at=excluded.updated_at`)
_, err = r.items.executeSQL(ins)
return err
}
// PutLastFailure records why an item exhausted its retry budget. It only updates an existing row:
// inserting one would write an empty hash, which the rest of the system reads as a settled absent.
func (r *artworkRepository) PutLastFailure(kind model.Kind, id, imageType, trace string) error {
upd := Update(itemArtworkTable).Set("last_failure", trace).
Where(Eq{"item_kind": kind.Prefix(), "item_id": id, "image_type": imageType})
_, err := r.items.executeSQL(upd)
return err
}
func (r *artworkRepository) DeleteForItems(kind model.Kind, ids []string) error {
for chunk := range slices.Chunk(ids, artworkBatchSize) {
if err := r.items.delete(Eq{"item_kind": kind.Prefix(), "item_id": chunk}); err != nil {

View File

@ -28,6 +28,51 @@ var _ = Describe("ArtworkRepository", func() {
repo = NewArtworkRepository(context.Background(), GetDBXBuilder())
})
Context("resolution traces", func() {
const traceJSON = `[{"c":"cover.*","o":"hit"}]`
It("round-trips the trace with the state row", func() {
Expect(repo.PutItemArtwork(&model.ItemArtwork{ItemKind: "al", ItemID: "t1",
ImageType: model.ImageTypePrimary, Hash: "h1", Trace: traceJSON})).To(Succeed())
got, err := repo.GetItemArtwork(model.KindAlbumArtwork, "t1", model.ImageTypePrimary)
Expect(err).ToNot(HaveOccurred())
Expect(got.Trace).To(Equal(traceJSON))
Expect(got.LastFailure).To(BeEmpty())
})
It("replaces the trace when the item is resolved again", func() {
Expect(repo.PutItemArtwork(&model.ItemArtwork{ItemKind: "al", ItemID: "t2",
ImageType: model.ImageTypePrimary, Trace: traceJSON})).To(Succeed())
Expect(repo.PutItemArtwork(&model.ItemArtwork{ItemKind: "al", ItemID: "t2",
ImageType: model.ImageTypePrimary, Trace: `[{"c":"embedded","o":"hit"}]`})).To(Succeed())
got, _ := repo.GetItemArtwork(model.KindAlbumArtwork, "t2", model.ImageTypePrimary)
Expect(got.Trace).To(Equal(`[{"c":"embedded","o":"hit"}]`))
})
It("records a last failure on an existing row", func() {
Expect(repo.PutItemArtwork(&model.ItemArtwork{ItemKind: "al", ItemID: "t3",
ImageType: model.ImageTypePrimary, Hash: "h3"})).To(Succeed())
Expect(repo.PutLastFailure(model.KindAlbumArtwork, "t3", model.ImageTypePrimary,
`[{"c":"decode","o":"error"}]`)).To(Succeed())
got, _ := repo.GetItemArtwork(model.KindAlbumArtwork, "t3", model.ImageTypePrimary)
Expect(got.LastFailure).To(Equal(`[{"c":"decode","o":"error"}]`))
Expect(got.Hash).To(Equal("h3"), "recording a failure must not disturb the served artwork")
})
// Inserting here would write hash='', which every reader treats as a settled absent.
It("never creates a row for an item that has no state", func() {
Expect(repo.PutLastFailure(model.KindAlbumArtwork, "ghost", model.ImageTypePrimary,
`[{"c":"decode","o":"error"}]`)).To(Succeed())
_, err := repo.GetItemArtwork(model.KindAlbumArtwork, "ghost", model.ImageTypePrimary)
Expect(err).To(MatchError(model.ErrNotFound))
})
})
Context("image identity", func() {
It("stores and retrieves an artwork by hash", func() {
a := &model.Artwork{Hash: "abc123", Mime: "image/jpeg", Width: 500, Height: 500, SizeBytes: 1234, BlurHash: "LKO2?U%2Tw=w"}

View File

@ -9,6 +9,8 @@ import (
"github.com/Masterminds/squirrel"
"github.com/fatih/structs"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/db"
)
type PostMapper interface {
@ -82,11 +84,28 @@ func (e existsCond) ToSql() (string, []any, error) {
var sortOrderRegex = regexp.MustCompile(`order_([a-z_]+)`)
// Convert the order_* columns to an expression using sort_* columns. Example:
// sort_album_name -> (coalesce(nullif(sort_album_name,”),order_album_name) collate nocase)
// naturalSort makes a plain text column sort numbers by value, leaving it alone
// otherwise so it keeps its declared collation. Parens guard buildSortOrder's space split.
func naturalSort(col string) string {
if !conf.Server.EnableNaturalSorting {
return col
}
return fmt.Sprintf("(%s collate %s)", col, db.NaturalCollation)
}
// Convert the order_* columns to a collated sort expression, falling back to the
// sort_* column when those are preferred. Example:
// order_album_name -> (coalesce(nullif(sort_album_name,”),order_album_name) collate nocase)
// It finds order column names anywhere in the substring
func mapSortOrder(tableName, order string) string {
order = strings.ToLower(order)
repl := fmt.Sprintf("(coalesce(nullif(%[1]s.sort_$1,''),%[1]s.order_$1) collate nocase)", tableName)
return sortOrderRegex.ReplaceAllString(order, repl)
col := tableName + ".order_$1"
if conf.Server.PreferSortTags {
col = fmt.Sprintf("coalesce(nullif(%[1]s.sort_$1,''),%[1]s.order_$1)", tableName)
}
collation := "nocase"
if conf.Server.EnableNaturalSorting {
collation = db.NaturalCollation
}
repl := fmt.Sprintf("(%s collate %s)", col, collation)
return sortOrderRegex.ReplaceAllString(strings.ToLower(order), repl)
}

View File

@ -4,6 +4,8 @@ import (
"time"
"github.com/Masterminds/squirrel"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/conf/configtest"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
@ -85,22 +87,51 @@ var _ = Describe("Helpers", func() {
})
Describe("mapSortOrder", func() {
BeforeEach(func() {
DeferCleanup(configtest.SetupConfig())
})
It("does not change the sort string if there are no order columns", func() {
sort := "album_name asc"
mapped := mapSortOrder("album", sort)
Expect(mapped).To(Equal(sort))
})
It("changes order columns to sort expression", func() {
sort := "ORDER_ALBUM_NAME asc"
mapped := mapSortOrder("album", sort)
Expect(mapped).To(Equal(`(coalesce(nullif(album.sort_album_name,''),album.order_album_name)` +
` collate nocase) asc`))
Expect(mapSortOrder("album", "album_name asc")).To(Equal("album_name asc"))
})
DescribeTable("maps order columns to a collated expression",
func(preferSortTags, naturalSorting bool, expected string) {
conf.Server.PreferSortTags = preferSortTags
conf.Server.EnableNaturalSorting = naturalSorting
Expect(mapSortOrder("album", "ORDER_ALBUM_NAME asc")).To(Equal(expected))
},
Entry("qualified column", false, false,
"(album.order_album_name collate nocase) asc"),
Entry("natural collation", false, true,
"(album.order_album_name collate NATSORT) asc"),
Entry("sort tags preferred", true, false,
`(coalesce(nullif(album.sort_album_name,''),album.order_album_name) collate nocase) asc`),
Entry("sort tags preferred, natural collation", true, true,
`(coalesce(nullif(album.sort_album_name,''),album.order_album_name) collate NATSORT) asc`),
)
It("changes multiple order columns to sort expressions", func() {
conf.Server.PreferSortTags = true
sort := "compilation, order_title asc, order_album_artist_name desc, year desc"
mapped := mapSortOrder("album", sort)
Expect(mapped).To(Equal(`compilation, (coalesce(nullif(album.sort_title,''),album.order_title) collate nocase) asc,` +
` (coalesce(nullif(album.sort_album_artist_name,''),album.order_album_artist_name) collate nocase) desc, year desc`))
Expect(mapSortOrder("album", sort)).To(Equal(
`compilation, (coalesce(nullif(album.sort_title,''),album.order_title) collate nocase) asc,` +
` (coalesce(nullif(album.sort_album_artist_name,''),album.order_album_artist_name) collate nocase) desc, year desc`))
})
})
Describe("naturalSort", func() {
BeforeEach(func() {
DeferCleanup(configtest.SetupConfig())
})
It("leaves the column alone by default, keeping its declared collation", func() {
Expect(naturalSort("media_file.title")).To(Equal("media_file.title"))
})
It("applies the natural collation when enabled", func() {
conf.Server.EnableNaturalSorting = true
Expect(naturalSort("media_file.title")).To(Equal("(media_file.title collate NATSORT)"))
})
})
})

View File

@ -86,7 +86,7 @@ func NewMediaFileRepository(ctx context.Context, db dbx.Builder) model.MediaFile
"title": "order_title",
"artist": "order_artist_name, order_album_name, release_date, disc_number, track_number",
"album_artist": "order_album_artist_name, order_album_name, release_date, disc_number, track_number",
"album": "order_album_name, album_id, disc_number, track_number, order_artist_name, title",
"album": "order_album_name, album_id, disc_number, track_number, order_artist_name, " + naturalSort("media_file.title"),
"random": "random",
"created_at": "media_file.created_at",
"recently_added": mediaFileRecentlyAddedSort(),

View File

@ -60,7 +60,8 @@ func NewPlaylistRepository(ctx context.Context, db dbx.Builder) model.PlaylistRe
"starred": annotationBoolFilter("starred"),
})
r.setSortMappings(map[string]string{
"owner_name": "owner_name",
"name": naturalSort("playlist.name"),
"owner_name": naturalSort("owner_name"),
})
return r
}

View File

@ -5,6 +5,8 @@ import (
"github.com/Masterminds/squirrel"
"github.com/deluan/rest"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/conf/configtest"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/model/criteria"
@ -24,6 +26,39 @@ var _ = Describe("PlaylistRepository", func() {
repo = NewPlaylistRepository(ctx, GetDBXBuilder())
})
Describe("natural sorting", func() {
var ids []string
BeforeEach(func() {
DeferCleanup(configtest.SetupConfig())
conf.Server.EnableNaturalSorting = true
ctx := log.NewContext(GinkgoT().Context())
ctx = request.WithUser(ctx, model.User{ID: "userid", UserName: "userid", IsAdmin: true})
repo = NewPlaylistRepository(ctx, GetDBXBuilder())
ids = nil
for _, n := range []string{"mix 1", "mix 10", "mix 2"} {
pls := model.Playlist{Name: n, OwnerID: "userid"}
Expect(repo.Put(&pls)).To(Succeed())
ids = append(ids, pls.ID)
}
DeferCleanup(func() {
for _, id := range ids {
_ = repo.Delete(id)
}
})
})
It("sorts playlist names by number value", func() {
all, err := repo.GetAll(model.QueryOptions{
Sort: "name", Filters: squirrel.Eq{"playlist.id": ids},
})
Expect(err).ToNot(HaveOccurred())
Expect(slice.Map(all, func(p model.Playlist) string { return p.Name })).To(
Equal([]string{"mix 1", "mix 2", "mix 10"}))
})
})
Describe("Count", func() {
It("returns the number of playlists in the DB", func() {
Expect(repo.CountAll()).To(Equal(int64(2)))

View File

@ -56,7 +56,7 @@ func (r *playlistRepository) Tracks(playlistId string, refreshSmartPlaylist bool
"id": "playlist_tracks.id",
"artist": "order_artist_name",
"album_artist": "order_album_artist_name",
"album": "order_album_name, album_id, disc_number, track_number, order_artist_name, title",
"album": "order_album_name, album_id, disc_number, track_number, order_artist_name, " + naturalSort("f.title"),
"title": "order_title",
"random": "random()",
// To make sure these fields will be whitelisted

View File

@ -113,10 +113,9 @@ func (r *sqlRepository) setSortMappings(mappings map[string]string, tableName ..
if len(tableName) > 0 {
tn = tableName[0]
}
if conf.Server.PreferSortTags {
if conf.Server.PreferSortTags || conf.Server.EnableNaturalSorting {
for k, v := range mappings {
v = mapSortOrder(tn, v)
mappings[k] = v
mappings[k] = mapSortOrder(tn, v)
}
}
r.sortMappings = mappings

View File

@ -18,6 +18,7 @@ import (
"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/navidrome/navidrome/utils"
"github.com/navidrome/navidrome/utils/slice"
"github.com/pocketbase/dbx"
@ -126,14 +127,30 @@ func (r *userRepository) Put(u *model.User) error {
}
delete(values, "current_password")
// Save/update the user
// The epoch bump rides the password UPDATE: as two statements they can interleave with a
// concurrent change and leave a session valid that the other change should have revoked.
update := Update(r.tableName).Where(Eq{"id": u.ID}).SetMap(values)
count, err := r.executeSQL(update)
if err != nil {
return err
var isNewUser bool
var epoch int
if u.NewPassword != "" {
var res struct{ TokenEpoch int }
err = r.queryOne(update.Set("token_epoch", Expr("token_epoch + 1")).
Suffix("RETURNING token_epoch"), &res)
switch {
case errors.Is(err, model.ErrNotFound):
isNewUser = true
case err != nil:
return err
default:
epoch = res.TokenEpoch
}
} else {
count, err := r.executeSQL(update)
if err != nil {
return err
}
isNewUser = count == 0
}
isNewUser := count == 0
if isNewUser {
values["created_at"] = time.Now()
insert := Insert(r.tableName).SetMap(values)
@ -163,6 +180,12 @@ func (r *userRepository) Put(u *model.User) error {
}
}
// Only the caller's own token can be refreshed in-flight; an admin resetting another
// user must keep their own epoch.
if u.NewPassword != "" && !isNewUser && loggedUser(r.ctx).ID == u.ID {
request.SetTokenEpoch(r.ctx, epoch)
}
return nil
}

View File

@ -4,6 +4,7 @@ import (
"context"
"errors"
"slices"
"sync"
"github.com/Masterminds/squirrel"
"github.com/deluan/rest"
@ -13,6 +14,7 @@ import (
"github.com/navidrome/navidrome/model/id"
"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"
)
@ -683,4 +685,159 @@ var _ = Describe("UserRepository", func() {
Expect(query).To(ContainSubstring("user.id = {:p0}"))
})
})
Describe("token epoch", func() {
var repo model.UserRepository
var usr model.User
newUser := func() model.User {
uid := id.NewRandom()
// user_name is unique; suffix it so each It gets its own row in the shared suite DB.
return model.User{ID: uid, UserName: "epoch-user-" + uid, Name: "Epoch", NewPassword: "hunter2"}
}
BeforeEach(func() {
ctx := log.NewContext(context.TODO())
ctx = request.WithUser(ctx, model.User{ID: "userid", IsAdmin: true})
repo = NewUserRepository(ctx, GetDBXBuilder())
usr = newUser()
Expect(repo.Put(&usr)).To(Succeed())
})
It("starts at zero for a new user", func() {
got, err := repo.Get(usr.ID)
Expect(err).ToNot(HaveOccurred())
Expect(got.TokenEpoch).To(Equal(0))
})
It("increments once per password change", func() {
usr.NewPassword = "second"
Expect(repo.Put(&usr)).To(Succeed())
got, err := repo.Get(usr.ID)
Expect(err).ToNot(HaveOccurred())
Expect(got.TokenEpoch).To(Equal(1))
usr.NewPassword = "third"
Expect(repo.Put(&usr)).To(Succeed())
got, err = repo.Get(usr.ID)
Expect(err).ToNot(HaveOccurred())
Expect(got.TokenEpoch).To(Equal(2))
})
It("leaves the epoch alone when the password is untouched", func() {
usr.NewPassword = ""
usr.Name = "Renamed"
Expect(repo.Put(&usr)).To(Succeed())
got, err := repo.Get(usr.ID)
Expect(err).ToNot(HaveOccurred())
Expect(got.TokenEpoch).To(Equal(0))
Expect(got.Name).To(Equal("Renamed"))
})
It("never signals the same epoch to two concurrent password changes", func() {
// Each writer's epoch must be the one its own UPDATE produced.
const callers = 4
var mu sync.Mutex
var signalled []int
var wg sync.WaitGroup
for range callers {
wg.Go(func() {
ctx := log.NewContext(context.TODO())
ctx = request.WithUser(ctx, model.User{ID: usr.ID})
ctx = request.WithTokenEpochHolder(ctx)
own := NewUserRepository(ctx, GetDBXBuilder())
u := usr
u.NewPassword = "concurrent"
if err := own.Put(&u); err != nil {
return // the shared in-memory test DB can raise SQLITE_LOCKED
}
epoch, ok := request.TokenEpochFrom(ctx)
if !ok {
return
}
mu.Lock()
defer mu.Unlock()
signalled = append(signalled, epoch)
})
}
wg.Wait()
Expect(signalled).To(HaveLen(len(slice.Unique(signalled))),
"an epoch was signalled to more than one writer: %v", signalled)
})
})
Describe("Put and the token epoch", func() {
newRepo := func(actingUserID string) model.UserRepository {
ctx := log.NewContext(context.TODO())
ctx = request.WithUser(ctx, model.User{ID: actingUserID, IsAdmin: true})
ctx = request.WithTokenEpochHolder(ctx)
return NewUserRepository(ctx, GetDBXBuilder())
}
It("does not bump when creating a user", func() {
repo := newRepo("admin")
usr := model.User{ID: id.NewRandom(), UserName: "fresh", NewPassword: "pw1"}
Expect(repo.Put(&usr)).To(Succeed())
got, err := repo.Get(usr.ID)
Expect(err).ToNot(HaveOccurred())
Expect(got.TokenEpoch).To(Equal(0))
})
It("bumps when the password changes", func() {
repo := newRepo("admin")
usr := model.User{ID: id.NewRandom(), UserName: "changer", NewPassword: "pw1"}
Expect(repo.Put(&usr)).To(Succeed())
usr.NewPassword = "pw2"
Expect(repo.Put(&usr)).To(Succeed())
got, err := repo.Get(usr.ID)
Expect(err).ToNot(HaveOccurred())
Expect(got.TokenEpoch).To(Equal(1))
})
It("does not bump on an edit that leaves the password alone", func() {
repo := newRepo("admin")
usr := model.User{ID: id.NewRandom(), UserName: "renamer", NewPassword: "pw1"}
Expect(repo.Put(&usr)).To(Succeed())
usr.NewPassword = ""
usr.Name = "New Display Name"
Expect(repo.Put(&usr)).To(Succeed())
got, err := repo.Get(usr.ID)
Expect(err).ToNot(HaveOccurred())
Expect(got.TokenEpoch).To(Equal(0))
})
It("signals the new epoch when a user changes their own password", func() {
userID := id.NewRandom()
repo := newRepo(userID)
usr := model.User{ID: userID, UserName: "self", NewPassword: "pw1"}
Expect(repo.Put(&usr)).To(Succeed())
usr.NewPassword = "pw2"
Expect(repo.Put(&usr)).To(Succeed())
epoch, ok := request.TokenEpochFrom(repo.(*userRepository).ctx)
Expect(ok).To(BeTrue())
Expect(epoch).To(Equal(1))
})
It("does not signal when an admin changes someone else's password", func() {
repo := newRepo("some-admin")
usr := model.User{ID: id.NewRandom(), UserName: "other", NewPassword: "pw1"}
Expect(repo.Put(&usr)).To(Succeed())
usr.NewPassword = "pw2"
Expect(repo.Put(&usr)).To(Succeed())
_, ok := request.TokenEpochFrom(repo.(*userRepository).ctx)
Expect(ok).To(BeFalse())
})
})
})

View File

@ -174,6 +174,14 @@ Capabilities define what your plugin can do. They're automatically detected base
Provides artist and album metadata. All methods are **optional** — implement only the ones your data source supports.
> **Returning "not found".** When you have no data for an item, return an empty response and no
> error. In the Go PDK that is `return nil, nil`. Navidrome reads it as a definitive "not found"
> and stops asking.
>
> Return an error only when the plugin itself failed, such as an unreachable API or a broken host
> call. Navidrome retries failed calls with backoff. A plugin that errors on "no data" makes
> Navidrome retry every item it has no data for.
| Function | Input | Output | Description |
|-----------------------------------|----------------------------|----------------------------------|--------------------------|
| `nd_get_artist_mbid` | `{id, name}` | `{mbid}` | Get MusicBrainz ID |

View File

@ -9,6 +9,9 @@ import "github.com/navidrome/navidrome/plugins/types"
// Plugins implementing this capability can choose which methods to implement.
// Each method is optional - plugins only need to provide the functionality they support.
//
// To say "no data for this item", return a nil response and a nil error. Return an error only when
// the plugin itself failed, because Navidrome retries failed calls with backoff.
//
//nd:capability name=metadata
type MetadataAgent interface {
// GetArtistMBID retrieves the MusicBrainz ID for an artist.

View File

@ -434,8 +434,7 @@ func (m *Manager) loadPluginWithConfig(p *model.Plugin) error {
return fmt.Errorf("manifest validation: %w", err)
}
m.mu.Lock()
m.plugins[p.ID] = &plugin{
loadedPlugin := &plugin{
name: p.ID,
path: p.Path,
manifest: pkg.Manifest,
@ -449,13 +448,16 @@ func (m *Manager) loadPluginWithConfig(p *model.Plugin) error {
fsConfig: fsConfig,
lyricsSem: make(chan struct{}, maxConcurrentLyricsCalls),
}
m.mu.Lock()
m.plugins[p.ID] = loadedPlugin
m.mu.Unlock()
loaded = true
// 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.
// Use the local: loads run concurrently, so reading the map back here would race the writes.
if m.transient == nil || m.transient.runInit {
callPluginInit(ctx, m.plugins[p.ID])
callPluginInit(ctx, loadedPlugin)
}
return nil

View File

@ -186,6 +186,9 @@ type TopSongsResponse struct {
//
// Plugins implementing this capability can choose which methods to implement.
// Each method is optional - plugins only need to provide the functionality they support.
//
// To say "no data for this item", return a nil response and a nil error. Return an error only when
// the plugin itself failed, because Navidrome retries failed calls with backoff.
type Metadata interface{}
// ArtistMBIDProvider provides the GetArtistMBID function.

View File

@ -184,6 +184,9 @@ type TopSongsResponse struct {
//
// Plugins implementing this capability can choose which methods to implement.
// Each method is optional - plugins only need to provide the functionality they support.
//
// To say "no data for this item", return a nil response and a nil error. Return an error only when
// the plugin itself failed, because Navidrome retries failed calls with backoff.
type Metadata interface{}
// ArtistMBIDProvider provides the GetArtistMBID function.

View File

@ -1,3 +1,3 @@
FROM public.ecr.aws/docker/library/alpine
FROM alpine
RUN apk update && apk add jq msitools
WORKDIR /workspace

View File

@ -12,10 +12,12 @@ import (
"net/http"
"slices"
"strings"
"sync"
"time"
"github.com/deluan/rest"
"github.com/go-chi/jwtauth/v5"
"github.com/lestrrat-go/jwx/v3/jwt"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/consts"
"github.com/navidrome/navidrome/core/auth"
@ -262,7 +264,7 @@ func Authenticator(ds model.DataStore) func(next http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
ctx, err := authenticateRequest(ds, r, UsernameFromConfig, UsernameFromToken, UsernameFromExtAuthHeader)
if err != nil {
if err != nil || !tokenAllowed(ctx) {
_ = rest.RespondWithError(w, http.StatusUnauthorized, "Not authenticated")
return
}
@ -272,24 +274,88 @@ func Authenticator(ds model.DataStore) func(next http.Handler) http.Handler {
}
}
// JWTRefresher updates the expiry date of the received JWT token, and add the new one to the Authorization Header
// tokenAllowed re-checks a JWT that actually identifies the resolved user. Header and
// config auth carry no token, so they short-circuit to true.
func tokenAllowed(ctx context.Context) bool {
token, _, err := jwtauth.FromContext(ctx)
if err != nil || token == nil {
return true
}
usr, ok := request.UserFrom(ctx)
if !ok {
return true
}
claims := auth.ClaimsFromToken(token)
if !strings.EqualFold(claims.Subject, usr.UserName) {
return true
}
if err := auth.CheckClaims(claims, usr, auth.AudienceNative); err != nil {
log.Warn(ctx, "Native API: rejected token", "user", claims.Subject, err)
return false
}
return true
}
// refreshingWriter defers the refreshed-token header until the handler's first write, so an
// epoch the handler bumped reaches the token the client stores.
type refreshingWriter struct {
http.ResponseWriter
ctx context.Context
token jwt.Token
once sync.Once
}
func (w *refreshingWriter) setToken() {
w.once.Do(func() {
claims := auth.ClaimsFromToken(w.token)
if epoch, ok := request.TokenEpochFrom(w.ctx); ok {
claims.Epoch = epoch
}
newToken, err := auth.TouchClaims(claims)
if err != nil {
log.Error(w.ctx, "Could not sign new token", err)
return
}
w.Header().Set(consts.UIAuthorizationHeader, newToken)
})
}
func (w *refreshingWriter) WriteHeader(code int) {
w.setToken()
w.ResponseWriter.WriteHeader(code)
}
func (w *refreshingWriter) Write(b []byte) (int, error) {
w.setToken()
return w.ResponseWriter.Write(b)
}
// Flush keeps the SSE events route working through the wrap.
func (w *refreshingWriter) Flush() {
w.setToken()
if f, ok := w.ResponseWriter.(http.Flusher); ok {
f.Flush()
}
}
// Unwrap lets capability lookups, such as SSE's write deadline, see past this wrap.
func (w *refreshingWriter) Unwrap() http.ResponseWriter {
return w.ResponseWriter
}
// JWTRefresher updates the expiry date of the received JWT token, and adds the new one to
// the Authorization Header.
func JWTRefresher(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
token, _, err := jwtauth.FromContext(ctx)
if err != nil {
token, _, err := jwtauth.FromContext(r.Context())
if err != nil || token == nil {
next.ServeHTTP(w, r)
return
}
newTokenString, err := auth.TouchToken(token)
if err != nil {
log.Error(r, "Could not sign new token", err)
_ = rest.RespondWithError(w, http.StatusUnauthorized, "Not authenticated")
return
}
w.Header().Set(consts.UIAuthorizationHeader, newTokenString)
next.ServeHTTP(w, r)
ctx := request.WithTokenEpochHolder(r.Context())
rw := &refreshingWriter{ResponseWriter: w, ctx: ctx, token: token}
next.ServeHTTP(rw, r.WithContext(ctx))
rw.setToken()
})
}

View File

@ -13,6 +13,7 @@ import (
"time"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/conf/configtest"
"github.com/navidrome/navidrome/consts"
"github.com/navidrome/navidrome/core/auth"
"github.com/navidrome/navidrome/model"
@ -357,4 +358,138 @@ var _ = Describe("Auth", func() {
Expect(u.IsAdmin).To(BeFalse())
})
})
Describe("Authenticator token gating", func() {
var ds *tests.MockDataStore
var usr *model.User
BeforeEach(func() {
DeferCleanup(configtest.SetupConfig())
conf.Server.SessionTimeout = time.Hour
ds = &tests.MockDataStore{}
auth.Init(ds)
ur := ds.User(context.TODO()).(*tests.MockedUserRepo)
usr = &model.User{ID: "u1", UserName: "johndoe", NewPassword: "pw", TokenEpoch: 2}
Expect(ur.Put(usr)).To(Succeed())
})
serve := func(token string) *httptest.ResponseRecorder {
r := httptest.NewRequest("GET", "/api/song", nil)
r.Header.Set(consts.UIAuthorizationHeader, "Bearer "+token)
w := httptest.NewRecorder()
handler := JWTVerifier(Authenticator(ds)(http.HandlerFunc(
func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusOK) },
)))
handler.ServeHTTP(w, r)
return w
}
It("accepts a current session token", func() {
tokenStr, err := auth.CreateToken(usr)
Expect(err).ToNot(HaveOccurred())
Expect(serve(tokenStr).Code).To(Equal(http.StatusOK))
})
It("rejects a jellyfin-scoped token", func() {
tokenStr, err := auth.CreateAPIToken(usr, auth.AudienceJellyfin)
Expect(err).ToNot(HaveOccurred())
Expect(serve(tokenStr).Code).To(Equal(http.StatusUnauthorized))
})
It("rejects a token with a stale epoch", func() {
tokenStr, err := auth.CreateToken(usr)
Expect(err).ToNot(HaveOccurred())
usr.TokenEpoch = 3
Expect(serve(tokenStr).Code).To(Equal(http.StatusUnauthorized))
})
It("ignores a stray token for someone else when config auto-login resolves the user", func() {
conf.Server.DevAutoLoginUsername = usr.UserName
tokenStr, err := auth.CreateToken(&model.User{UserName: "someone-else"})
Expect(err).ToNot(HaveOccurred())
Expect(serve(tokenStr).Code).To(Equal(http.StatusOK))
})
It("rejects a stale-epoch token whose subject differs only in case from the resolved user", func() {
tokenStr, err := auth.CreateToken(&model.User{UserName: strings.ToUpper(usr.UserName), TokenEpoch: usr.TokenEpoch})
Expect(err).ToNot(HaveOccurred())
usr.TokenEpoch = 5
Expect(serve(tokenStr).Code).To(Equal(http.StatusUnauthorized))
})
})
Describe("JWTRefresher", func() {
BeforeEach(func() {
DeferCleanup(configtest.SetupConfig())
// TouchClaims reads this; left at zero every refreshed token is born expired.
conf.Server.SessionTimeout = time.Hour
auth.Init(&tests.MockDataStore{})
})
serveWith := func(handler http.HandlerFunc) *httptest.ResponseRecorder {
usr := model.User{ID: "u1", UserName: "johndoe", TokenEpoch: 1}
tokenStr, err := auth.CreateToken(&usr)
Expect(err).ToNot(HaveOccurred())
r := httptest.NewRequest("GET", "/api/song", nil)
r.Header.Set(consts.UIAuthorizationHeader, "Bearer "+tokenStr)
w := httptest.NewRecorder()
JWTVerifier(JWTRefresher(handler)).ServeHTTP(w, r)
return w
}
It("writes a refreshed token when the handler writes a body", func() {
w := serveWith(func(w http.ResponseWriter, _ *http.Request) {
_, _ = w.Write([]byte("ok"))
})
Expect(w.Header().Get(consts.UIAuthorizationHeader)).ToNot(BeEmpty())
})
It("writes a refreshed token when the handler writes no body", func() {
w := serveWith(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusNoContent)
})
Expect(w.Header().Get(consts.UIAuthorizationHeader)).ToNot(BeEmpty())
})
It("picks up an epoch the handler reported", func() {
w := serveWith(func(w http.ResponseWriter, r *http.Request) {
request.SetTokenEpoch(r.Context(), 42)
w.WriteHeader(http.StatusOK)
})
claims, err := auth.Validate(w.Header().Get(consts.UIAuthorizationHeader))
Expect(err).ToNot(HaveOccurred())
Expect(claims.Epoch).To(Equal(42))
})
It("keeps the original epoch when the handler reports nothing", func() {
w := serveWith(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusOK)
})
claims, err := auth.Validate(w.Header().Get(consts.UIAuthorizationHeader))
Expect(err).ToNot(HaveOccurred())
Expect(claims.Epoch).To(Equal(1))
})
It("propagates Flush to the underlying ResponseWriter", func() {
w := serveWith(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusOK)
w.(http.Flusher).Flush()
})
Expect(w.Flushed).To(BeTrue())
})
It("exposes the underlying ResponseWriter via Unwrap, for http.ResponseController lookups", func() {
var unwrapped http.ResponseWriter
w := serveWith(func(w http.ResponseWriter, _ *http.Request) {
u, ok := w.(interface{ Unwrap() http.ResponseWriter })
Expect(ok).To(BeTrue())
unwrapped = u.Unwrap()
w.WriteHeader(http.StatusOK)
})
Expect(unwrapped).To(BeIdenticalTo(w))
})
})
})

View File

@ -58,6 +58,8 @@ query param — all forms are accepted, matching what different clients do).
`/auth/login` (`AuthRequestLimit`/`AuthWindowLength`), since it's an unauthenticated brute-force
surface.
Access tokens do not expire, matching real Jellyfin. They are revoked by a password change, which bumps the user's token epoch.
### Public user list (login picker)
`GET /Users/Public` lets a client render a login user-picker (tap a user, then just type the

View File

@ -36,7 +36,7 @@ func (api *Router) authenticateByName(w http.ResponseWriter, r *http.Request) {
log.Error(ctx, "Jellyfin API: could not update last login date", "username", body.Username, err)
}
token, err := auth.CreateToken(usr)
token, err := auth.CreateAPIToken(usr, auth.AudienceJellyfin)
if err != nil {
api.internalError(w, r, err)
return

View File

@ -6,6 +6,7 @@ import (
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/conf/configtest"
"github.com/navidrome/navidrome/core/auth"
"github.com/navidrome/navidrome/server/jellyfin/dto"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
@ -61,6 +62,42 @@ var _ = Describe("Authentication", func() {
It("rejects a malformed body", func() {
Expect(rawReq("POST", "/Users/AuthenticateByName", "not json").Code).To(Equal(http.StatusBadRequest))
})
It("mints a non-expiring token scoped to the Jellyfin audience", func() {
w := authenticate("admin", "password")
var res dto.AuthenticationResult
parseInto(w, &res)
claims, err := auth.Validate(res.AccessToken)
Expect(err).ToNot(HaveOccurred())
Expect(claims.ExpiresAt.IsZero()).To(BeTrue())
Expect(claims.Audience).To(Equal([]string{"jellyfin"}))
Expect(claims.Subject).To(Equal("admin"))
})
It("revokes an already-issued token when the user's epoch is bumped", func() {
w := authenticate("admin", "password")
var res dto.AuthenticationResult
parseInto(w, &res)
r := httptest.NewRequest("GET", "/Users/Me", nil)
r.Header.Set("X-Emby-Token", res.AccessToken)
pw := httptest.NewRecorder()
router.ServeHTTP(pw, r)
Expect(pw.Code).To(Equal(http.StatusOK))
// A real password change through the repository, which is what revokes in production.
admin, err := ds.User(ctx).Get(testID("admin-1"))
Expect(err).ToNot(HaveOccurred())
admin.NewPassword = "rotated"
Expect(ds.User(ctx).Put(admin)).To(Succeed())
r = httptest.NewRequest("GET", "/Users/Me", nil)
r.Header.Set("X-Emby-Token", res.AccessToken)
pw = httptest.NewRecorder()
router.ServeHTTP(pw, r)
Expect(pw.Code).To(Equal(http.StatusUnauthorized))
})
})
Describe("GET /Users/Public", func() {

View File

@ -167,6 +167,10 @@ func (api *Router) userFromToken(r *http.Request) (model.User, bool) {
log.Warn(r.Context(), "Jellyfin API: token subject not found", "user", claims.Subject, err)
return model.User{}, false
}
if err := auth.CheckClaims(claims, *usr, auth.AudienceJellyfin); err != nil {
log.Warn(r.Context(), "Jellyfin API: rejected token", "user", claims.Subject, err)
return model.User{}, false
}
return *usr, true
}

View File

@ -95,6 +95,52 @@ var _ = Describe("authenticate middleware", func() {
api.authenticate(next).ServeHTTP(w, r)
Expect(w.Code).To(Equal(http.StatusUnauthorized))
})
Context("token scoping and revocation", func() {
var usr *model.User
BeforeEach(func() {
ur := ds.User(context.Background()).(*tests.MockedUserRepo)
usr = &model.User{ID: testID("u2"), UserName: "bob", NewPassword: "secret", TokenEpoch: 3}
Expect(ur.Put(usr)).To(Succeed())
})
serve := func(token string) *httptest.ResponseRecorder {
next := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusOK)
})
w := httptest.NewRecorder()
r := httptest.NewRequest("GET", "/Items", nil)
r.Header.Set("X-Emby-Token", token)
api.authenticate(next).ServeHTTP(w, r)
return w
}
It("accepts a jellyfin-scoped token with the current epoch", func() {
tokenStr, err := auth.CreateAPIToken(usr, auth.AudienceJellyfin)
Expect(err).ToNot(HaveOccurred())
Expect(serve(tokenStr).Code).To(Equal(http.StatusOK))
})
It("rejects a token whose epoch is stale", func() {
tokenStr, err := auth.CreateAPIToken(usr, auth.AudienceJellyfin)
Expect(err).ToNot(HaveOccurred())
usr.TokenEpoch = 4
Expect(serve(tokenStr).Code).To(Equal(http.StatusUnauthorized))
})
It("rejects a token minted for another API", func() {
tokenStr, err := auth.CreateAPIToken(usr, auth.AudienceNative)
Expect(err).ToNot(HaveOccurred())
Expect(serve(tokenStr).Code).To(Equal(http.StatusUnauthorized))
})
It("still accepts an unscoped session token", func() {
tokenStr, err := auth.CreateToken(usr)
Expect(err).ToNot(HaveOccurred())
Expect(serve(tokenStr).Code).To(Equal(http.StatusOK))
})
})
})
var _ = Describe("withPlayer middleware", func() {

View File

@ -0,0 +1,80 @@
package nativeapi
import (
"bytes"
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"path/filepath"
"time"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/conf/configtest"
"github.com/navidrome/navidrome/consts"
"github.com/navidrome/navidrome/core"
"github.com/navidrome/navidrome/core/auth"
"github.com/navidrome/navidrome/db"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/persistence"
"github.com/navidrome/navidrome/server"
"github.com/navidrome/navidrome/tests"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
type noopPluginUnloader struct{}
func (noopPluginUnloader) UnloadDisabledPlugins(context.Context) {}
// Pins that the token-epoch handoff survives a real request through the real middleware chain.
var _ = Describe("PUT /user/{id}: token refresh on self password change", func() {
var ds model.DataStore
var router http.Handler
BeforeEach(func() {
// db.Db() is a process-wide singleton that this DeferCleanup closes for the whole binary; keep this the only real-DB spec in this package.
DeferCleanup(configtest.SetupConfig())
conf.Server.EnableUserEditing = true
conf.Server.EnableSharing = false
conf.Server.SessionTimeout = time.Hour
conf.Server.DbPath = filepath.Join(GinkgoT().TempDir(), "nativeapi-user-refresh.db") + "?_journal_mode=WAL"
DeferCleanup(db.Init(GinkgoT().Context()))
ds = &tests.MockDataStore{RealDS: persistence.New(db.Db())}
auth.Init(ds)
userService := core.NewUser(ds, noopPluginUnloader{})
nativeRouter := New(ds, nil, nil, nil, tests.NewMockLibraryService(), userService, nil, nil, nil)
router = server.JWTVerifier(nativeRouter)
})
It("carries the bumped epoch in the refreshed token, not the epoch the token was minted with", func() {
usr := model.User{UserName: "selfchanger", Name: "Self Changer", NewPassword: "old-password"}
Expect(ds.User(GinkgoT().Context()).Put(&usr)).To(Succeed())
token, err := auth.CreateToken(&usr)
Expect(err).ToNot(HaveOccurred())
body, _ := json.Marshal(map[string]any{
"userName": usr.UserName,
"name": usr.Name,
"currentPassword": "old-password",
"password": "new-password",
})
req := createAuthenticatedRequest(http.MethodPut, "/user/"+usr.ID, bytes.NewBuffer(body), token)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
Expect(w.Code).To(Equal(http.StatusOK), w.Body.String())
refreshed := w.Header().Get(consts.UIAuthorizationHeader)
Expect(refreshed).ToNot(BeEmpty())
claims, err := auth.Validate(refreshed)
Expect(err).ToNot(HaveOccurred())
reloaded, err := ds.User(GinkgoT().Context()).Get(usr.ID)
Expect(err).ToNot(HaveOccurred())
Expect(reloaded.TokenEpoch).To(Equal(1))
Expect(claims.Epoch).To(Equal(reloaded.TokenEpoch))
})
})

View File

@ -178,7 +178,9 @@ func validateCredentials(user *model.User, pass, token, salt, jwt string) error
switch {
case jwt != "":
claims, err := auth.Validate(jwt)
valid = err == nil && claims.Subject == user.UserName
valid = err == nil &&
claims.Subject == user.UserName &&
auth.CheckClaims(claims, *user, auth.AudienceSubsonic) == nil
case pass != "":
if strings.HasPrefix(pass, "enc:") {
if dec, err := hex.DecodeString(pass[4:]); err == nil {

View File

@ -470,6 +470,7 @@ var _ = Describe("Middlewares", func() {
var validToken string
BeforeEach(func() {
DeferCleanup(configtest.SetupConfig())
conf.Server.SessionTimeout = time.Minute
auth.Init(ds)
@ -499,6 +500,36 @@ var _ = Describe("Middlewares", func() {
Expect(err).To(MatchError(model.ErrInvalidAuth))
})
})
Context("JWT credentials", func() {
var usr *model.User
BeforeEach(func() {
DeferCleanup(configtest.SetupConfig())
conf.Server.SessionTimeout = time.Minute
auth.Init(ds)
usr = &model.User{ID: "u1", UserName: "johndoe", TokenEpoch: 1}
})
It("accepts an unscoped session token", func() {
tokenStr, err := auth.CreateToken(usr)
Expect(err).ToNot(HaveOccurred())
Expect(validateCredentials(usr, "", "", "", tokenStr)).To(Succeed())
})
It("rejects a jellyfin-scoped token", func() {
tokenStr, err := auth.CreateAPIToken(usr, auth.AudienceJellyfin)
Expect(err).ToNot(HaveOccurred())
Expect(validateCredentials(usr, "", "", "", tokenStr)).To(MatchError(model.ErrInvalidAuth))
})
It("rejects a token with a stale epoch", func() {
tokenStr, err := auth.CreateToken(usr)
Expect(err).ToNot(HaveOccurred())
usr.TokenEpoch = 2
Expect(validateCredentials(usr, "", "", "", tokenStr)).To(MatchError(model.ErrInvalidAuth))
})
})
})
})

View File

@ -118,7 +118,7 @@ func (m *MockArtworkQueueRepo) DequeueBatch(n int, kinds ...string) ([]model.Art
return res, nil
}
func (m *MockArtworkQueueRepo) MarkFailedIfUnchanged(kind, id, imageType string, seenRetryAt, retryAt time.Time) error {
func (m *MockArtworkQueueRepo) MarkFailedIfUnchanged(kind, id, imageType string, seenRetryAt, retryAt time.Time, trace string) error {
m.mu.Lock()
defer m.mu.Unlock()
if m.Err != nil {
@ -128,6 +128,7 @@ func (m *MockArtworkQueueRepo) MarkFailedIfUnchanged(kind, id, imageType string,
if it, ok := m.Data[k]; ok && it.RetryAt.Equal(seenRetryAt) {
it.Attempts++
it.RetryAt = retryAt
it.Trace = trace
m.Data[k] = it
}
return nil
@ -166,6 +167,30 @@ func (m *MockArtworkQueueRepo) PurgeDangling() (int64, error) {
return purged, nil
}
// queueFilterMatches mirrors artworkQueueFilter, so the mock cannot let a preview and a delete disagree.
func queueFilterMatches(it model.ArtworkQueueItem, kinds []model.Kind, priorities []int) bool {
prefixes := model.KindPrefixes(kinds)
return (len(prefixes) == 0 || slices.Contains(prefixes, it.ItemKind)) &&
(len(priorities) == 0 || slices.Contains(priorities, it.Priority))
}
func (m *MockArtworkQueueRepo) PurgeQueued(kinds []model.Kind, priorities []int) (int64, error) {
m.mu.Lock()
defer m.mu.Unlock()
if m.Err != nil {
return 0, m.Err
}
var purged int64
for k, it := range m.Data {
if !queueFilterMatches(it, kinds, priorities) {
continue
}
delete(m.Data, k)
purged++
}
return purged, nil
}
func (m *MockArtworkQueueRepo) Count() (int64, error) {
m.mu.Lock()
defer m.mu.Unlock()
@ -175,7 +200,7 @@ func (m *MockArtworkQueueRepo) Count() (int64, error) {
return int64(len(m.Data)), nil
}
func (m *MockArtworkQueueRepo) CountByKindAndPriority() ([]model.ArtworkQueueStat, error) {
func (m *MockArtworkQueueRepo) CountQueued(kinds []model.Kind, priorities []int) ([]model.ArtworkQueueStat, error) {
m.mu.Lock()
defer m.mu.Unlock()
if m.Err != nil {
@ -183,6 +208,9 @@ func (m *MockArtworkQueueRepo) CountByKindAndPriority() ([]model.ArtworkQueueSta
}
var res []model.ArtworkQueueStat
for _, it := range m.Data {
if !queueFilterMatches(it, kinds, priorities) {
continue
}
i := slices.IndexFunc(res, func(s model.ArtworkQueueStat) bool {
return s.ItemKind == it.ItemKind && s.Priority == it.Priority
})
@ -244,18 +272,24 @@ func (m *MockArtworkQueueRepo) EnqueuePreservingBackoff(items ...model.ArtworkQu
return nil
}
func (m *MockArtworkQueueRepo) EnqueueStaleAbsent(kind model.Kind, attemptedBefore time.Time) (int64, error) {
func (m *MockArtworkQueueRepo) EnqueueStaleAbsent(kind model.Kind, attemptedBefore time.Time, limit int) (int64, error) {
m.mu.Lock()
defer m.mu.Unlock()
if m.Err != nil || m.ItemArtworkSource == nil {
return 0, m.Err
}
var stale []model.ItemArtwork
for _, ia := range m.ItemArtworkSource.ItemData {
if ia.ItemKind == kind.Prefix() && ia.Hash == "" && ia.AttemptedAt.Before(attemptedBefore) {
stale = append(stale, ia)
}
}
slices.SortFunc(stale, func(a, b model.ItemArtwork) int { return a.AttemptedAt.Compare(b.AttemptedAt) })
// The limit caps the selection, like the SQL's LIMIT before ON CONFLICT: queued rows use up budget.
stale = stale[:min(limit, len(stale))]
now := time.Now()
var inserted int64
for _, ia := range m.ItemArtworkSource.ItemData {
if ia.ItemKind != kind.Prefix() || ia.Hash != "" || !ia.AttemptedAt.Before(attemptedBefore) {
continue
}
for _, ia := range stale {
k := iaKey(ia.ItemKind, ia.ItemID, ia.ImageType)
if _, ok := m.Data[k]; ok { // DO NOTHING: never touch existing queue rows
continue

View File

@ -122,6 +122,20 @@ func (m *MockArtworkRepo) GetItemArtwork(kind model.Kind, id, imageType string)
return nil, model.ErrNotFound
}
func (m *MockArtworkRepo) PutLastFailure(kind model.Kind, id, imageType, trace string) error {
m.mu.Lock()
defer m.mu.Unlock()
if m.Err != nil {
return m.Err
}
key := iaKey(kind.Prefix(), id, imageType)
if ia, ok := m.ItemData[key]; ok {
ia.LastFailure = trace
m.ItemData[key] = ia
}
return nil
}
func (m *MockArtworkRepo) PutItemArtwork(ia *model.ItemArtwork) error {
m.mu.Lock()
defer m.mu.Unlock()

View File

@ -10,15 +10,32 @@ import "strings"
// or a positive value if a > b using natural sort ordering.
//
// When two numeric segments are numerically equal (e.g. "01" vs "1"),
// comparison continues with the remaining suffixes. If one or both
// strings end at the digit boundary, the raw strings are compared
// lexically, which makes leading zeros significant as a tie-breaker
// (e.g. "a01" < "a1", "a0" < "a00").
// comparison continues with the remaining suffixes, and the padding
// difference is kept as a final tie-breaker that only decides strings
// that are otherwise equal (e.g. "a01" < "a1", "a0" < "a00"). Deferring
// it that way is what keeps the ordering transitive, which SQLite
// requires of a collating function.
func Compare(a, b string) int {
return compare(a, b, false)
}
// CompareFold is Compare with ASCII case folding, matching SQLite's NOCASE
// collation: only A-Z fold, bytes >= 0x80 are compared as-is.
func CompareFold(a, b string) int {
return compare(a, b, true)
}
func compare(a, b string, fold bool) int {
ia, ib := 0, 0
// Set when two runs are numerically equal but differently padded. Applying it
// immediately would break transitivity, so it only decides otherwise-equal strings.
padTie := 0
for ia < len(a) && ib < len(b) {
ca, cb := a[ia], b[ib]
da, db := isDigit(ca), isDigit(cb)
if fold {
ca, cb = lower(ca), lower(cb)
}
switch {
case da && db:
@ -35,17 +52,11 @@ func Compare(a, b string) int {
if c := compareNumbers(a[ia:endA], b[ib:endB]); c != 0 {
return c
}
// Numerically equal. If both sides have trailing data, continue
// comparing after the digit runs. Otherwise fall through to
// lexical comparison of the full remaining strings (which makes
// leading-zero differences significant as a tie-breaker).
if endA < len(a) && endB < len(b) {
ia = endA
ib = endB
continue
if t := strings.Compare(a[ia:endA], b[ib:endB]); t != 0 {
padTie = t
}
return strings.Compare(a[ia:], b[ib:])
ia = endA
ib = endB
case da != db:
return int(ca) - int(cb)
default:
@ -56,7 +67,10 @@ func Compare(a, b string) int {
ib++
}
}
return (len(a) - ia) - (len(b) - ib)
if c := (len(a) - ia) - (len(b) - ib); c != 0 {
return c
}
return padTie
}
// compareNumbers compares two digit strings numerically.
@ -96,3 +110,10 @@ func stripZeros(s string) string {
func isDigit(c byte) bool {
return c >= '0' && c <= '9'
}
func lower(c byte) byte {
if c >= 'A' && c <= 'Z' {
return c + 'a' - 'A'
}
return c
}

View File

@ -13,17 +13,23 @@ func TestNatural(t *testing.T) {
RunSpecs(t, "Natural Suite")
}
// expectOrder asserts the sign of cmp(a, b) matches expected.
func expectOrder(cmp func(string, string) int, a, b string, expected int) {
result := cmp(a, b)
switch {
case expected < 0:
ExpectWithOffset(1, result).To(BeNumerically("<", 0), "expected %q < %q", a, b)
case expected > 0:
ExpectWithOffset(1, result).To(BeNumerically(">", 0), "expected %q > %q", a, b)
default:
ExpectWithOffset(1, result).To(Equal(0), "expected %q == %q", a, b)
}
}
var _ = Describe("Compare", func() {
DescribeTable("returns correct ordering",
func(a, b string, expected int) {
result := natural.Compare(a, b)
if expected < 0 {
Expect(result).To(BeNumerically("<", 0), "expected %q < %q", a, b)
} else if expected > 0 {
Expect(result).To(BeNumerically(">", 0), "expected %q > %q", a, b)
} else {
Expect(result).To(Equal(0), "expected %q == %q", a, b)
}
expectOrder(natural.Compare, a, b, expected)
},
// Basic string ordering
Entry("a < b", "a", "b", -1),
@ -67,7 +73,9 @@ var _ = Describe("Compare", func() {
Entry("a00b00 < a0b1", "a00b00", "a0b1", -1),
Entry("a00b00 > a0b0", "a00b00", "a0b0", 1),
Entry("a00b01 > a0b00", "a00b01", "a0b00", 1),
Entry("a00b00 == a0b00", "a00b00", "a0b00", 0),
// Distinct strings must not compare equal: the padding difference in the first
// run decides once everything else matches.
Entry("a00b00 > a0b00", "a00b00", "a0b00", 1),
// Leading zeros at end of string — lexical tie-break
Entry("file01 < file1", "file01", "file1", -1),
@ -109,8 +117,78 @@ var _ = Describe("Compare", func() {
Entry("large: equal",
"a100000000000000000000", "a100000000000000000000", 0),
Entry("large: leading zeros with trailing data",
"a00000000000000000000001x", "a1x", 0),
"a00000000000000000000001x", "a1x", -1),
Entry("large: leading zeros with trailing data (2)",
"a099999999999999999999x", "a99999999999999999999x", 0),
"a099999999999999999999x", "a99999999999999999999x", -1),
)
})
var _ = Describe("CompareFold", func() {
DescribeTable("orders case-insensitively",
func(a, b string, expected int) {
expectOrder(natural.CompareFold, a, b, expected)
},
Entry("numbers compare numerically", "foo 2", "foo 10", -1),
Entry("numbers compare numerically, reversed", "foo 10", "foo 2", 1),
Entry("case is ignored", "apple 2", "Banana 10", -1),
Entry("case is ignored, reversed", "Banana 10", "apple 2", 1),
Entry("same word, different case, is equal", "ABC", "abc", 0),
Entry("case ignored while comparing numbers", "Vol 2", "vol 10", -1),
Entry("uppercase digits boundary", "Track9", "track10", -1),
Entry("empty vs empty", "", "", 0),
Entry("empty sorts first", "", "a", -1),
Entry("non-ASCII is left untouched", "café 2", "café 10", -1),
)
// SQLite requires a collating function to be transitive; if it is not, the behavior of
// ORDER BY is undefined and paginated queries can drop or duplicate rows.
It("is transitive, as a SQLite collation requires", func() {
var corpus []string
var build func(prefix string, depth int)
build = func(prefix string, depth int) {
if prefix != "" {
corpus = append(corpus, prefix)
}
if depth == 0 {
return
}
for _, c := range []string{"0", "1", "a"} {
build(prefix+c, depth-1)
}
}
build("", 3)
sign := func(n int) int {
switch {
case n < 0:
return -1
case n > 0:
return 1
}
return 0
}
for _, a := range corpus {
for _, b := range corpus {
ab := sign(natural.CompareFold(a, b))
for _, c := range corpus {
bc := sign(natural.CompareFold(b, c))
ac := sign(natural.CompareFold(a, c))
if ab == 0 && bc == 0 {
Expect(ac).To(Equal(0), "%q==%q and %q==%q but %q vs %q is %d", a, b, b, c, a, c, ac)
}
if ab < 0 && bc < 0 {
Expect(ac).To(BeNumerically("<", 0), "%q<%q<%q but %q vs %q is %d", a, b, c, a, c, ac)
}
}
}
}
})
It("matches Compare when both sides are already lowercase", func() {
pairs := [][2]string{{"foo 2", "foo 10"}, {"a01", "a1"}, {"a", "aa"}, {"vol 3", "vol 3"}}
for _, p := range pairs {
Expect(natural.CompareFold(p[0], p[1])).To(Equal(natural.Compare(p[0], p[1])),
"CompareFold(%q,%q) should match Compare", p[0], p[1])
}
})
})